Skip to content

Commit

Permalink
fix(clock): fix pauseAt to arrive at wall time (#31297)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Jun 13, 2024
1 parent 8ea663a commit 897f744
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 50 deletions.
102 changes: 56 additions & 46 deletions packages/playwright-core/src/server/injected/clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ type Timer = {
func: TimerHandler;
args: any[];
delay: number;
callAt: number;
createdAt: number;
callAt: Ticks;
createdAt: Ticks;
id: number;
error?: Error;
};
Expand All @@ -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';
Expand All @@ -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;
}

Expand All @@ -99,40 +99,40 @@ 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 {
this._replayLogOnce();
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) {
Expand All @@ -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<number> {
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() {
Expand All @@ -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();
}
Expand All @@ -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;

Expand All @@ -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 {
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
24 changes: 24 additions & 0 deletions tests/library/clock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 4 additions & 4 deletions tests/page/page-clock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand 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);
Expand Down Expand Up @@ -368,7 +368,7 @@ it.describe('popup', () => {
res.setHeader('Content-Type', 'text/html');
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.goto(server.EMPTY_PAGE);
// Wait for 2 second in real life to check that it is past in popup.
Expand Down

0 comments on commit 897f744

Please sign in to comment.