From ca004cf221c9cb69db3ff9a1eb9c695869292ea3 Mon Sep 17 00:00:00 2001 From: xarmian Date: Wed, 23 Sep 2026 19:00:57 -0400 Subject: [PATCH] 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 --- web/e2e/lib/paneSettle.ts | 83 +++++++++++++++++++++++++ web/e2e/pane-async-race.spec.ts | 54 ++++++++++++++-- web/e2e/pane-full-page-capstone.spec.ts | 11 ++-- 3 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 web/e2e/lib/paneSettle.ts diff --git a/web/e2e/lib/paneSettle.ts b/web/e2e/lib/paneSettle.ts new file mode 100644 index 000000000..27fadd8ef --- /dev/null +++ b/web/e2e/lib/paneSettle.ts @@ -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, + ); +} diff --git a/web/e2e/pane-async-race.spec.ts b/web/e2e/pane-async-race.spec.ts index 5c754bac7..b8542c1e7 100644 --- a/web/e2e/pane-async-race.spec.ts +++ b/web/e2e/pane-async-race.spec.ts @@ -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 diff --git a/web/e2e/pane-full-page-capstone.spec.ts b/web/e2e/pane-full-page-capstone.spec.ts index 328d0801d..3470fc1e2 100644 --- a/web/e2e/pane-full-page-capstone.spec.ts +++ b/web/e2e/pane-full-page-capstone.spec.ts @@ -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 });