feat: avoid side effects after progress aborts (#2518)

Actions like click, focus or polling now avoid doing any work
with side-effects after their progress has been aborted.
This commit is contained in:
Dmitry Gozman 2020-06-09 18:57:11 -07:00 committed by GitHub
parent 4faa1306b9
commit 4d069dda0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 52 additions and 14 deletions

View file

@ -262,6 +262,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
await this._waitForDisplayedAtStablePositionAndEnabled(progress); await this._waitForDisplayedAtStablePositionAndEnabled(progress);
progress.log(apiLog, ' scrolling into view if needed'); progress.log(apiLog, ' scrolling into view if needed');
progress.throwIfAborted(); // Avoid action that has side-effects.
const scrolled = await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined); const scrolled = await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
if (scrolled === 'invisible') { if (scrolled === 'invisible') {
if (force) if (force)
@ -299,6 +300,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
if ((options as any).__testHookBeforePointerAction)
await (options as any).__testHookBeforePointerAction();
progress.throwIfAborted(); // Avoid action that has side-effects.
let restoreModifiers: input.Modifier[] | undefined; let restoreModifiers: input.Modifier[] | undefined;
if (options && options.modifiers) if (options && options.modifiers)
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
@ -362,6 +366,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"'); assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"');
} }
return this._page._frameManager.waitForSignalsCreatedBy<string[]>(progress, options.noWaitAfter, async () => { return this._page._frameManager.waitForSignalsCreatedBy<string[]>(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
const injectedResult = await this._evaluateInUtility(([injected, node, selectOptions]) => injected.selectOptions(node, selectOptions), selectOptions); const injectedResult = await this._evaluateInUtility(([injected, node, selectOptions]) => injected.selectOptions(node, selectOptions), selectOptions);
return handleInjectedResult(injectedResult); return handleInjectedResult(injectedResult);
}); });
@ -381,6 +386,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const pollHandler = new InjectedScriptPollHandler(progress, poll); const pollHandler = new InjectedScriptPollHandler(progress, poll);
const injectedResult = await pollHandler.finish(); const injectedResult = await pollHandler.finish();
const needsInput = handleInjectedResult(injectedResult); const needsInput = handleInjectedResult(injectedResult);
progress.throwIfAborted(); // Avoid action that has side-effects.
if (needsInput) { if (needsInput) {
if (value) if (value)
await this._page.keyboard.insertText(value); await this._page.keyboard.insertText(value);
@ -392,6 +398,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async selectText(): Promise<void> { async selectText(): Promise<void> {
return runAbortableTask(async progress => { return runAbortableTask(async progress => {
progress.throwIfAborted(); // Avoid action that has side-effects.
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {}); const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {});
handleInjectedResult(injectedResult); handleInjectedResult(injectedResult);
}, this._page._logger, 0); }, this._page._logger, 0);
@ -431,6 +438,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
} }
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, filePayloads); await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, filePayloads);
}); });
} }
@ -440,6 +448,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
} }
async _focus(progress: Progress) { async _focus(progress: Progress) {
progress.throwIfAborted(); // Avoid action that has side-effects.
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {}); const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {});
handleInjectedResult(injectedResult); handleInjectedResult(injectedResult);
} }
@ -452,6 +461,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.log(apiLog, `elementHandle.type("${text}")`); progress.log(apiLog, `elementHandle.type("${text}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this._focus(progress); await this._focus(progress);
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.type(text, options); await this._page.keyboard.type(text, options);
}, 'input'); }, 'input');
} }
@ -464,22 +474,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.log(apiLog, `elementHandle.press("${key}")`); progress.log(apiLog, `elementHandle.press("${key}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this._focus(progress); await this._focus(progress);
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.press(key, options); await this._page.keyboard.press(key, options);
}, 'input'); }, 'input');
} }
async check(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async check(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return runAbortableTask(async progress => { return runAbortableTask(progress => this._setChecked(progress, true, options), this._page._logger, this._page._timeoutSettings.timeout(options));
progress.log(apiLog, `elementHandle.check()`);
await this._setChecked(progress, true, options);
}, this._page._logger, this._page._timeoutSettings.timeout(options));
} }
async uncheck(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) { async uncheck(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return runAbortableTask(async progress => { return runAbortableTask(progress => this._setChecked(progress, false, options), this._page._logger, this._page._timeoutSettings.timeout(options));
progress.log(apiLog, `elementHandle.uncheck()`);
await this._setChecked(progress, false, options);
}, this._page._logger, this._page._timeoutSettings.timeout(options));
} }
async _setChecked(progress: Progress, state: boolean, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) { async _setChecked(progress: Progress, state: boolean, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
@ -529,10 +534,10 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<void> { async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<void> {
progress.log(apiLog, ' waiting for element to be displayed, enabled and not moving'); progress.log(apiLog, ' waiting for element to be displayed, enabled and not moving');
const rafCount = this._page._delegate.rafCountForStablePosition(); const rafCount = this._page._delegate.rafCountForStablePosition();
const poll = await this._evaluateHandleInUtility(([injected, node, rafCount]) => { const poll = this._evaluateHandleInUtility(([injected, node, rafCount]) => {
return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount); return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount);
}, rafCount); }, rafCount);
const pollHandler = new InjectedScriptPollHandler<types.InjectedScriptResult>(progress, poll); const pollHandler = new InjectedScriptPollHandler<types.InjectedScriptResult>(progress, await poll);
const injectedResult = await pollHandler.finish(); const injectedResult = await pollHandler.finish();
handleInjectedResult(injectedResult); handleInjectedResult(injectedResult);
progress.log(apiLog, ' element is displayed and does not move'); progress.log(apiLog, ' element is displayed and does not move');
@ -565,6 +570,9 @@ export class InjectedScriptPollHandler<T> {
constructor(progress: Progress, poll: js.JSHandle<types.InjectedScriptPoll<T>>) { constructor(progress: Progress, poll: js.JSHandle<types.InjectedScriptPoll<T>>) {
this._progress = progress; this._progress = progress;
this._poll = poll; this._poll = poll;
// Ensure we cancel the poll before progress aborts and returns:
// - no unnecessary work in the page;
// - no possible side effects after progress promsie rejects.
this._progress.cleanupWhenAborted(() => this.cancel()); this._progress.cleanupWhenAborted(() => this.cancel());
this._streamLogs(poll.evaluateHandle(poll => poll.logs)); this._streamLogs(poll.evaluateHandle(poll => poll.logs));
} }
@ -588,7 +596,7 @@ export class InjectedScriptPollHandler<T> {
await this._finishInternal(); await this._finishInternal();
return result; return result;
} finally { } finally {
this.cancel(); await this.cancel();
} }
} }
@ -598,7 +606,7 @@ export class InjectedScriptPollHandler<T> {
await this._finishInternal(); await this._finishInternal();
return result; return result;
} finally { } finally {
this.cancel(); await this.cancel();
} }
} }
@ -611,12 +619,13 @@ export class InjectedScriptPollHandler<T> {
this._progress.log(apiLog, message); this._progress.log(apiLog, message);
} }
cancel() { async cancel() {
if (!this._poll) if (!this._poll)
return; return;
const copy = this._poll; const copy = this._poll;
this._poll = null; this._poll = null;
copy.evaluate(p => p.cancel()).catch(e => {}).then(() => copy.dispose()); await copy.evaluate(p => p.cancel()).catch(e => {});
copy.dispose();
} }
} }

