Skip to content

Commit

Permalink
chore: introduce clock test mode (microsoft#31110)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored May 31, 2024
1 parent afa0bf2 commit 8bfd0eb
Show file tree
Hide file tree
Showing 60 changed files with 291 additions and 140 deletions.
62 changes: 62 additions & 0 deletions .github/workflows/tests_clock.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: "tests Clock"

on:
push:
branches:
- main
- release-*
pull_request:
paths-ignore:
- 'browser_patches/**'
- 'docs/**'
types: [ labeled ]
branches:
- main
- release-*

env:
# Force terminal colors. @see https://www.npmjs.com/package/colors
FORCE_COLOR: 1
ELECTRON_SKIP_BINARY_DOWNLOAD: 1

jobs:
frozen_time_linux:
name: Frozen time library
environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}
permissions:
id-token: write # This is required for OIDC login (azure/login) to succeed
contents: read # This is required for actions/checkout to succeed
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/run-test
with:
node-version: 20
browsers-to-install: chromium
command: npm run test -- --project=chromium-*
bot-name: "frozen-time-library-chromium-linux"
flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }}
flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }}
flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }}
env:
PW_FREEZE_TIME: 1

frozen_time_test_runner:
name: Frozen time test runner
environment: ${{ github.event_name == 'push' && 'allow-uploading-flakiness-results' || null }}
runs-on: ubuntu-22.04
permissions:
id-token: write # This is required for OIDC login (azure/login) to succeed
contents: read # This is required for actions/checkout to succeed
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/run-test
with:
node-version: 20
command: npm run ttest
bot-name: "frozen-time-runner-chromium-linux"
flakiness-client-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_CLIENT_ID }}
flakiness-tenant-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_TENANT_ID }}
flakiness-subscription-id: ${{ secrets.AZURE_FLAKINESS_DASHBOARD_SUBSCRIPTION_ID }}
env:
PW_FREEZE_TIME: 1
2 changes: 1 addition & 1 deletion docs/src/api/class-clock.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Install fake timers with the specified unix epoch (default: 0).
- `toFake` <[Array]<[FakeMethod]<"setTimeout"|"clearTimeout"|"setInterval"|"clearInterval"|"Date"|"requestAnimationFrame"|"cancelAnimationFrame"|"requestIdleCallback"|"cancelIdleCallback"|"performance">>>

An array with names of global methods and APIs to fake. For instance, `await page.clock.install({ toFake: ['setTimeout'] })` will fake only `setTimeout()`.
By default, `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` and `Date` are faked.
By default, all the methods are faked.

### option: Clock.install.loopLimit
* since: v1.45
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/client/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = BrowserContext.from(response.context);
await this._browserType._didCreateContext(context, contextOptions, this._options, options.logger || this._logger);
if (!forReuse && !!process.env.PW_FREEZE_TIME)
await this._wrapApiCall(async () => { await context.clock.install(); }, true);
return context;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr

async _evaluateExposeUtilityScript<R, Arg>(pageFunction: structs.PageFunction<Arg, R>, arg?: Arg): Promise<R> {
assertMaxArguments(arguments.length, 2);
const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', exposeUtilityScript: true, arg: serializeArgument(arg) });
const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === 'function', arg: serializeArgument(arg) });
return parseResult(result.value);
}

