// Technique 2: a fake clock. // Playwright's page.clock replaces requestAnimationFrame, performance.now, Date and the timers in the page. Paused, // it moves only when a test calls runFor or fastForward, so the game sees exactly the time the test chooses. // // How fake time becomes the model's `Time t` events (worked out from Playwright 1.65's clock and the three glues): // - The fake clock counts integer milliseconds ("ticks"). A requestAnimationFrame callback is due at the next // multiple of 16 ticks after the request (`16 - ticks % 16` later) and receives the tick count as its timestamp. // - Each track's glue (js/solutions/browser.js, scala/web/BrowserPlatform.scala, fsharp/web/BrowserPlatform.fs) // remembers the timestamp T0 of its first callback and feeds `Time(T - T0)` for every callback at tick T, and asks // for the next frame inside the callback (so the next one is due 16 ticks later). Scala and F# truncate to a whole // number; fake timestamps are whole numbers already, so all three see the same values. // - Hence: the first frame is Time 0, and from a moment at tick N, `runFor(ms)` fires one Time(T - T0) for every // multiple of 16 T in (N, N + ms], in order; `fastForward(ms)` fires the single pending frame once, at N + ms // (Time(N + ms - T0)) — that is how a test makes one long frame, like a tab that was hidden. // - Keys and clicks sent between two clock calls reach the page after every frame up to the current tick, so the // model is fed them in that order. // This class mirrors that arithmetic and returns, for each move of the clock, the exact list of Time events the page // received; the oracle is fed those. The technique test (tests/techniques.spec.mjs) checks the mapping against the // timestamps of the recorded frames on every track, so a change in Playwright's clock shows up there first. /** Interval of Playwright's fake requestAnimationFrame, in ms. */ export const FRAME = 16 export class FakeClock { constructor(page) { this.page = page this.now = null // current tick, known after start() this.t0 = null // tick of the page's first animation frame } /** Install a paused clock for every document the page loads from now on. Call before page.goto. */ async install() { await this.page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }) await this.page.clock.pauseAt(new Date('2026-01-01T00:00:01Z')) } /** The tick the page's clock shows now. */ ticks() { return this.page.evaluate(() => performance.now()) } /** * Step the clock one frame at a time until the page has drawn its first frame (`drawn()` returns that frame's tick, * or null while nothing is drawn). Before that, the page may still be loading its script or the replay viewer its * recording; frames that fire before the program asked for one do not exist for it. Returns [Time 0]. */ async start(drawn, tries = 200) { this.now = await this.ticks() for (let i = 0; i < tries; i++) { const at = await drawn() if (at !== null) { if (this.now !== at) throw new Error(`fake clock: expected to stop on the first frame (tick ${at}), but it shows ${this.now}`) this.t0 = at return [{ tag: 'Time', t: 0 }] } // Up to the next multiple of 16, where a frame fires if the program has asked for one. const step = FRAME - (this.now % FRAME) await this.page.clock.runFor(step) this.now += step } throw new Error('the page drew nothing within 200 animation frames of fake time') } /** The Time events the page receives while the clock runs from tick `from` to tick `to` frame by frame. */ timesBetween(from, to) { const first = Math.floor(from / FRAME) + 1 const last = Math.floor(to / FRAME) return Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => ({ tag: 'Time', t: (first + i) * FRAME - this.t0 })) } /** Let `ms` of fake time pass, one animation frame every 16 ms. Returns the Time events the page received. */ async advance(ms) { const events = this.timesBetween(this.now, this.now + ms) await this.page.clock.runFor(ms) this.now += ms return events } /** Advance to the next tick that is a multiple of 16 after `this.now + ms` (inclusive), so a frame fires there. */ async advanceToFrameAfter(ms) { const target = Math.ceil((this.now + ms) / FRAME) * FRAME return this.advance(target - this.now) } /** Jump `ms` ahead in one go: the pending animation frame fires once, at the end. Returns that one Time event. */ async jump(ms) { await this.page.clock.fastForward(ms) this.now += ms return [{ tag: 'Time', t: this.now - this.t0 }] } }