mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user