Expand Down
1 change: 0 additions & 1 deletion packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1429,7 +1429,6 @@ scheme.FrameDispatchEventResult = tOptional(tObject({}));
scheme.FrameEvaluateExpressionParams = tObject({
expression: tString,
isFunction: tOptional(tBoolean),
exposeUtilityScript: tOptional(tBoolean),
arg: tType('SerializedArgument'),
});
scheme.FrameEvaluateExpressionResult = tObject({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
}

async evaluateExpression(params: channels.FrameEvaluateExpressionParams, metadata: CallMetadata): Promise<channels.FrameEvaluateExpressionResult> {
return { value: serializeResult(await this._frame.evaluateExpression(params.expression, { isFunction: params.isFunction, exposeUtilityScript: params.exposeUtilityScript }, parseArgument(params.arg))) };
return { value: serializeResult(await this._frame.evaluateExpression(params.expression, { isFunction: params.isFunction }, parseArgument(params.arg))) };
}

async evaluateExpressionHandle(params: channels.FrameEvaluateExpressionHandleParams, metadata: CallMetadata): Promise<channels.FrameEvaluateExpressionHandleResult> {
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ export class FrameExecutionContext extends js.ExecutionContext {
return js.evaluate(this, false /* returnByValue */, pageFunction, arg);
}

async evaluateExpression(expression: string, options: { isFunction?: boolean, exposeUtilityScript?: boolean }, arg?: any): Promise<any> {
async evaluateExpression(expression: string, options: { isFunction?: boolean }, arg?: any): Promise<any> {
return js.evaluateExpression(this, expression, { ...options, returnByValue: true }, arg);
}

async evaluateExpressionHandle(expression: string, options: { isFunction?: boolean, exposeUtilityScript?: boolean }, arg?: any): Promise<js.JSHandle<any>> {
async evaluateExpressionHandle(expression: string, options: { isFunction?: boolean }, arg?: any): Promise<js.JSHandle<any>> {
return js.evaluateExpression(this, expression, { ...options, returnByValue: false }, arg);
}

Expand Down
8 changes: 4 additions & 4 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,13 +745,13 @@ export class Frame extends SdkObject {
return this._context('utility');
}

async evaluateExpression(expression: string, options: { isFunction?: boolean, exposeUtilityScript?: boolean, world?: types.World } = {}, arg?: any): Promise<any> {
async evaluateExpression(expression: string, options: { isFunction?: boolean, world?: types.World } = {}, arg?: any): Promise<any> {
const context = await this._context(options.world ?? 'main');
const value = await context.evaluateExpression(expression, options, arg);
return value;
}

async evaluateExpressionHandle(expression: string, options: { isFunction?: boolean, exposeUtilityScript?: boolean, world?: types.World } = {}, arg?: any): Promise<js.JSHandle<any>> {
async evaluateExpressionHandle(expression: string, options: { isFunction?: boolean, world?: types.World } = {}, arg?: any): Promise<js.JSHandle<any>> {
const context = await this._context(options.world ?? 'main');
const value = await context.evaluateExpressionHandle(expression, options, arg);
return value;
Expand Down Expand Up @@ -1513,9 +1513,9 @@ export class Frame extends SdkObject {
return;
}
if (typeof polling !== 'number')
requestAnimationFrame(next);
injected.builtinRequestAnimationFrame(next);
else
setTimeout(next, polling);
injected.builtinSetTimeout(next, polling);
} catch (e) {
reject(e);
}
Expand Down
19 changes: 18 additions & 1 deletion packages/playwright-core/src/server/injected/fakeTimers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,22 @@ import SinonFakeTimers from '../../third_party/fake-timers-src';
import type * as channels from '@protocol/channels';

export function install(params: channels.BrowserContextClockInstallOptions) {
return SinonFakeTimers.install(params);
// eslint-disable-next-line no-restricted-globals
const window = globalThis;
const builtin = {
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
requestIdleCallback: window.requestIdleCallback?.bind(window),
cancelIdleCallback: window.cancelIdleCallback?.bind(window),
performance: window.performance,
Intl: window.Intl,
Date: window.Date,
};
const result = SinonFakeTimers.install(params);
result.builtin = builtin;
return result;
}
2 changes: 1 addition & 1 deletion packages/playwright-core/src/server/injected/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export class Highlight {
if (this._rafRequest)
cancelAnimationFrame(this._rafRequest);
this.updateHighlight(this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement), { tooltipText: asLocator(this._language, stringifySelector(selector)) });
this._rafRequest = requestAnimationFrame(() => this.runHighlightOnRaf(selector));
this._rafRequest = this._injectedScript.builtinRequestAnimationFrame(() => this.runHighlightOnRaf(selector));
}

uninstall() {
Expand Down
29 changes: 26 additions & 3 deletions packages/playwright-core/src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ export class InjectedScript {
(this.window as any).__injectedScript = this;
}

builtinSetTimeout(callback: Function, timeout: number) {
if (this.window.__pwFakeTimers?.builtin)
return this.window.__pwFakeTimers.builtin.setTimeout(callback, timeout);
return setTimeout(callback, timeout);
}

builtinRequestAnimationFrame(callback: FrameRequestCallback) {
if (this.window.__pwFakeTimers?.builtin)
return this.window.__pwFakeTimers.builtin.requestAnimationFrame(callback);
return requestAnimationFrame(callback);
}

eval(expression: string): any {
return this.window.eval(expression);
}
Expand Down Expand Up @@ -427,7 +439,7 @@ export class InjectedScript {
observer.observe(element);
// Firefox doesn't call IntersectionObserver callback unless
// there are rafs.
requestAnimationFrame(() => {});
this.builtinRequestAnimationFrame(() => {});
});
}

Expand Down Expand Up @@ -536,12 +548,12 @@ export class InjectedScript {
if (success !== continuePolling)
fulfill(success);
else
requestAnimationFrame(raf);
this.builtinRequestAnimationFrame(raf);
} catch (e) {
reject(e);
}
};
requestAnimationFrame(raf);
this.builtinRequestAnimationFrame(raf);

return result;
}
Expand Down Expand Up @@ -1510,3 +1522,14 @@ function deepEquals(a: any, b: any): boolean {

return false;
}

declare global {
interface Window {
__pwFakeTimers?: {
builtin: {
setTimeout: Window['setTimeout'],
requestAnimationFrame: Window['requestAnimationFrame'],
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ class Overlay {
flashToolSucceeded(tool: 'assertingVisibility' | 'assertingValue') {
const element = tool === 'assertingVisibility' ? this._assertVisibilityToggle : this._assertValuesToggle;
element.classList.add('succeeded');
setTimeout(() => element.classList.remove('succeeded'), 2000);
this._recorder.injectedScript.builtinSetTimeout(() => element.classList.remove('succeeded'), 2000);
}

private _hideOverlay() {
Expand Down Expand Up @@ -1312,7 +1312,7 @@ interface Embedder {
export class PollingRecorder implements RecorderDelegate {
private _recorder: Recorder;
private _embedder: Embedder;
private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
private _pollRecorderModeTimer: number | undefined;

constructor(injectedScript: InjectedScript) {
this._recorder = new Recorder(injectedScript);
Expand All @@ -1333,7 +1333,7 @@ export class PollingRecorder implements RecorderDelegate {
clearTimeout(this._pollRecorderModeTimer);
const state = await this._embedder.__pw_recorderState().catch(() => {});
if (!state) {
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
return;
}
const win = this._recorder.document.defaultView!;
Expand All @@ -1343,7 +1343,7 @@ export class PollingRecorder implements RecorderDelegate {
state.actionPoint = undefined;
}
this._recorder.setUIState(state, this);
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
}

async performAction(action: actions.Action) {
Expand Down
52 changes: 49 additions & 3 deletions packages/playwright-core/src/server/injected/utilityScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@
import { serializeAsCallArgument, parseEvaluationResultValue } from '../isomorphic/utilityScriptSerializers';

export class UtilityScript {
constructor(isUnderTest: boolean) {
if (isUnderTest)
this._setBuiltins();
}

serializeAsCallArgument = serializeAsCallArgument;
parseEvaluationResultValue = parseEvaluationResultValue;

evaluate(isFunction: boolean | undefined, returnByValue: boolean, exposeUtilityScript: boolean | undefined, expression: string, argCount: number, ...argsAndHandles: any[]) {
evaluate(isFunction: boolean | undefined, returnByValue: boolean, expression: string, argCount: number, ...argsAndHandles: any[]) {
const args = argsAndHandles.slice(0, argCount);
const handles = argsAndHandles.slice(argCount);
const parameters = [];
for (let i = 0; i < args.length; i++)
parameters[i] = this.parseEvaluationResultValue(args[i], handles);
if (exposeUtilityScript)
parameters.unshift(this);

// eslint-disable-next-line no-restricted-globals
let result = globalThis.eval(expression);
Expand Down Expand Up @@ -71,4 +74,47 @@ export class UtilityScript {
}
return safeJson(value);
}

private _setBuiltins() {
// eslint-disable-next-line no-restricted-globals
const window = (globalThis as any);
window.builtinSetTimeout = (callback: Function, timeout: number) => {
if (window.__pwFakeTimers?.builtin)
return window.__pwFakeTimers.builtin.setTimeout(callback, timeout);
return setTimeout(callback, timeout);
};

window.builtinClearTimeout = (id: number) => {
if (window.__pwFakeTimers?.builtin)
return window.__pwFakeTimers.builtin.clearTimeout(id);
return clearTimeout(id);
};

window.builtinSetInterval = (callback: Function, timeout: number) => {
if (window.__pwFakeTimers?.builtin)
return window.__pwFakeTimers.builtin.setInterval(callback, timeout);
return setInterval(callback, timeout);
};

window.builtinClearInterval = (id: number) => {
if (window.__pwFakeTimers?.builtin)
return window.__pwFakeTimers.builtin.clearInterval(id);
return clearInterval(id);
};

window.builtinRequestAnimationFrame = (callback: FrameRequestCallback) => {
if (window.__pwFakeTimers?.builtin)
return window.__pwFakeTimers.builtin.requestAnimationFrame(callback);
return requestAnimationFrame(callback);
};

window.builtinCancelAnimationFrame = (id: number) => {
if (window.__pwFakeTimers?.builtin)
return window.__pwFakeTimers.builtin.cancelAnimationFrame(id);
return cancelAnimationFrame(id);
};

window.builtinDate = window.__pwFakeTimers?.builtin.Date || Date;
window.builtinPerformance = window.__pwFakeTimers?.builtin.performance || performance;
}
}
7 changes: 4 additions & 3 deletions packages/playwright-core/src/server/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { serializeAsCallArgument } from './isomorphic/utilityScriptSerializers';
import type { UtilityScript } from './injected/utilityScript';
import { SdkObject } from './instrumentation';
import { LongStandingScope } from '../utils/manualPromise';
import { isUnderTest } from '../utils';

export type ObjectId = string;
export type RemoteObject = {
Expand Down Expand Up @@ -118,7 +119,7 @@ export class ExecutionContext extends SdkObject {
(() => {
const module = {};
${utilityScriptSource.source}
return new (module.exports.UtilityScript())();
return new (module.exports.UtilityScript())(${isUnderTest()});
})();`;
this._utilityScriptPromise = this._raceAgainstContextDestroyed(this._delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', 'UtilityScript', objectId)));
}
Expand Down Expand Up @@ -257,7 +258,7 @@ export async function evaluate(context: ExecutionContext, returnByValue: boolean
return evaluateExpression(context, String(pageFunction), { returnByValue, isFunction: typeof pageFunction === 'function' }, ...args);
}

export async function evaluateExpression(context: ExecutionContext, expression: string, options: { returnByValue?: boolean, isFunction?: boolean, exposeUtilityScript?: boolean }, ...args: any[]): Promise<any> {
export async function evaluateExpression(context: ExecutionContext, expression: string, options: { returnByValue?: boolean, isFunction?: boolean }, ...args: any[]): Promise<any> {
const utilityScript = await context.utilityScript();
expression = normalizeEvaluationExpression(expression, options.isFunction);
const handles: (Promise<JSHandle>)[] = [];
Expand Down Expand Up @@ -290,7 +291,7 @@ export async function evaluateExpression(context: ExecutionContext, expression:
}

// See UtilityScript for arguments.
const utilityScriptValues = [options.isFunction, options.returnByValue, options.exposeUtilityScript, expression, args.length, ...args];
const utilityScriptValues = [options.isFunction, options.returnByValue, expression, args.length, ...args];

const script = `(utilityScript, ...args) => utilityScript.evaluate(...args)`;
try {
Expand Down
3 changes: 1 addition & 2 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17274,8 +17274,7 @@ export interface Clock {

/**
* An array with names of global methods and APIs to fake. For instance, `await page.clock.install({ toFake:
* ['setTimeout'] })` will fake only `setTimeout()`. By default, `setTimeout`, `clearTimeout`, `setInterval`,
* `clearInterval` and `Date` are faked.
* ['setTimeout'] })` will fake only `setTimeout()`. By default, all the methods are faked.
*/
toFake?: Array<"setTimeout"|"clearTimeout"|"setInterval"|"clearInterval"|"Date"|"requestAnimationFrame"|"cancelAnimationFrame"|"requestIdleCallback"|"cancelIdleCallback"|"performance">;
}): Promise<void>;
Expand Down
Loading

0 comments on commit 8bfd0eb

Please sign in to comment.