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:
1 parent
3f621c65c3
commit
81803f7ca5
7 files changed
+70
-42
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user