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);
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);
if (scrolled === 'invisible') {
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 () => {
if ((options as any).__testHookBeforePointerAction)
await (options as any).__testHookBeforePointerAction();
progress.throwIfAborted(); // Avoid action that has side-effects.
let restoreModifiers: input.Modifier[] | undefined;
if (options && 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) + '"');
}
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);
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 injectedResult = await pollHandler.finish();
const needsInput = handleInjectedResult(injectedResult);
progress.throwIfAborted(); // Avoid action that has side-effects.
if (needsInput) {
if (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> {
return runAbortableTask(async progress => {
progress.throwIfAborted(); // Avoid action that has side-effects.
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {});
handleInjectedResult(injectedResult);
}, 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 () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
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) {
progress.throwIfAborted(); // Avoid action that has side-effects.
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {});
handleInjectedResult(injectedResult);
}
@ -452,6 +461,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.log(apiLog, `elementHandle.type("${text}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this._focus(progress);
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.type(text, options);
}, 'input');
}
@ -464,22 +474,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.log(apiLog, `elementHandle.press("${key}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this._focus(progress);
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.press(key, options);
}, 'input');
}
async check(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return runAbortableTask(async progress => {
progress.log(apiLog, `elementHandle.check()`);
await this._setChecked(progress, true, options);
}, this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(progress => this._setChecked(progress, true, options), this._page._logger, this._page._timeoutSettings.timeout(options));
}
async uncheck(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return runAbortableTask(async progress => {
progress.log(apiLog, `elementHandle.uncheck()`);
await this._setChecked(progress, false, options);
}, this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(progress => this._setChecked(progress, false, options), this._page._logger, this._page._timeoutSettings.timeout(options));
}
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> {
progress.log(apiLog, ' waiting for element to be displayed, enabled and not moving');
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);
}, rafCount);
const pollHandler = new InjectedScriptPollHandler<types.InjectedScriptResult>(progress, poll);
const pollHandler = new InjectedScriptPollHandler<types.InjectedScriptResult>(progress, await poll);
const injectedResult = await pollHandler.finish();
handleInjectedResult(injectedResult);
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>>) {
this._progress = progress;
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._streamLogs(poll.evaluateHandle(poll => poll.logs));
}
@ -588,7 +596,7 @@ export class InjectedScriptPollHandler<T> {
await this._finishInternal();
return result;
} finally {
this.cancel();
await this.cancel();
}
}
@ -598,7 +606,7 @@ export class InjectedScriptPollHandler<T> {
await this._finishInternal();
return result;
} finally {
this.cancel();
await this.cancel();
}
}
@ -611,12 +619,13 @@ export class InjectedScriptPollHandler<T> {
this._progress.log(apiLog, message);
}
cancel() {
async cancel() {
if (!this._poll)
return;
const copy = this._poll;
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;
cleanupWhenAborted(cleanup: () => any): 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> {
@ -89,6 +90,10 @@ export class ProgressController {
this._logger.log(log, message);
}
},
throwIfAborted: () => {
if (this._state === 'aborted')
throw new AbortedError();
},
};
this._logger.log(apiLog, `=> ${this._apiName} started`);
@ -142,3 +147,5 @@ function monotonicTime(): number {
const [seconds, nanoseconds] = process.hrtime();
return seconds * 1000 + (nanoseconds / 1000000 | 0);
}
class AbortedError extends Error {}

View file

@ -66,6 +66,13 @@ describe('Page.click', function() {
]).catch(e => {});
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}) => {
await page.goto(server.PREFIX + '/input/button.html');
await page.click('button');

View file

@ -57,6 +57,21 @@ describe('Frame.waitForFunction', function() {
}, {}, {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}) => {
const error = await page.waitForFunction(() => true, {}, {polling: 'mutation'}).catch(e => e);
expect(error.message).toBe('Unknown polling option: mutation');