View file

@ -26,6 +26,7 @@ export interface Progress {
isRunning(): boolean; isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void; cleanupWhenAborted(cleanup: () => any): void;
log(log: Log, message: string | Error): void; log(log: Log, message: string | Error): void;
throwIfAborted(): void;
} }
export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, logger: InnerLogger, timeout: number, apiName?: string): Promise<T> { export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, logger: InnerLogger, timeout: number, apiName?: string): Promise<T> {
@ -89,6 +90,10 @@ export class ProgressController {
this._logger.log(log, message); this._logger.log(log, message);
} }
}, },
throwIfAborted: () => {
if (this._state === 'aborted')
throw new AbortedError();
},
}; };
this._logger.log(apiLog, `=> ${this._apiName} started`); this._logger.log(apiLog, `=> ${this._apiName} started`);
@ -142,3 +147,5 @@ function monotonicTime(): number {
const [seconds, nanoseconds] = process.hrtime(); const [seconds, nanoseconds] = process.hrtime();
return seconds * 1000 + (nanoseconds / 1000000 | 0); return seconds * 1000 + (nanoseconds / 1000000 | 0);
} }
class AbortedError extends Error {}

View file

@ -66,6 +66,13 @@ describe('Page.click', function() {
]).catch(e => {}); ]).catch(e => {});
await context.close(); await context.close();
}); });
it('should avoid side effects after timeout', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html');
const error = await page.click('button', { timeout: 2000, __testHookBeforePointerAction: () => new Promise(f => setTimeout(f, 2500))}).catch(e => e);
await page.waitForTimeout(5000); // Give it some time to click after the test hook is done waiting.
expect(await page.evaluate(() => result)).toBe('Was not clicked');
expect(error.message).toContain('Timeout 2000ms exceeded during page.click.');
});
it('should click the button after navigation ', async({page, server}) => { it('should click the button after navigation ', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html'); await page.goto(server.PREFIX + '/input/button.html');
await page.click('button'); await page.click('button');

View file

@ -57,6 +57,21 @@ describe('Frame.waitForFunction', function() {
}, {}, {polling}); }, {}, {polling});
expect(await timeDelta.jsonValue()).not.toBeLessThan(polling); expect(await timeDelta.jsonValue()).not.toBeLessThan(polling);
}); });
it('should avoid side effects after timeout', async({page, server}) => {
let counter = 0;
page.on('console', () => ++counter);
const error = await page.waitForFunction(() => {
window.counter = (window.counter || 0) + 1;
console.log(window.counter);
}, {}, { polling: 1, timeout: 1000 }).catch(e => e);
const savedCounter = counter;
await page.waitForTimeout(2000); // Give it some time to produce more logs.
expect(error.message).toContain('Timeout 1000ms exceeded during page.waitForFunction');
expect(counter).toBe(savedCounter);
});
it('should throw on polling:mutation', async({page, server}) => { it('should throw on polling:mutation', async({page, server}) => {
const error = await page.waitForFunction(() => true, {}, {polling: 'mutation'}).catch(e => e); const error = await page.waitForFunction(() => true, {}, {polling: 'mutation'}).catch(e => e);
expect(error.message).toBe('Unknown polling option: mutation'); expect(error.message).toBe('Unknown polling option: mutation');