fix(clock): fix pauseAt to arrive at wall time (#31297)

This commit is contained in:
Pavel Feldman 2024-06-13 10:21:00 -07:00 committed by GitHub
parent 8ea663aa64
commit 897f7449ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 50 deletions

View file

@ -45,8 +45,8 @@ type Timer = {
func: TimerHandler; func: TimerHandler;
args: any[]; args: any[];
delay: number; delay: number;
callAt: number; callAt: Ticks;
createdAt: number; createdAt: Ticks;
id: number; id: number;
error?: Error; error?: Error;
}; };
@ -58,15 +58,15 @@ interface Embedder {
setInterval(task: () => void, delay: number): () => void; 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 = { type Time = {
// ms since Epoch time: WallTime;
time: number; ticks: Ticks;
// Ticks since the session began (ala performance.now)
ticks: number;
// Whether fixed time was set.
isFixedTime: boolean; isFixedTime: boolean;
// Origin time since Epoch when session started. origin: WallTime;
origin: number;
}; };
type LogEntryType = 'fastForward' |'install' | 'pauseAt' | 'resume' | 'runFor' | 'setFixedTime' | 'setSystemTime'; type LogEntryType = 'fastForward' |'install' | 'pauseAt' | 'resume' | 'runFor' | 'setFixedTime' | 'setSystemTime';
@ -79,11 +79,11 @@ export class ClockController {
private _embedder: Embedder; private _embedder: Embedder;
readonly disposables: (() => void)[] = []; readonly disposables: (() => void)[] = [];
private _log: { type: LogEntryType, time: number, param?: number }[] = []; private _log: { type: LogEntryType, time: number, param?: number }[] = [];
private _realTime: { startTicks: number, lastSyncTicks: number } | undefined; private _realTime: { startTicks: EmbedderTicks, lastSyncTicks: EmbedderTicks } | undefined;
private _currentRealTimeTimer: { callAt: number, dispose: () => void } | undefined; private _currentRealTimeTimer: { callAt: Ticks, dispose: () => void } | undefined;
constructor(embedder: Embedder) { 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; this._embedder = embedder;
} }
@ -99,17 +99,17 @@ export class ClockController {
install(time: number) { install(time: number) {
this._replayLogOnce(); this._replayLogOnce();
this._innerSetTime(time); this._innerSetTime(asWallTime(time));
} }
setSystemTime(time: number) { setSystemTime(time: number) {
this._replayLogOnce(); this._replayLogOnce();
this._innerSetTime(time); this._innerSetTime(asWallTime(time));
} }
setFixedTime(time: number) { setFixedTime(time: number) {
this._replayLogOnce(); this._replayLogOnce();
this._innerSetFixedTime(time); this._innerSetFixedTime(asWallTime(time));
} }
performanceNow(): DOMHighResTimeStamp { performanceNow(): DOMHighResTimeStamp {
@ -117,22 +117,22 @@ export class ClockController {
return this._now.ticks; return this._now.ticks;
} }
private _innerSetTime(time: number) { private _innerSetTime(time: WallTime) {
this._now.time = time; this._now.time = time;
this._now.isFixedTime = false; this._now.isFixedTime = false;
if (this._now.origin < 0) if (this._now.origin < 0)
this._now.origin = this._now.time; this._now.origin = this._now.time;
} }
private _innerSetFixedTime(time: number) { private _innerSetFixedTime(time: WallTime) {
this._innerSetTime(time); this._innerSetTime(time);
this._now.isFixedTime = true; this._now.isFixedTime = true;
} }
private _advanceNow(toTicks: number) { private _advanceNow(to: Ticks) {
if (!this._now.isFixedTime) if (!this._now.isFixedTime)
this._now.time += toTicks - this._now.ticks; this._now.time = asWallTime(this._now.time + to - this._now.ticks);
this._now.ticks = toTicks; this._now.ticks = to;
} }
async log(type: LogEntryType, time: number, param?: number) { async log(type: LogEntryType, time: number, param?: number) {
@ -143,30 +143,32 @@ export class ClockController {
this._replayLogOnce(); this._replayLogOnce();
if (ticks < 0) if (ticks < 0)
throw new TypeError('Negative ticks are not supported'); 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) { private async _runTo(to: Ticks) {
if (this._now.ticks > tickTo) if (this._now.ticks > to)
return; return;
let firstException: Error | undefined; let firstException: Error | undefined;
while (true) { while (true) {
const result = await this._callFirstTimer(tickTo); const result = await this._callFirstTimer(to);
if (!result.timerFound) if (!result.timerFound)
break; break;
firstException = firstException || result.error; firstException = firstException || result.error;
} }
this._advanceNow(tickTo); this._advanceNow(to);
if (firstException) if (firstException)
throw firstException; throw firstException;
} }
async pauseAt(time: number) { async pauseAt(time: number): Promise<number> {
this._replayLogOnce(); this._replayLogOnce();
this._innerPause(); this._innerPause();
await this._innerFastForwardTo(time); const toConsume = time - this._now.time;
await this._innerFastForwardTo(shiftTicks(this._now.ticks, toConsume));
return toConsume;
} }
private _innerPause() { private _innerPause() {
@ -180,7 +182,7 @@ export class ClockController {
} }
private _innerResume() { private _innerResume() {
const now = this._embedder.performanceNow(); const now = this._embedder.performanceNow() as EmbedderTicks;
this._realTime = { startTicks: now, lastSyncTicks: now }; this._realTime = { startTicks: now, lastSyncTicks: now };
this._updateRealTimeTimer(); this._updateRealTimeTimer();
} }
@ -195,7 +197,7 @@ export class ClockController {
const firstTimer = this._firstTimer(); const firstTimer = this._firstTimer();
// Either run the next timer or move time in 100ms chunks. // 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) if (this._currentRealTimeTimer && this._currentRealTimeTimer.callAt < callAt)
return; return;
@ -207,30 +209,30 @@ export class ClockController {
this._currentRealTimeTimer = { this._currentRealTimeTimer = {
callAt, callAt,
dispose: this._embedder.setTimeout(() => { dispose: this._embedder.setTimeout(() => {
const now = Math.ceil(this._embedder.performanceNow()); const now = Math.ceil(this._embedder.performanceNow()) as EmbedderTicks;
this._currentRealTimeTimer = undefined; this._currentRealTimeTimer = undefined;
const sinceLastSync = now - this._realTime!.lastSyncTicks; const sinceLastSync = now - this._realTime!.lastSyncTicks;
this._realTime!.lastSyncTicks = now; this._realTime!.lastSyncTicks = now;
// eslint-disable-next-line no-console // 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), }, callAt - this._now.ticks),
}; };
} }
async fastForward(ticks: number) { async fastForward(ticks: number) {
this._replayLogOnce(); this._replayLogOnce();
await this._innerFastForwardTo(this._now.ticks + ticks | 0); await this._innerFastForwardTo(shiftTicks(this._now.ticks, ticks | 0));
} }
private async _innerFastForwardTo(toTicks: number) { private async _innerFastForwardTo(to: Ticks) {
if (toTicks < this._now.ticks) if (to < this._now.ticks)
throw new Error('Cannot fast-forward to the past'); throw new Error('Cannot fast-forward to the past');
for (const timer of this._timers.values()) { for (const timer of this._timers.values()) {
if (toTicks > timer.callAt) if (to > timer.callAt)
timer.callAt = toTicks; timer.callAt = to;
} }
await this._runTo(toTicks); await this._runTo(to);
} }
addTimer(options: { func: TimerHandler, type: TimerType, delay?: number | string, args?: any[] }): number { addTimer(options: { func: TimerHandler, type: TimerType, delay?: number | string, args?: any[] }): number {
@ -249,7 +251,7 @@ export class ClockController {
func: options.func, func: options.func,
args: options.args || [], args: options.args || [],
delay, delay,
callAt: this._now.ticks + (delay || (this._duringTick ? 1 : 0)), callAt: shiftTicks(this._now.ticks, (delay || (this._duringTick ? 1 : 0))),
createdAt: this._now.ticks, createdAt: this._now.ticks,
id: this._uniqueTimerId++, id: this._uniqueTimerId++,
error: new Error(), error: new Error(),
@ -283,7 +285,7 @@ export class ClockController {
this._advanceNow(timer.callAt); this._advanceNow(timer.callAt);
if (timer.type === TimerType.Interval) if (timer.type === TimerType.Interval)
this._timers.get(timer.id)!.callAt += timer.delay; timer.callAt = shiftTicks(timer.callAt, timer.delay);
else else
this._timers.delete(timer.id); this._timers.delete(timer.id);
return timer; return timer;
@ -375,29 +377,29 @@ export class ClockController {
for (const { type, time, param } of this._log) { for (const { type, time, param } of this._log) {
if (!isPaused && lastLogTime !== -1) if (!isPaused && lastLogTime !== -1)
this._advanceNow(this._now.ticks + time - lastLogTime); this._advanceNow(shiftTicks(this._now.ticks, time - lastLogTime));
lastLogTime = time; lastLogTime = time;
if (type === 'install') { if (type === 'install') {
this._innerSetTime(param!); this._innerSetTime(asWallTime(param!));
} else if (type === 'fastForward' || type === 'runFor') { } else if (type === 'fastForward' || type === 'runFor') {
this._advanceNow(this._now.ticks + param!); this._advanceNow(shiftTicks(this._now.ticks, param!));
} else if (type === 'pauseAt') { } else if (type === 'pauseAt') {
isPaused = true; isPaused = true;
this._innerPause(); this._innerPause();
this._innerSetTime(param!); this._innerSetTime(asWallTime(param!));
} else if (type === 'resume') { } else if (type === 'resume') {
this._innerResume(); this._innerResume();
isPaused = false; isPaused = false;
} else if (type === 'setFixedTime') { } else if (type === 'setFixedTime') {
this._innerSetFixedTime(param!); this._innerSetFixedTime(asWallTime(param!));
} else if (type === 'setSystemTime') { } else if (type === 'setSystemTime') {
this._innerSetTime(param!); this._innerSetTime(asWallTime(param!));
} }
} }
if (!isPaused && lastLogTime > 0) 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; this._log.length = 0;
} }
@ -710,3 +712,11 @@ export function inject(globalObject: WindowOrWorkerGlobalScope) {
builtin: platformOriginals(globalObject).bound, 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;
}

View file

@ -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.describe('performance.now()', () => {
it('should start at 0', async ({ clock }) => { it('should start at 0', async ({ clock }) => {
const result = clock.performance.now(); const result = clock.performance.now();

View file

@ -313,13 +313,13 @@ it.describe('stubTimers', () => {
}); });
await page.clock.runFor(1000); await page.clock.runFor(1000);
expect(await page.evaluate(() => performance.timeOrigin)).toBe(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.describe('popup', () => {
it('should tick after popup', async ({ page }) => { it('should tick after popup', async ({ page }) => {
await page.clock.install(); await page.clock.install({ time: 0 });
const now = new Date('2015-09-25'); const now = new Date('2015-09-25');
await page.clock.pauseAt(now); await page.clock.pauseAt(now);
const [popup] = await Promise.all([ const [popup] = await Promise.all([
@ -334,7 +334,7 @@ it.describe('popup', () => {
}); });
it('should tick before popup', async ({ page }) => { it('should tick before popup', async ({ page }) => {
await page.clock.install(); await page.clock.install({ time: 0 });
const now = new Date('2015-09-25'); const now = new Date('2015-09-25');
await page.clock.pauseAt(now); await page.clock.pauseAt(now);
await page.clock.runFor(1000); await page.clock.runFor(1000);
@ -368,7 +368,7 @@ it.describe('popup', () => {
res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Type', 'text/html');
res.end(`<script>window.time = Date.now()</script>`); res.end(`<script>window.time = Date.now()</script>`);
}); });
await page.clock.install(); await page.clock.install({ time: 0 });
await page.clock.pauseAt(1000); await page.clock.pauseAt(1000);
await page.goto(server.EMPTY_PAGE); await page.goto(server.EMPTY_PAGE);
// Wait for 2 second in real life to check that it is past in popup. // Wait for 2 second in real life to check that it is past in popup.