test(web): keep page timers running under the pinned fixture day

pinHostClock replaces Date.now in the Vitest worker, where Playwright's
server also runs. page.clock.setFixedTime therefore recorded the fixture
day, and after a navigation the page clock replayed the gap to the real
day. The resulting negative timer delay was truncated to a 32-bit integer
and, from 2026-10-15T13:45:20Z, became a delay of weeks that stopped every
page timer, failing the steering and seeded-history suites.

pinBrowserClock now replaces only the page's Date so it reads the fixture
day while native timers and animation frames keep their real schedule; a
regression test navigates twice with both clocks pinned.
This commit is contained in:
imccyu committed 2026-10-16 12:07:47 +08:00
1 parent 3f621c65c3
commit 81803f7ca5
7 files changed
+70 -42

No files matched your search

+1 -3
View File
@@ -166,7 +166,6 @@ describe('web e2e: Markdown image rendering', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let unpinBrowserClock: (() => void) | undefined
const mediaResponses = new Map<string, number>()
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)
+1 -3
View File
@@ -191,7 +191,6 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
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()
})
+38
View File
@@ -0,0 +1,38 @@
/** Pinned Host and page calendars keep page timers on their real schedule across navigations. */
import { chromium, type Browser } from 'playwright'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { newEnglishPage, pinBrowserClock, pinHostClock, WEB_FIXTURE_TIME } from './support.ts'
let browser: Browser | undefined
let hostClock: ReturnType<typeof pinHostClock> | undefined
beforeAll(async () => {
hostClock = pinHostClock()
browser = await chromium.launch()
})
afterAll(async () => {
try {
await browser?.close()
} finally {
hostClock?.mockRestore()
}
})
it('fires page timers after each navigation while reading the fixture day', async () => {
const page = await newEnglishPage(browser!)
await pinBrowserClock(page)
for (const body of ['<p>first document</p>', '<p>second document</p>']) {
await page.goto(`data:text/html,${encodeURIComponent(body)}`)
const fired = page.evaluate(() => Promise.all([
new Promise<void>((resolve) => { setTimeout(resolve, 0) }),
new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) }),
]).then(() => 'fired' as const))
// The Node timer runs on the real clock, so it bounds a stalled page.
const stalled = new Promise<'stalled'>((resolve) => { setTimeout(() => { resolve('stalled') }, 5_000) })
expect(await Promise.race([fired, stalled])).toBe('fired')
const read = await page.evaluate(() => ({ now: Date.now(), constructed: new Date().getTime() }))
expect(Math.abs(read.now - WEB_FIXTURE_TIME)).toBeLessThan(120_000)
expect(Math.abs(read.constructed - WEB_FIXTURE_TIME)).toBeLessThan(120_000)
}
})
+1 -3
View File
@@ -142,7 +142,6 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
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()
})
+1 -3
View File
@@ -230,7 +230,6 @@ describe('web e2e: seeded history renders through cold resume', () => {
let seededThroughSeq = -1
let openingWindow: unknown
let hostClock: ReturnType<typeof pinHostClock> | 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()
}
})
+3 -7
View File
@@ -73,7 +73,6 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let hostClock: MockInstance<typeof Date.now> | 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<typeof watchConsole>
let hostClock: MockInstance<typeof Date.now> | 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()
}
})
+25 -23
View File
@@ -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<typeof Date.now> {
}
/**
* 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<void> => {
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<void> {
// 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)
}
/**