diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 390675a3d1..db3cad441a 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -166,7 +166,6 @@ describe('web e2e: Markdown image rendering', () => { let browser: Browser let page: Page let tripwire: ReturnType - let unpinBrowserClock: (() => void) | undefined const mediaResponses = new Map() beforeAll(async () => { @@ -191,7 +190,7 @@ describe('web e2e: Markdown image rendering', () => { { createdAt: WEB_FIXTURE_TIME - 60_000 }) browser = await chromium.launch() page = await newEnglishPage(browser) - unpinBrowserClock = await pinBrowserClock(page) + await pinBrowserClock(page) tripwire = watchConsole(page) page.on('response', (response) => { const url = new URL(response.url()) @@ -204,7 +203,6 @@ describe('web e2e: Markdown image rendering', () => { }, 120_000) afterAll(async () => { - unpinBrowserClock?.() await browser?.close() await scaffold?.close() if (imageOrigin !== undefined) await stopServer(imageOrigin.server) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 2254c9dafe..d70558dab4 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -191,7 +191,6 @@ describe('web e2e: message IconActions and clocks on settled history', () => { let browser: Browser let page: Page let tripwire: ReturnType - let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { scaffold = await launchWebScaffold() @@ -212,14 +211,13 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME - 90_000 }) browser = await chromium.launch() page = await newEnglishPage(browser) - unpinBrowserClock = await pinBrowserClock(page) + await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) afterAll(async () => { - unpinBrowserClock?.() await browser?.close() await scaffold?.close() }) diff --git a/apps/web/tests/pinned-clock.e2e.ts b/apps/web/tests/pinned-clock.e2e.ts new file mode 100644 index 0000000000..5488f20e02 --- /dev/null +++ b/apps/web/tests/pinned-clock.e2e.ts @@ -0,0 +1,60 @@ +/** Fixture-day clock pins keep page scheduling native at every real-to-fixture distance. */ +import { chromium, type Browser } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { newEnglishPage, pinBrowserClock, WEB_FIXTURE_TIME } from './support.ts' + +// Host clock shifts spread over one 2^32 ms period of the real-to-fixture +// distance; Playwright's page clock stopped page timers in half of that period. +const DISTANCE_PERIOD_MS = 2 ** 32 +const HOST_SHIFTS_MS = Array.from({ length: 8 }, (_, index) => index * DISTANCE_PERIOD_MS / 8) + +describe('web e2e: pinned fixture clocks', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch() + }, 120_000) + + afterAll(async () => { + await browser?.close() + }) + + it.each(HOST_SHIFTS_MS)('runs page timers and reads the fixture day with the Host clock %d ms behind it', async (shift) => { + const hostStartedAt = performance.now() + const hostClock = vi.spyOn(Date, 'now').mockImplementation(() => + WEB_FIXTURE_TIME - shift + Math.floor(performance.now() - hostStartedAt)) + const page = await newEnglishPage(browser) + try { + await pinBrowserClock(page) + for (const navigation of ['initial', 'replacement']) { + await page.goto(`data:text/html,${navigation}`) + const pageStartedAt = await page.evaluate(() => { + requestAnimationFrame(() => { document.body.dataset.frame = 'done' }) + setTimeout(() => { document.body.dataset.timer = 'done' }, 20) + return Date.now() + }) + await expect.poll(() => page.evaluate(() => ({ + frame: document.body.dataset.frame, + timer: document.body.dataset.timer, + })), { timeout: 5_000 }).toEqual({ frame: 'done', timer: 'done' }) + const dates = await page.evaluate(() => ({ + now: Date.now(), + day: new Date().toISOString().slice(0, 10), + explicit: new Date(0).getTime(), + parsed: Date.parse('2000-01-01T00:00:00Z'), + })) + expect(pageStartedAt).toBeGreaterThanOrEqual(WEB_FIXTURE_TIME) + expect(dates.now).toBeGreaterThan(pageStartedAt) + expect(dates.day).toBe(new Date(WEB_FIXTURE_TIME).toISOString().slice(0, 10)) + expect(dates.explicit).toBe(0) + expect(dates.parsed).toBe(946_684_800_000) + } + } finally { + try { + await page.close() + } finally { + hostClock.mockRestore() + } + } + }, 60_000) +}) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 6381deb606..a1e83e6bd0 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -142,7 +142,6 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through let browser: Browser let page: Page let tripwire: ReturnType - let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -151,7 +150,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID, undefined, { createdAt: targetCreatedAt }) browser = await chromium.launch() page = await newEnglishPage(browser) - unpinBrowserClock = await pinBrowserClock(page) + await pinBrowserClock(page) tripwire = watchConsole(page) // Fixture files land before the workspace connects so the Host's file // index never races their creation (the connect helper mkdirs the same @@ -171,7 +170,6 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through }, 120_000) afterAll(async () => { - unpinBrowserClock?.() await browser?.close() await scaffold?.close() }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 4c6fff7f18..5dbee101e4 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -230,7 +230,6 @@ describe('web e2e: seeded history renders through cold resume', () => { let seededThroughSeq = -1 let openingWindow: unknown let hostClock: ReturnType | undefined - let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { // Seeded and later command timestamps share the renderer's fixture day across midnight. @@ -271,7 +270,7 @@ describe('web e2e: seeded history renders through cold resume', () => { } browser = await chromium.launch() page = await newEnglishPage(browser) - if (MODE !== 'record') unpinBrowserClock = await pinBrowserClock(page) + if (MODE !== 'record') await pinBrowserClock(page) tripwire = watchConsole(page) if (MODE !== 'record') { // A one-message tail makes this short recording exercise the real Load earlier path. @@ -303,7 +302,6 @@ describe('web e2e: seeded history renders through cold resume', () => { await browser?.close() await scaffold?.close() } finally { - unpinBrowserClock?.() hostClock?.mockRestore() } }) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index b2790aea83..2f0921321e 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -73,7 +73,6 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { let page: Page let tripwire: ReturnType let hostClock: MockInstance | undefined - let unpinBrowserClock: (() => void) | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { @@ -87,7 +86,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) - unpinBrowserClock = await pinBrowserClock(page) + await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -101,8 +100,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await scaffold?.close() } finally { // A failing close (unconsumed fixture, mismatched snapshot) must not - // leave the pinned clocks running for the suites after this one. - unpinBrowserClock?.() + // leave the pinned Host clock running for the suites after this one. hostClock?.mockRestore() } }) @@ -396,7 +394,6 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { let page: Page let tripwire: ReturnType let hostClock: MockInstance | undefined - let unpinBrowserClock: (() => void) | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { @@ -416,7 +413,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) - unpinBrowserClock = await pinBrowserClock(page) + await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -431,7 +428,6 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => { await browser?.close() await scaffold?.close() } finally { - unpinBrowserClock?.() hostClock?.mockRestore() } }) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index d60a81dba5..33cfdeb374 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -50,9 +50,6 @@ export const WEB_FIXTURE_TIME = Date.parse('2026-01-15T12:00:00+08:00') */ export const PLUGIN_TOGGLE_SETTLE_MS = 10_000 -/** How often a pinned browser clock advances, in milliseconds. */ -const PINNED_CLOCK_STEP_MS = 250 - /** * Pin the Host clock to the shared fixture day for one scenario suite. * @@ -74,31 +71,36 @@ export function pinHostClock(): MockInstance { } /** - * Pin a scenario page's clock to the shared fixture day, advancing with real + * Pin a scenario page's calendar to the shared fixture day, advancing with real * time. Pair it with {@link pinHostClock} for a scenario that stamps its own * message times, and with `seedSession`'s `createdAt` for a seeded one. * - * The frozen instant is re-applied from the anchor on an interval rather than - * left at one value, because product logic reads two `Date.now()` values to - * decide a minimum display hold or a deadline window; a permanently frozen - * clock stops supplying elapsed time, so a title that owes its golden settled - * text keeps rendering its previous one. Both clocks therefore advance at the - * real rate and stay within one step of each other. - * @param page - page whose clock is pinned. - * @returns the disposer that stops re-pinning; call it in teardown. + * Only the page's `Date` reads the fixture day; native timers and + * `requestAnimationFrame` keep their real scheduling. Playwright's + * `page.clock` is not used: its server records clock state with the Vitest + * worker's `Date.now`, which {@link pinHostClock} replaces, so after a + * navigation the page clock replays the gap between the real and fixture days + * and hands the resulting negative delay to native `setTimeout`. The browser + * truncates that delay to a 32-bit integer, and in about half of the dates the + * result is a positive delay of weeks that stops every page timer. The `Date` + * override is installed for every later document and on the current one, and + * stays until the page closes. + * @param page - page whose calendar is pinned. */ -export async function pinBrowserClock(page: Page): Promise<() => void> { - const startedAt = Date.now() - const step = async (): Promise => { - try { - await page.clock.setFixedTime(WEB_FIXTURE_TIME + (Date.now() - startedAt)) - } catch { - // The page closed under the interval; teardown owns the last word. - } +export async function pinBrowserClock(page: Page): Promise { + // The worker's `new Date()` keeps the real clock even when `Date.now` is pinned. + const offset = WEB_FIXTURE_TIME - new Date().getTime() + const install = (offset: number): void => { + const NativeDate = Date + const now = (): number => NativeDate.now() + offset + globalThis.Date = new Proxy(NativeDate, { + construct: (target, args, newTarget): Date => Reflect.construct(target, args.length === 0 ? [now()] : args, newTarget) as Date, + apply: () => new NativeDate(now()).toString(), + get: (target, property, receiver): unknown => property === 'now' ? now : Reflect.get(target, property, receiver), + }) } - await step() - const timer = setInterval(() => { void step() }, PINNED_CLOCK_STEP_MS) - return () => { clearInterval(timer) } + await page.addInitScript(install, offset) + await page.evaluate(install, offset) } /**