test(BUG-3166): the drill-after-Back race legs time the drill in the page from the settle's arming; a drill after the window mints the popped item first (#1476)

* test(BUG-3166): drill-after-Back legs time the drill in the page from the settle's arming; add the after-window leg

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* test(BUG-3166): the settle helper latches only a 140ms timer armed inside the Back's popstate dispatch (codex r1: the pane-follow debounce is also 140ms)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX

* test(BUG-3166): the settle helper clears its safety timer and popstate listener on either outcome (codex r2)

Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX
This commit is contained in:
xarmian
2026-09-23 19:00:57 -04:00
committed by GitHub
parent e346b858da
commit ca004cf221
3 changed files with 140 additions and 8 deletions
+83
View File
@@ -0,0 +1,83 @@
import type { Page } from '@playwright/test';
/** `PANE_MINT_SETTLE_MS` (src/lib/collections/paneMintSettle.ts). */
export const PANE_MINT_SETTLE_MS = 140;
/**
* Browser Back, then a pane drill `delayMs` after the Back's mint-settle is
* ARMED — both timed in the page (BUG-3166), so no Playwright round-trip sits
* between them. The settle is recognised as the `PANE_MINT_SETTLE_MS`
* setTimeout the Back's afterNavigate arms; its callback is wrapped to record
* whether it had FIRED by the time the drill ran, which is the precondition
* that decides what the drill may observe (a busy main thread delays both
* timers, so the delay alone does not say which ran first).
*
* Which 140ms timer is the settle (codex r1): the collection host's j/k
* pane-follow debounce is ALSO 140ms, so the wrapper latches only a timer armed
* INSIDE the Back's popstate dispatch — after `history.back()` is called and
* before this helper's own popstate listener runs. SvelteKit's listener was
* registered first and its navigation resolves in microtasks, which run to
* afterNavigate (where the settle is armed) before the next listener (measured:
* armed at 5ms, our listener at 6ms). A timer armed before the Back is never
* taken; if a future change moves the arming out of that window, the helper
* rejects rather than latching the wrong timer.
*/
export async function backThenDrillAfterSettleArms(
page: Page,
ref: string,
delayMs: number,
): Promise<{ drillAfterArmMs: number; settleFiredFirst: boolean }> {
return page.evaluate(
([r, delay, settleMs]) =>
new Promise<{ drillAfterArmMs: number; settleFiredFirst: boolean }>((resolve, reject) => {
const orig = window.setTimeout;
const restore = () => {
window.setTimeout = orig;
};
let fired = false;
let inBackDispatch = false;
window.setTimeout = ((fn: TimerHandler, ms?: number, ...rest: unknown[]) => {
if (!inBackDispatch || ms !== settleMs || typeof fn !== 'function') return orig(fn, ms, ...rest);
restore();
const armed = performance.now();
const settle = fn as (...a: unknown[]) => void;
const id = orig(
(...a: unknown[]) => {
fired = true;
settle(...a);
},
ms,
...rest,
);
orig(() => {
const settleFiredFirst = fired;
(
window as unknown as { __padPaneController?: { navigatePaneTo(x: string): void } }
).__padPaneController?.navigatePaneTo(r);
resolve({ drillAfterArmMs: performance.now() - armed, settleFiredFirst });
}, delay);
return id;
}) as typeof window.setTimeout;
const onPopstate = () => {
// Registered after SvelteKit's, so this runs once its handling of
// the Back (settle included) is done: the window closes here.
clearTimeout(safety);
inBackDispatch = false;
if (window.setTimeout !== orig) {
restore();
reject(new Error('the back-settle was not armed inside the popstate dispatch'));
}
};
window.addEventListener('popstate', onPopstate, { once: true });
const safety = orig(() => {
window.removeEventListener('popstate', onPopstate);
inBackDispatch = false;
restore();
reject(new Error('no popstate within 5s of history.back()'));
}, 5000);
inBackDispatch = true;
history.back();
}),
[ref, delayMs, PANE_MINT_SETTLE_MS] as const,
);
}
+50 -4
View File
@@ -7,6 +7,7 @@ import {
} from './lib/collab-helpers';
import type { APIRequestContext, Page } from '@playwright/test';
import type { SuiteFixture } from './fixtures';
import { backThenDrillAfterSettleArms } from './lib/paneSettle';
/**
* Pane mini-browser — async-race hardening suite (PLAN-2154 R14 / TASK-2167).
@@ -394,11 +395,16 @@ test.describe('pane async-race hardening (PLAN-2154 R14 / TASK-2167)', () => {
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Back to A (depth 0) — a popstate that arms the mint-settle for A — then
// IMMEDIATELY drill to C, inside the settle window.
// drill to C INSIDE the settle window, 20ms before it would fire
// (BUG-3166). The drill is scheduled in the page from the moment the
// settle is armed, not by two Playwright steps: those spent the window on
// round-trips, so on a loaded box the drill often ran AFTER the settle
// had fired — when minting A first is correct — and the test failed on
// the harness's timing. Measured over 40 runs, every A fetch had the
// drill running after the fire; none had it before.
const getsBeforeBack = itemGets.length;
await page.goBack();
await expect.poll(() => openItemParam(page)).toBe(refA);
await drillTo(page, c.slug);
const drill = await backThenDrillAfterSettleArms(page, c.slug, FOLLOW_DEBOUNCE_MS - 20);
expect(drill.settleFiredFirst, `precondition: the drill ran inside the settle window (${Math.round(drill.drillAfterArmMs)}ms after arming)`).toBe(false);
await expect.poll(() => openItemParam(page)).toBe(c.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
@@ -418,6 +424,46 @@ test.describe('pane async-race hardening (PLAN-2154 R14 / TASK-2167)', () => {
expect(postBack).not.toContain(refA);
});
// ── Scenario 2b — the same drill AFTER the settle window (BUG-3166) ─────
// The other half of the contract: a reader who stays on A past the window
// DOES get A minted (fetched), and the later drill still lands on C. Pins
// that "never fetch A" above is about the window, so a future window change
// cannot turn correct behaviour into a "fix".
test('a drill fired AFTER the back-settle window: A is minted first, then the drill lands on the new target', async ({
page,
fixture,
request,
}) => {
await page.setViewportSize(DESKTOP);
await enableHook(page);
await browserLogin(page);
const { collSlug, seeded } = await seedFreshCollection(fixture, request, 'Race drilllate', 'RDLT', [
{ title: 'Race drilllate alpha' },
{ title: 'Race drilllate bravo' },
{ title: 'Race drilllate charlie' },
]);
const [, b, c] = seeded;
await page.goto(collUrl(fixture, collSlug));
const itemGets = trackItemGets(page);
await page.locator('.item-card', { hasText: 'Race drilllate alpha' }).first().click();
const pane = page.locator('.item-pane');
await expect(pane).toBeVisible();
const refA = openItemParam(page);
await drillTo(page, b.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
const getsBeforeBack = itemGets.length;
const drill = await backThenDrillAfterSettleArms(page, c.slug, FOLLOW_DEBOUNCE_MS + 160);
expect(drill.settleFiredFirst, `precondition: the settle fired before the drill (${Math.round(drill.drillAfterArmMs)}ms after arming)`).toBe(true);
await expect.poll(() => openItemParam(page)).toBe(c.slug);
await expect(pane.locator('.title', { hasText: /Race drilllate charlie/ })).toBeVisible();
const postBack = itemGets.slice(getsBeforeBack);
expect(postBack, 'the settle minted A before the late drill').toContain(refA);
expect(postBack.lastIndexOf(c.slug)).toBeGreaterThan(postBack.indexOf(refA!));
});
// ── Scenario 3 — a close fired during an in-flight history.go + latch ────
// A cold-loaded, then drilled pane closes via a TWO-PHASE cold-base
// `history.go(-depth)` + a latched `replaceState`-delete fired on the
+7 -4
View File
@@ -2,6 +2,7 @@ import { test, expect } from './fixtures';
import { browserLogin, seedDoc, EDITOR_SELECTOR, SYNCED_BADGE_SELECTOR } from './lib/collab-helpers';
import type { APIRequestContext, Page } from '@playwright/test';
import type { SuiteFixture } from './fixtures';
import { backThenDrillAfterSettleArms } from './lib/paneSettle';
/**
* Full-page pane HOST — Phase-2 CAPSTONE runtime verification
@@ -673,11 +674,13 @@ test.describe('full-page pane host CAPSTONE (PLAN-2154 Phase 2 / TASK-2175)', ()
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
// Back to A (depth 0) — a popstate that arms the mint-settle for A — then
// IMMEDIATELY drill to C, inside the settle window.
// drill to C INSIDE the settle window, timed in the page from the moment
// the settle is armed (BUG-3166: two Playwright steps spent the window on
// round-trips, so on a loaded box the drill often ran after the settle
// had fired, when minting A first is correct).
const getsBeforeBack = itemGets.length;
await page.goBack();
await expect.poll(() => openItemParam(page)).toBe(refA);
await drillTo(page, c.slug);
const drill = await backThenDrillAfterSettleArms(page, c.slug, FOLLOW_DEBOUNCE_MS - 20);
expect(drill.settleFiredFirst, `precondition: the drill ran inside the settle window (${Math.round(drill.drillAfterArmMs)}ms after arming)`).toBe(false);
await expect.poll(() => openItemParam(page)).toBe(c.slug);
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });