From 897f7449ef7cf5d2333e8523a9f585e9c03209d1 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 13 Jun 2024 10:21:00 -0700 Subject: [PATCH] fix(clock): fix pauseAt to arrive at wall time (#31297) --- .../src/server/injected/clock.ts | 102 ++++++++++-------- tests/library/clock.spec.ts | 24 +++++ tests/page/page-clock.spec.ts | 8 +- 3 files changed, 84 insertions(+), 50 deletions(-) diff --git a/packages/playwright-core/src/server/injected/clock.ts b/packages/playwright-core/src/server/injected/clock.ts index 5b8abd3271d35..e566597e8dd55 100644 --- a/packages/playwright-core/src/server/injected/clock.ts +++ b/packages/playwright-core/src/server/injected/clock.ts @@ -45,8 +45,8 @@ type Timer = { func: TimerHandler; args: any[]; delay: number; - callAt: number; - createdAt: number; + callAt: Ticks; + createdAt: Ticks; id: number; error?: Error; }; @@ -58,15 +58,15 @@ interface Embedder { setInterval(task: () => void, delay: number): () => void; } +type Ticks = number & { readonly __brand: 'Ticks' }; +type EmbedderTicks = number & { readonly __brand: 'EmbedderTicks' }; +type WallTime = number & { readonly __brand: 'WallTime' }; + type Time = { - // ms since Epoch - time: number; - // Ticks since the session began (ala performance.now) - ticks: number; - // Whether fixed time was set. + time: WallTime; + ticks: Ticks; isFixedTime: boolean; - // Origin time since Epoch when session started. - origin: number; + origin: WallTime; }; type LogEntryType = 'fastForward' |'install' | 'pauseAt' | 'resume' | 'runFor' | 'setFixedTime' | 'setSystemTime'; @@ -79,11 +79,11 @@ export class ClockController { private _embedder: Embedder; readonly disposables: (() => void)[] = []; private _log: { type: LogEntryType, time: number, param?: number }[] = []; - private _realTime: { startTicks: number, lastSyncTicks: number } | undefined; - private _currentRealTimeTimer: { callAt: number, dispose: () => void } | undefined; + private _realTime: { startTicks: EmbedderTicks, lastSyncTicks: EmbedderTicks } | undefined; + private _currentRealTimeTimer: { callAt: Ticks, dispose: () => void } | undefined; constructor(embedder: Embedder) { - this._now = { time: 0, isFixedTime: false, ticks: 0, origin: -1 }; + this._now = { time: asWallTime(0), isFixedTime: false, ticks: 0 as Ticks, origin: asWallTime(-1) }; this._embedder = embedder; } @@ -99,17 +99,17 @@ export class ClockController { install(time: number) { this._replayLogOnce(); - this._innerSetTime(time); + this._innerSetTime(asWallTime(time)); } setSystemTime(time: number) { this._replayLogOnce(); - this._innerSetTime(time); + this._innerSetTime(asWallTime(time)); } setFixedTime(time: number) { this._replayLogOnce(); - this._innerSetFixedTime(time); + this._innerSetFixedTime(asWallTime(time)); } performanceNow(): DOMHighResTimeStamp { @@ -117,22 +117,22 @@ export class ClockController { return this._now.ticks; } - private _innerSetTime(time: number) { + private _innerSetTime(time: WallTime) { this._now.time = time; this._now.isFixedTime = false; if (this._now.origin < 0) this._now.origin = this._now.time; } - private _innerSetFixedTime(time: number) { + private _innerSetFixedTime(time: WallTime) { this._innerSetTime(time); this._now.isFixedTime = true; } - private _advanceNow(toTicks: number) { + private _advanceNow(to: Ticks) { if (!this._now.isFixedTime) - this._now.time += toTicks - this._now.ticks; - this._now.ticks = toTicks; + this._now.time = asWallTime(this._now.time + to - this._now.ticks); + this._now.ticks = to; } async log(type: LogEntryType, time: number, param?: number) { @@ -143,30 +143,32 @@ export class ClockController { this._replayLogOnce(); if (ticks < 0) throw new TypeError('Negative ticks are not supported'); - await this._runTo(this._now.ticks + ticks); + await this._runTo(shiftTicks(this._now.ticks, ticks)); } - private async _runTo(tickTo: number) { - if (this._now.ticks > tickTo) + private async _runTo(to: Ticks) { + if (this._now.ticks > to) return; let firstException: Error | undefined; while (true) { - const result = await this._callFirstTimer(tickTo); + const result = await this._callFirstTimer(to); if (!result.timerFound) break; firstException = firstException || result.error; } - this._advanceNow(tickTo); + this._advanceNow(to); if (firstException) throw firstException; } - async pauseAt(time: number) { + async pauseAt(time: number): Promise { this._replayLogOnce(); this._innerPause(); - await this._innerFastForwardTo(time); + const toConsume = time - this._now.time; + await this._innerFastForwardTo(shiftTicks(this._now.ticks, toConsume)); + return toConsume; } private _innerPause() { @@ -180,7 +182,7 @@ export class ClockController { } private _innerResume() { - const now = this._embedder.performanceNow(); + const now = this._embedder.performanceNow() as EmbedderTicks; this._realTime = { startTicks: now, lastSyncTicks: now }; this._updateRealTimeTimer(); } @@ -195,7 +197,7 @@ export class ClockController { const firstTimer = this._firstTimer(); // Either run the next timer or move time in 100ms chunks. - const callAt = Math.min(firstTimer ? firstTimer.callAt : this._now.ticks + maxTimeout, this._now.ticks + 100); + const callAt = Math.min(firstTimer ? firstTimer.callAt : this._now.ticks + maxTimeout, this._now.ticks + 100) as Ticks; if (this._currentRealTimeTimer && this._currentRealTimeTimer.callAt < callAt) return; @@ -207,30 +209,30 @@ export class ClockController { this._currentRealTimeTimer = { callAt, dispose: this._embedder.setTimeout(() => { - const now = Math.ceil(this._embedder.performanceNow()); + const now = Math.ceil(this._embedder.performanceNow()) as EmbedderTicks; this._currentRealTimeTimer = undefined; const sinceLastSync = now - this._realTime!.lastSyncTicks; this._realTime!.lastSyncTicks = now; // eslint-disable-next-line no-console - this._runTo(this._now.ticks + sinceLastSync).catch(e => console.error(e)).then(() => this._updateRealTimeTimer()); + this._runTo(shiftTicks(this._now.ticks, sinceLastSync)).catch(e => console.error(e)).then(() => this._updateRealTimeTimer()); }, callAt - this._now.ticks), }; } async fastForward(ticks: number) { this._replayLogOnce(); - await this._innerFastForwardTo(this._now.ticks + ticks | 0); + await this._innerFastForwardTo(shiftTicks(this._now.ticks, ticks | 0)); } - private async _innerFastForwardTo(toTicks: number) { - if (toTicks < this._now.ticks) + private async _innerFastForwardTo(to: Ticks) { + if (to < this._now.ticks) throw new Error('Cannot fast-forward to the past'); for (const timer of this._timers.values()) { - if (toTicks > timer.callAt) - timer.callAt = toTicks; + if (to > timer.callAt) + timer.callAt = to; } - await this._runTo(toTicks); + await this._runTo(to); } addTimer(options: { func: TimerHandler, type: TimerType, delay?: number | string, args?: any[] }): number { @@ -249,7 +251,7 @@ export class ClockController { func: options.func, args: options.args || [], delay, - callAt: this._now.ticks + (delay || (this._duringTick ? 1 : 0)), + callAt: shiftTicks(this._now.ticks, (delay || (this._duringTick ? 1 : 0))), createdAt: this._now.ticks, id: this._uniqueTimerId++, error: new Error(), @@ -283,7 +285,7 @@ export class ClockController { this._advanceNow(timer.callAt); if (timer.type === TimerType.Interval) - this._timers.get(timer.id)!.callAt += timer.delay; + timer.callAt = shiftTicks(timer.callAt, timer.delay); else this._timers.delete(timer.id); return timer; @@ -375,29 +377,29 @@ export class ClockController { for (const { type, time, param } of this._log) { if (!isPaused && lastLogTime !== -1) - this._advanceNow(this._now.ticks + time - lastLogTime); + this._advanceNow(shiftTicks(this._now.ticks, time - lastLogTime)); lastLogTime = time; if (type === 'install') { - this._innerSetTime(param!); + this._innerSetTime(asWallTime(param!)); } else if (type === 'fastForward' || type === 'runFor') { - this._advanceNow(this._now.ticks + param!); + this._advanceNow(shiftTicks(this._now.ticks, param!)); } else if (type === 'pauseAt') { isPaused = true; this._innerPause(); - this._innerSetTime(param!); + this._innerSetTime(asWallTime(param!)); } else if (type === 'resume') { this._innerResume(); isPaused = false; } else if (type === 'setFixedTime') { - this._innerSetFixedTime(param!); + this._innerSetFixedTime(asWallTime(param!)); } else if (type === 'setSystemTime') { - this._innerSetTime(param!); + this._innerSetTime(asWallTime(param!)); } } if (!isPaused && lastLogTime > 0) - this._advanceNow(this._now.ticks + this._embedder.dateNow() - lastLogTime); + this._advanceNow(shiftTicks(this._now.ticks, this._embedder.dateNow() - lastLogTime)); this._log.length = 0; } @@ -710,3 +712,11 @@ export function inject(globalObject: WindowOrWorkerGlobalScope) { builtin: platformOriginals(globalObject).bound, }; } + +function asWallTime(n: number): WallTime { + return n as WallTime; +} + +function shiftTicks(ticks: Ticks, ms: number): Ticks { + return ticks + ms as Ticks; +} diff --git a/tests/library/clock.spec.ts b/tests/library/clock.spec.ts index ba3a17335e4cf..9fd8426a924f9 100644 --- a/tests/library/clock.spec.ts +++ b/tests/library/clock.spec.ts @@ -1350,6 +1350,30 @@ it.describe('fastForward', () => { }); }); +it.describe('pauseAt', () => { + it('pause at target time', async ({ clock }) => { + clock.install(0); + await clock.pauseAt(1000); + expect(clock.Date.now()).toBe(1000); + }); + + it('fire target timers', async ({ clock }) => { + clock.install(0); + const stub = createStub(); + clock.setTimeout(stub, 1000); + clock.setTimeout(stub, 1001); + await clock.pauseAt(1000); + expect(stub.callCount).toBe(1); + }); + + it('returns consumed clicks', async ({ clock }) => { + const now = Date.now(); + clock.install(now); + const consumedTicks = await clock.pauseAt(now + 1000 * 60 * 60 * 24); + expect(consumedTicks).toBe(1000 * 60 * 60 * 24); + }); +}); + it.describe('performance.now()', () => { it('should start at 0', async ({ clock }) => { const result = clock.performance.now(); diff --git a/tests/page/page-clock.spec.ts b/tests/page/page-clock.spec.ts index 58a0d130e2586..670891a40a229 100644 --- a/tests/page/page-clock.spec.ts +++ b/tests/page/page-clock.spec.ts @@ -313,13 +313,13 @@ it.describe('stubTimers', () => { }); await page.clock.runFor(1000); expect(await page.evaluate(() => performance.timeOrigin)).toBe(1000); - expect(await promise).toEqual({ prev: 2000, next: 3000 }); + expect(await promise).toEqual({ prev: 1000, next: 2000 }); }); }); it.describe('popup', () => { it('should tick after popup', async ({ page }) => { - await page.clock.install(); + await page.clock.install({ time: 0 }); const now = new Date('2015-09-25'); await page.clock.pauseAt(now); const [popup] = await Promise.all([ @@ -334,7 +334,7 @@ it.describe('popup', () => { }); it('should tick before popup', async ({ page }) => { - await page.clock.install(); + await page.clock.install({ time: 0 }); const now = new Date('2015-09-25'); await page.clock.pauseAt(now); await page.clock.runFor(1000); @@ -368,7 +368,7 @@ it.describe('popup', () => { res.setHeader('Content-Type', 'text/html'); res.end(``); }); - await page.clock.install(); + await page.clock.install({ time: 0 }); await page.clock.pauseAt(1000); await page.goto(server.EMPTY_PAGE); // Wait for 2 second in real life to check that it is past in popup.