Skip to content

Commit b7df4d5

Browse files
authored
chore: migrate wait tasks to Progress (#2422)
1 parent 721d56a commit b7df4d5

File tree

7 files changed

+97
-86
lines changed

7 files changed

+97
-86
lines changed

src/debug/stackTrace.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as path from 'path';
1818

1919
// NOTE: update this to point to playwright/lib when moving this file.
2020
const PLAYWRIGHT_LIB_PATH = path.normalize(path.join(__dirname, '..'));
21+
const APICOVERAGE = path.normalize(path.join(__dirname, '..', '..', 'test', 'apicoverage'));
2122

2223
type ParsedStackFrame = { filePath: string, functionName: string };
2324

@@ -67,7 +68,7 @@ export function getCurrentApiCall(prefix = PLAYWRIGHT_LIB_PATH): string {
6768
let apiName: string = '';
6869
for (const frame of stackFrames) {
6970
const parsed = parseStackFrame(frame);
70-
if (!parsed || (!parsed.filePath.startsWith(prefix) && parsed.filePath !== __filename))
71+
if (!parsed || (!parsed.filePath.startsWith(prefix) && !parsed.filePath.startsWith(APICOVERAGE) && parsed.filePath !== __filename))
7172
break;
7273
apiName = parsed.functionName;
7374
}

src/frames.ts

Lines changed: 51 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import * as types from './types';
3030
import { waitForTimeoutWasUsed } from './hints';
3131
import { BrowserContext } from './browserContext';
3232
import { rewriteErrorMessage } from './debug/stackTrace';
33+
import { Progress } from './progress';
3334

3435
type ContextType = 'main' | 'utility';
3536
type ContextData = {
@@ -441,37 +442,40 @@ export class Frame {
441442
return selectors._query(this, selector);
442443
}
443444

444-
async waitForSelector(selector: string, options?: types.WaitForElementOptions): Promise<dom.ElementHandle<Element> | null> {
445-
if (options && (options as any).visibility)
445+
async waitForSelector(selector: string, options: types.WaitForElementOptions = {}): Promise<dom.ElementHandle<Element> | null> {
446+
if ((options as any).visibility)
446447
throw new Error('options.visibility is not supported, did you mean options.state?');
447-
if (options && (options as any).waitFor && (options as any).waitFor !== 'visible')
448+
if ((options as any).waitFor && (options as any).waitFor !== 'visible')
448449
throw new Error('options.waitFor is not supported, did you mean options.state?');
449-
const { state = 'visible' } = (options || {});
450+
const { state = 'visible' } = options;
450451
if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
451452
throw new Error(`Unsupported waitFor option "${state}"`);
452-
453-
const deadline = this._page._timeoutSettings.computeDeadline(options);
454453
const { world, task } = selectors._waitForSelectorTask(selector, state);
455-
const result = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
456-
if (!result.asElement()) {
457-
result.dispose();
458-
return null;
459-
}
460-
const handle = result.asElement() as dom.ElementHandle<Element>;
461-
const mainContext = await this._mainContext();
462-
if (handle && handle._context !== mainContext) {
463-
const adopted = await this._page._delegate.adoptElementHandle(handle, mainContext);
464-
handle.dispose();
465-
return adopted;
466-
}
467-
return handle;
454+
return Progress.runCancelableTask(async progress => {
455+
progress.log(dom.inputLog, `Waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}...`);
456+
const result = await this._scheduleRerunnableTask(progress, world, task);
457+
if (!result.asElement()) {
458+
result.dispose();
459+
return null;
460+
}
461+
const handle = result.asElement() as dom.ElementHandle<Element>;
462+
const mainContext = await this._mainContext();
463+
if (handle && handle._context !== mainContext) {
464+
const adopted = await this._page._delegate.adoptElementHandle(handle, mainContext);
465+
handle.dispose();
466+
return adopted;
467+
}
468+
return handle;
469+
}, options, this._page, this._page._timeoutSettings);
468470
}
469471

470472
async dispatchEvent(selector: string, type: string, eventInit?: Object, options?: types.TimeoutOptions): Promise<void> {
471-
const deadline = this._page._timeoutSettings.computeDeadline(options);
472473
const task = selectors._dispatchEventTask(selector, type, eventInit || {});
473-
const result = await this._scheduleRerunnableTask(task, 'main', deadline, `selector "${selector}"`);
474-
result.dispose();
474+
return Progress.runCancelableTask(async progress => {
475+
progress.log(dom.inputLog, `Dispatching "${type}" event on selector "${selector}"...`);
476+
const result = await this._scheduleRerunnableTask(progress, 'main', task);
477+
result.dispose();
478+
}, options || {}, this._page, this._page._timeoutSettings);
475479
}
476480

477481
async $eval<R, Arg>(selector: string, pageFunction: types.FuncOn<Element, Arg, R>, arg: Arg): Promise<R>;
@@ -702,7 +706,9 @@ export class Frame {
702706
try {
703707
const { world, task } = selectors._waitForSelectorTask(selector, 'attached');
704708
this._page._log(dom.inputLog, `waiting for the selector "${selector}"`);
705-
const handle = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selector}"`);
709+
const handle = await Progress.runCancelableTask(
710+
progress => this._scheduleRerunnableTask(progress, world, task),
711+
options, this._page, this._page._timeoutSettings);
706712
this._page._log(dom.inputLog, `...got element for the selector`);
707713
const element = handle.asElement() as dom.ElementHandle<Element>;
708714
try {
@@ -803,20 +809,23 @@ export class Frame {
803809
async waitForFunction<R>(pageFunction: types.Func1<void, R>, arg?: any, options?: types.WaitForFunctionOptions): Promise<types.SmartHandle<R>>;
804810
async waitForFunction<R, Arg>(pageFunction: types.Func1<Arg, R>, arg: Arg, options: types.WaitForFunctionOptions = {}): Promise<types.SmartHandle<R>> {
805811
const { polling = 'raf' } = options;
806-
const deadline = this._page._timeoutSettings.computeDeadline(options);
807812
if (helper.isString(polling))
808813
assert(polling === 'raf', 'Unknown polling option: ' + polling);
809814
else if (helper.isNumber(polling))
810815
assert(polling > 0, 'Cannot poll with non-positive interval: ' + polling);
811816
else
812817
throw new Error('Unknown polling options: ' + polling);
813818
const predicateBody = helper.isString(pageFunction) ? 'return (' + pageFunction + ')' : 'return (' + pageFunction + ')(arg)';
814-
815-
const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, arg }) => {
816-
const innerPredicate = new Function('arg', predicateBody) as (arg: any) => R;
817-
return injected.poll(polling, () => innerPredicate(arg));
818-
}, { injected: await context.injectedScript(), predicateBody, polling, arg });
819-
return this._scheduleRerunnableTask(task, 'main', deadline);
819+
const task = async (context: dom.FrameExecutionContext) => {
820+
const injectedScript = await context.injectedScript();
821+
return context.evaluateHandleInternal(({ injectedScript, predicateBody, polling, arg }) => {
822+
const innerPredicate = new Function('arg', predicateBody) as (arg: any) => R;
823+
return injectedScript.poll(polling, () => innerPredicate(arg));
824+
}, { injectedScript, predicateBody, polling, arg });
825+
};
826+
return Progress.runCancelableTask(
827+
progress => this._scheduleRerunnableTask(progress, 'main', task),
828+
options, this._page, this._page._timeoutSettings);
820829
}
821830

822831
async title(): Promise<string> {
@@ -836,9 +845,9 @@ export class Frame {
836845
this._parentFrame = null;
837846
}
838847

839-
private _scheduleRerunnableTask<T>(task: SchedulableTask<T>, contextType: ContextType, deadline: number, title?: string): Promise<types.SmartHandle<T>> {
848+
private _scheduleRerunnableTask<T>(progress: Progress, contextType: ContextType, task: SchedulableTask<T>): Promise<types.SmartHandle<T>> {
840849
const data = this._contextData.get(contextType)!;
841-
const rerunnableTask = new RerunnableTask(data, task, deadline, title);
850+
const rerunnableTask = new RerunnableTask(data, progress, task);
842851
if (data.context)
843852
rerunnableTask.rerun(data.context);
844853
return rerunnableTask.promise;
@@ -893,38 +902,24 @@ export type SchedulableTask<T> = (context: dom.FrameExecutionContext) => Promise
893902

894903
class RerunnableTask<T> {
895904
readonly promise: Promise<types.SmartHandle<T>>;
896-
terminate: (reason: Error) => void = () => {};
897905
private _task: SchedulableTask<T>;
898906
private _resolve: (result: types.SmartHandle<T>) => void = () => {};
899907
private _reject: (reason: Error) => void = () => {};
900-
private _terminatedPromise: Promise<Error>;
908+
private _progress: Progress;
901909

902-
constructor(data: ContextData, task: SchedulableTask<T>, deadline: number, title?: string) {
910+
constructor(data: ContextData, progress: Progress, task: SchedulableTask<T>) {
903911
this._task = task;
912+
this._progress = progress;
904913
data.rerunnableTasks.add(this);
905-
906-
// Since page navigation requires us to re-install the pageScript, we should track
907-
// timeout on our end.
908-
const timeoutError = new TimeoutError(`waiting for ${title || 'function'} failed: timeout exceeded. Re-run with the DEBUG=pw:input env variable to see the debug log.`);
909-
let timeoutTimer: NodeJS.Timer | undefined;
910-
this._terminatedPromise = new Promise(resolve => {
911-
timeoutTimer = setTimeout(() => resolve(timeoutError), helper.timeUntilDeadline(deadline));
912-
this.terminate = resolve;
913-
});
914-
915-
// This promise is either resolved with the task result, or rejected with a meaningful
916-
// evaluation error.
917-
const resultPromise = new Promise<types.SmartHandle<T>>((resolve, reject) => {
914+
this.promise = progress.race(new Promise<types.SmartHandle<T>>((resolve, reject) => {
915+
// The task is either resolved with a value, or rejected with a meaningful evaluation error.
918916
this._resolve = resolve;
919917
this._reject = reject;
920-
});
921-
const failPromise = this._terminatedPromise.then(error => Promise.reject(error));
918+
}));
919+
}
922920

923-
this.promise = Promise.race([resultPromise, failPromise]).finally(() => {
924-
if (timeoutTimer)
925-
clearTimeout(timeoutTimer);
926-
data.rerunnableTasks.delete(this);
927-
});
921+
terminate(error: Error) {
922+
this._progress.cancel(error);
928923
}
929924

930925
async rerun(context: dom.FrameExecutionContext) {
@@ -938,7 +933,7 @@ class RerunnableTask<T> {
938933
poll = null;
939934
copy.evaluate(p => p.cancel()).catch(e => {}).then(() => copy.dispose());
940935
};
941-
this._terminatedPromise.then(cancelPoll);
936+
this._progress.cleanupWhenCanceled(cancelPoll);
942937

943938
try {
944939
poll = await this._task(context);

src/helper.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class Helper {
7070
const isAsync = method.constructor.name === 'AsyncFunction';
7171
if (!isAsync)
7272
continue;
73-
Reflect.set(classType.prototype, methodName, function(this: any, ...args: any[]) {
73+
const override = function(this: any, ...args: any[]) {
7474
const syncStack: any = {};
7575
Error.captureStackTrace(syncStack);
7676
return method.call(this, ...args).catch((e: any) => {
@@ -80,7 +80,9 @@ class Helper {
8080
e.stack += '\n -- ASYNC --\n' + stack;
8181
throw e;
8282
});
83-
});
83+
};
84+
Object.defineProperty(override, 'name', { writable: false, value: methodName });
85+
Reflect.set(classType.prototype, methodName, override);
8486
}
8587
}
8688

src/progress.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,31 @@ import { getCurrentApiCall, rewriteErrorMessage } from './debug/stackTrace';
2424
class AbortError extends Error {}
2525

2626
export class Progress {
27-
static async runCancelableTask<T>(task: (progress: Progress) => Promise<T>, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, apiName?: string): Promise<T> {
28-
let resolveCancelation = () => {};
29-
const progress = new Progress(timeoutOptions, logger, new Promise(resolve => resolveCancelation = resolve), apiName);
27+
static async runCancelableTask<T>(task: (progress: Progress) => Promise<T>, timeoutOptions: types.TimeoutOptions, logger: InnerLogger, timeoutSettings?: TimeoutSettings, apiName?: string): Promise<T> {
28+
apiName = apiName || getCurrentApiCall();
29+
30+
const defaultTimeout = timeoutSettings ? timeoutSettings.timeout() : DEFAULT_TIMEOUT;
31+
const { timeout = defaultTimeout } = timeoutOptions;
32+
const deadline = TimeoutSettings.computeDeadline(timeout);
3033

31-
const { timeout = DEFAULT_TIMEOUT } = timeoutOptions;
32-
const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded during ${progress.apiName}.`);
33-
let rejectWithTimeout: (error: Error) => void;
34-
const timeoutPromise = new Promise<T>((resolve, x) => rejectWithTimeout = x);
35-
const timeoutTimer = setTimeout(() => rejectWithTimeout(timeoutError), helper.timeUntilDeadline(progress.deadline));
34+
let rejectCancelPromise: (error: Error) => void = () => {};
35+
const cancelPromise = new Promise<T>((resolve, x) => rejectCancelPromise = x);
36+
const timeoutError = new TimeoutError(`Timeout ${timeout}ms exceeded during ${apiName}.`);
37+
const timer = setTimeout(() => rejectCancelPromise(timeoutError), helper.timeUntilDeadline(deadline));
3638

39+
let resolveCancelation = () => {};
40+
const progress = new Progress(deadline, logger, new Promise(resolve => resolveCancelation = resolve), rejectCancelPromise, apiName);
3741
try {
3842
const promise = task(progress);
39-
const result = await Promise.race([promise, timeoutPromise]);
40-
clearTimeout(timeoutTimer);
43+
const result = await Promise.race([promise, cancelPromise]);
44+
clearTimeout(timer);
4145
progress._running = false;
4246
progress._logRecording = [];
4347
return result;
4448
} catch (e) {
4549
resolveCancelation();
46-
rewriteErrorMessage(e, e.message + formatLogRecording(progress._logRecording, progress.apiName));
47-
clearTimeout(timeoutTimer);
50+
rewriteErrorMessage(e, e.message + formatLogRecording(progress._logRecording, apiName));
51+
clearTimeout(timer);
4852
progress._running = false;
4953
progress._logRecording = [];
5054
await Promise.all(progress._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
@@ -54,16 +58,18 @@ export class Progress {
5458

5559
readonly apiName: string;
5660
readonly deadline: number; // To be removed?
61+
readonly cancel: (error: Error) => void;
5762
readonly _canceled: Promise<any>;
5863

5964
private _logger: InnerLogger;
6065
private _logRecording: string[] = [];
6166
private _cleanups: (() => any)[] = [];
6267
private _running = true;
6368

64-
constructor(options: types.TimeoutOptions, logger: InnerLogger, canceled: Promise<any>, apiName?: string) {
65-
this.apiName = apiName || getCurrentApiCall();
66-
this.deadline = TimeoutSettings.computeDeadline(options.timeout);
69+
constructor(deadline: number, logger: InnerLogger, canceled: Promise<any>, cancel: (error: Error) => void, apiName: string) {
70+
this.deadline = deadline;
71+
this.apiName = apiName;
72+
this.cancel = cancel;
6773
this._canceled = canceled;
6874
this._logger = logger;
6975
}
@@ -108,6 +114,8 @@ async function runCleanup(cleanup: () => any) {
108114
}
109115

110116
function formatLogRecording(log: string[], name: string): string {
117+
if (!log.length)
118+
return '';
111119
name = ` ${name} logs `;
112120
const headerLength = 60;
113121
const leftLength = (headerLength - name.length) / 2;

src/timeoutSettings.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,16 @@ export class TimeoutSettings {
4848
return DEFAULT_TIMEOUT;
4949
}
5050

51-
private _timeout(): number {
51+
timeout(): number {
5252
if (this._defaultTimeout !== null)
5353
return this._defaultTimeout;
5454
if (this._parent)
55-
return this._parent._timeout();
55+
return this._parent.timeout();
5656
return DEFAULT_TIMEOUT;
5757
}
5858

5959
computeDeadline(options: TimeoutOptions = {}) {
60-
return TimeoutSettings.computeDeadline(options.timeout, this._timeout());
60+
return TimeoutSettings.computeDeadline(options.timeout, this.timeout());
6161
}
6262

6363
static computeDeadline(timeout: number | undefined, defaultValue = DEFAULT_TIMEOUT): number {

test/apicoverage.spec.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ function traceAPICoverage(apiCoverage, events, className, classType) {
2828
if (methodName === 'constructor' || typeof methodName !== 'string' || methodName.startsWith('_') || typeof method !== 'function')
2929
continue;
3030
apiCoverage.set(`${className}.${methodName}`, false);
31-
Reflect.set(classType.prototype, methodName, function(...args) {
31+
const override = function(...args) {
3232
apiCoverage.set(`${className}.${methodName}`, true);
3333
return method.call(this, ...args);
34-
});
34+
};
35+
Object.defineProperty(override, 'name', { writable: false, value: methodName });
36+
Reflect.set(classType.prototype, methodName, override);
3537
}
3638

3739
if (events[classType.name]) {

test/waittask.spec.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,15 @@ describe('Frame.waitForFunction', function() {
110110
let error = null;
111111
await page.waitForFunction('false', {}, {timeout: 10}).catch(e => error = e);
112112
expect(error).toBeTruthy();
113-
expect(error.message).toContain('waiting for function failed: timeout');
113+
expect(error.message).toContain('Timeout 10ms exceeded during page.waitForFunction');
114114
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
115115
});
116116
it('should respect default timeout', async({page}) => {
117117
page.setDefaultTimeout(1);
118118
let error = null;
119119
await page.waitForFunction('false').catch(e => error = e);
120120
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
121-
expect(error.message).toContain('waiting for function failed: timeout');
121+
expect(error.message).toContain('Timeout 1ms exceeded during page.waitForFunction');
122122
});
123123
it('should disable timeout when its set to 0', async({page}) => {
124124
const watchdog = page.waitForFunction(() => {
@@ -277,10 +277,10 @@ describe('Frame.waitForSelector', function() {
277277
it('should not consider visible when zero-sized', async({page, server}) => {
278278
await page.setContent(`<div style='width: 0; height: 0;'>1</div>`);
279279
let error = await page.waitForSelector('div', { timeout: 1000 }).catch(e => e);
280-
expect(error.message).toContain('timeout exceeded');
280+
expect(error.message).toContain('Timeout 1000ms exceeded during page.waitForSelector');
281281
await page.evaluate(() => document.querySelector('div').style.width = '10px');
282282
error = await page.waitForSelector('div', { timeout: 1000 }).catch(e => e);
283-
expect(error.message).toContain('timeout exceeded');
283+
expect(error.message).toContain('Timeout 1000ms exceeded during page.waitForSelector');
284284
await page.evaluate(() => document.querySelector('div').style.height = '10px');
285285
expect(await page.waitForSelector('div', { timeout: 1000 })).toBeTruthy();
286286
});
@@ -333,15 +333,17 @@ describe('Frame.waitForSelector', function() {
333333
let error = null;
334334
await page.waitForSelector('div', { timeout: 10, state: 'attached' }).catch(e => error = e);
335335
expect(error).toBeTruthy();
336-
expect(error.message).toContain('waiting for selector "div" failed: timeout');
336+
expect(error.message).toContain('Timeout 10ms exceeded during page.waitForSelector');
337+
expect(error.message).toContain('Waiting for selector "div"...');
337338
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
338339
});
339340
it('should have an error message specifically for awaiting an element to be hidden', async({page, server}) => {
340341
await page.setContent(`<div>content</div>`);
341342
let error = null;
342343
await page.waitForSelector('div', { state: 'hidden', timeout: 1000 }).catch(e => error = e);
343344
expect(error).toBeTruthy();
344-
expect(error.message).toContain('waiting for selector "div" to be hidden failed: timeout');
345+
expect(error.message).toContain('Timeout 1000ms exceeded during page.waitForSelector');
346+
expect(error.message).toContain('Waiting for selector "div" to be hidden...');
345347
});
346348
it('should respond to node attribute mutation', async({page, server}) => {
347349
let divFound = false;
@@ -421,7 +423,8 @@ describe('Frame.waitForSelector xpath', function() {
421423
let error = null;
422424
await page.waitForSelector('//div', { state: 'attached', timeout: 10 }).catch(e => error = e);
423425
expect(error).toBeTruthy();
424-
expect(error.message).toContain('waiting for selector "//div" failed: timeout');
426+
expect(error.message).toContain('Timeout 10ms exceeded during page.waitForSelector');
427+
expect(error.message).toContain('Waiting for selector "//div"...');
425428
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
426429
});
427430
it('should run in specified frame', async({page, server}) => {

0 commit comments

Comments
 (0)