chore: explicitly plumb various errors through the retries (#2554)

We are about to have more errors, so explicit plumbing helps with visibility.
This commit is contained in:
Dmitry Gozman 2020-06-12 14:59:26 -07:00 committed by GitHub
parent 39019e8863
commit d4c466868b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 204 additions and 176 deletions

View File

@ -35,7 +35,6 @@ import { CRPDF } from './crPdf';
import { CRBrowserContext } from './crBrowser';
import * as types from '../types';
import { ConsoleMessage } from '../console';
import { NotConnectedError } from '../errors';
import * as sourceMap from '../utils/sourceMap';
import { rewriteErrorMessage } from '../utils/stackTrace';
@ -245,7 +244,7 @@ export class CRPage implements PageDelegate {
return this._sessionForHandle(handle)._getBoundingBox(handle);
}
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect);
}
@ -832,15 +831,15 @@ class FrameSession {
return {x, y, width, height};
}
async _scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
async _scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
return await this._client.send('DOM.scrollIntoViewIfNeeded', {
objectId: handle._objectId,
rect,
}).then(() => 'success' as const).catch(e => {
}).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
return 'invisible';
return 'notvisible';
if (e instanceof Error && e.message.includes('Node is detached from document'))
throw new NotConnectedError();
return 'notconnected';
throw e;
});
}

View File

@ -28,7 +28,6 @@ import * as js from './javascript';
import { Page } from './page';
import { selectors } from './selectors';
import * as types from './types';
import { NotConnectedError } from './errors';
import { apiLog } from './logger';
import { Progress, runAbortableTask } from './progress';
import DebugScript from './debug/injected/debugScript';
@ -208,15 +207,15 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
injected.dispatchEvent(node, type, eventInit), { type, eventInit });
}
async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<'success' | 'invisible'> {
async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
return await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect);
}
async scrollIntoViewIfNeeded() {
await this._scrollRectIntoViewIfNeeded();
throwIfNotConnected(await this._scrollRectIntoViewIfNeeded());
}
private async _clickablePoint(): Promise<types.Point | 'invisible' | 'outsideviewport'> {
private async _clickablePoint(): Promise<types.Point | 'notvisible' | 'notinviewport'> {
const intersectQuadWithViewport = (quad: types.Quad): types.Quad => {
return quad.map(point => ({
x: Math.min(Math.max(point.x, 0), metrics.width),
@ -241,11 +240,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
this._page._delegate.layoutViewport(),
] as const);
if (!quads || !quads.length)
return 'invisible';
return 'notvisible';
const filtered = quads.map(quad => intersectQuadWithViewport(quad)).filter(quad => computeQuadArea(quad) > 1);
if (!filtered.length)
return 'outsideviewport';
return 'notinviewport';
// Return the middle point of the first quad.
const result = { x: 0, y: 0 };
for (const point of filtered[0]) {
@ -255,13 +254,13 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return result;
}
private async _offsetPoint(offset: types.Point): Promise<types.Point | 'invisible'> {
private async _offsetPoint(offset: types.Point): Promise<types.Point | 'notvisible'> {
const [box, border] = await Promise.all([
this.boundingBox(),
this._evaluateInUtility(([injected, node]) => injected.getElementBorderWidth(node), {}).catch(e => {}),
]);
if (!box || !border)
return 'invisible';
return 'notvisible';
// Make point relative to the padding box to align with offsetX/offsetY.
return {
x: box.x + border.left + offset.x,
@ -269,60 +268,73 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
};
}
async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
let first = true;
while (progress.isRunning()) {
progress.log(apiLog, `${first ? 'attempting' : 'retrying'} ${progress.apiName} action`);
const result = await this._performPointerAction(progress, action, options);
if (result === 'done')
return;
first = false;
if (result === 'notvisible') {
if (options.force)
throw new Error('Element is not visible');
progress.log(apiLog, ' element is not visible');
continue;
}
if (result === 'notinviewport') {
if (options.force)
throw new Error('Element is outside of the viewport');
progress.log(apiLog, ' element is outside of the viewport');
continue;
}
if (result === 'nothittarget') {
if (options.force)
throw new Error('Element does not receive pointer events');
progress.log(apiLog, ' element does not receive pointer events');
continue;
}
if (result === 'notconnected')
return result;
break;
}
return 'done';
}
async _performPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'done' | 'retry'> {
async _performPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notvisible' | 'notconnected' | 'notinviewport' | 'nothittarget' | 'done'> {
const { force = false, position } = options;
if (!force)
await this._waitForDisplayedAtStablePositionAndEnabled(progress);
if (!force) {
const result = await this._waitForDisplayedAtStablePositionAndEnabled(progress);
if (result === 'notconnected')
return 'notconnected';
}
if ((options as any).__testHookAfterStable)
await (options as any).__testHookAfterStable();
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)
throw new Error('Element is not visible');
progress.log(apiLog, ' element is not visible');
return 'retry';
}
if (scrolled === 'notvisible')
return 'notvisible';
if (scrolled === 'notconnected')
return 'notconnected';
progress.log(apiLog, ' done scrolling');
const maybePoint = position ? await this._offsetPoint(position) : await this._clickablePoint();
if (maybePoint === 'invisible') {
if (force)
throw new Error('Element is not visible');
progress.log(apiLog, ' element is not visibile');
return 'retry';
}
if (maybePoint === 'outsideviewport') {
if (force)
throw new Error('Element is outside of the viewport');
progress.log(apiLog, ' element is outside of the viewport');
return 'retry';
}
if (maybePoint === 'notvisible')
return 'notvisible';
if (maybePoint === 'notinviewport')
return 'notinviewport';
const point = roundPoint(maybePoint);
if (!force) {
if ((options as any).__testHookBeforeHitTarget)
await (options as any).__testHookBeforeHitTarget();
progress.log(apiLog, ` checking that element receives pointer events at (${point.x},${point.y})`);
const matchesHitTarget = await this._checkHitTargetAt(point);
if (!matchesHitTarget) {
progress.log(apiLog, ' element does not receive pointer events');
return 'retry';
}
progress.log(apiLog, ` element does receive pointer events, continuing input action`);
const hitTargetResult = await this._checkHitTargetAt(point);
if (hitTargetResult === 'notconnected')
return 'notconnected';
if (hitTargetResult === 'nothittarget')
return 'nothittarget';
progress.log(apiLog, ` element does receive pointer events`);
}
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
@ -347,34 +359,42 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
hover(options: PointerActionOptions & types.PointerActionWaitOptions = {}): Promise<void> {
return runAbortableTask(progress => this._hover(progress, options), this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(async progress => {
throwIfNotConnected(await this._hover(progress, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
_hover(progress: Progress, options: PointerActionOptions & types.PointerActionWaitOptions): Promise<void> {
_hover(progress: Progress, options: PointerActionOptions & types.PointerActionWaitOptions): Promise<'notconnected' | 'done'> {
return this._retryPointerAction(progress, point => this._page.mouse.move(point.x, point.y), options);
}
click(options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
return runAbortableTask(progress => this._click(progress, options), this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(async progress => {
throwIfNotConnected(await this._click(progress, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
_click(progress: Progress, options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
_click(progress: Progress, options: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
return this._retryPointerAction(progress, point => this._page.mouse.click(point.x, point.y, options), options);
}
dblclick(options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
return runAbortableTask(progress => this._dblclick(progress, options), this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(async progress => {
throwIfNotConnected(await this._dblclick(progress, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
_dblclick(progress: Progress, options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
_dblclick(progress: Progress, options: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
return this._retryPointerAction(progress, point => this._page.mouse.dblclick(point.x, point.y, options), options);
}
async selectOption(values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[] | null, options: types.NavigatingActionWaitOptions = {}): Promise<string[]> {
return runAbortableTask(progress => this._selectOption(progress, values, options), this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(async progress => {
return throwIfNotConnected(await this._selectOption(progress, values, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
async _selectOption(progress: Progress, values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[] | null, options: types.NavigatingActionWaitOptions): Promise<string[]> {
async _selectOption(progress: Progress, values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[] | null, options: types.NavigatingActionWaitOptions): Promise<string[] | 'notconnected'> {
let vals: string[] | ElementHandle[] | types.SelectOption[];
if (values === null)
vals = [];
@ -394,61 +414,68 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (option.index !== undefined)
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(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);
return throwIfError(injectedResult);
});
}
async fill(value: string, options: types.NavigatingActionWaitOptions = {}): Promise<void> {
return runAbortableTask(progress => this._fill(progress, value, options), this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(async progress => {
throwIfNotConnected(await this._fill(progress, value, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<void> {
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
progress.log(apiLog, `elementHandle.fill("${value}")`);
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.log(apiLog, ' waiting for element to be visible, enabled and editable');
const poll = await this._evaluateHandleInUtility(([injected, node, value]) => {
return injected.waitForEnabledAndFill(node, value);
}, value);
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const injectedResult = await pollHandler.finish();
const needsInput = handleInjectedResult(injectedResult);
progress.log(apiLog, ' element is visible, enabled and editable');
const filled = throwIfError(await pollHandler.finish());
progress.throwIfAborted(); // Avoid action that has side-effects.
if (needsInput) {
if (filled === 'notconnected')
return 'notconnected';
progress.log(apiLog, ' element is visible, enabled and editable');
if (filled === 'needsinput') {
if (value)
await this._page.keyboard.insertText(value);
else
await this._page.keyboard.press('Delete');
}
return 'done';
}, 'input');
}
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);
const selected = throwIfError(await this._evaluateInUtility(([injected, node]) => injected.selectText(node), {}));
throwIfNotConnected(selected);
}, this._page._logger, 0);
}
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}) {
return runAbortableTask(async progress => this._setInputFiles(progress, files, options), this._page._logger, this._page._timeoutSettings.timeout(options));
return runAbortableTask(async progress => {
throwIfNotConnected(await this._setInputFiles(progress, files, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
async _setInputFiles(progress: Progress, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions) {
const injectedResult = await this._evaluateInUtility(([injected, node]): types.InjectedScriptResult<boolean> => {
async _setInputFiles(progress: Progress, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
const multiple = throwIfError(await this._evaluateInUtility(([injected, node]): types.InjectedScriptResult<'notconnected' | boolean> => {
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
return { status: 'error', error: 'Node is not an HTMLInputElement' };
return { error: 'Node is not an HTMLInputElement', value: false };
if (!node.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
const input = node as Node as HTMLInputElement;
return { status: 'success', value: input.multiple };
}, {});
const multiple = handleInjectedResult(injectedResult);
return { value: input.multiple };
}, {}));
if (multiple === 'notconnected')
return 'notconnected';
let ff: string[] | types.FilePayload[];
if (!Array.isArray(files))
ff = [ files ] as string[] | types.FilePayload[];
@ -472,41 +499,53 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, filePayloads);
});
return 'done';
}
async focus() {
return runAbortableTask(progress => this._focus(progress), this._page._logger, 0);
async focus(): Promise<void> {
return runAbortableTask(async progress => {
throwIfNotConnected(await this._focus(progress));
}, this._page._logger, 0);
}
async _focus(progress: Progress) {
async _focus(progress: Progress): Promise<'notconnected' | 'done'> {
progress.throwIfAborted(); // Avoid action that has side-effects.
const injectedResult = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {});
handleInjectedResult(injectedResult);
return throwIfError(await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {}));
}
async type(text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
return runAbortableTask(progress => this._type(progress, text, options), this._page._logger, this._page._timeoutSettings.timeout(options));
async type(text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}): Promise<void> {
return runAbortableTask(async progress => {
throwIfNotConnected(await this._type(progress, text, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
async _type(progress: Progress, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions) {
async _type(progress: Progress, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
progress.log(apiLog, `elementHandle.type("${text}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this._focus(progress);
const focused = await this._focus(progress);
if (focused === 'notconnected')
return 'notconnected';
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.type(text, options);
return 'done';
}, 'input');
}
async press(key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
return runAbortableTask(progress => this._press(progress, key, options), this._page._logger, this._page._timeoutSettings.timeout(options));
async press(key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}): Promise<void> {
return runAbortableTask(async progress => {
throwIfNotConnected(await this._press(progress, key, options));
}, this._page._logger, this._page._timeoutSettings.timeout(options));
}
async _press(progress: Progress, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions) {
async _press(progress: Progress, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
progress.log(apiLog, `elementHandle.press("${key}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
await this._focus(progress);
const focused = await this._focus(progress);
if (focused === 'notconnected')
return 'notconnected';
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.press(key, options);
return 'done';
}, 'input');
}
@ -562,32 +601,32 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return result;
}
async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<void> {
async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<'notconnected' | 'done'> {
progress.log(apiLog, ' waiting for element to be visible, enabled and not moving');
const rafCount = this._page._delegate.rafCountForStablePosition();
const poll = this._evaluateHandleInUtility(([injected, node, rafCount]) => {
return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount);
}, rafCount);
const pollHandler = new InjectedScriptPollHandler<types.InjectedScriptResult>(progress, await poll);
const injectedResult = await pollHandler.finish();
handleInjectedResult(injectedResult);
const pollHandler = new InjectedScriptPollHandler(progress, await poll);
const result = throwIfError(await pollHandler.finish());
progress.log(apiLog, ' element is visible, enabled and does not move');
return result;
}
async _checkHitTargetAt(point: types.Point): Promise<boolean> {
async _checkHitTargetAt(point: types.Point): Promise<'notconnected' | 'nothittarget' | 'done'> {
const frame = await this.ownerFrame();
if (frame && frame.parentFrame()) {
const element = await frame.frameElement();
const box = await element.boundingBox();
if (!box)
throw new NotConnectedError();
return 'notconnected';
// Translate from viewport coordinates to frame coordinates.
point = { x: point.x - box.x, y: point.y - box.y };
}
const injectedResult = await this._evaluateInUtility(([injected, node, point]) => {
return injected.checkHitTargetAt(node, point);
}, point);
return handleInjectedResult(injectedResult);
return throwIfError(injectedResult);
}
}
@ -668,12 +707,16 @@ export function toFileTransferPayload(files: types.FilePayload[]): types.FileTra
}));
}
function handleInjectedResult<T = undefined>(injectedResult: types.InjectedScriptResult<T>): T {
if (injectedResult.status === 'notconnected')
throw new NotConnectedError();
if (injectedResult.status === 'error')
function throwIfError<T>(injectedResult: types.InjectedScriptResult<T>): T {
if (injectedResult.error)
throw new Error(injectedResult.error);
return injectedResult.value as T;
return injectedResult.value!;
}
function throwIfNotConnected<T>(result: 'notconnected' | T): T {
if (result === 'notconnected')
throw new Error('Element is not attached to the DOM');
return result;
}
function roundPoint(point: types.Point): types.Point {

View File

@ -23,10 +23,4 @@ class CustomError extends Error {
}
}
export class NotConnectedError extends CustomError {
constructor() {
super('Element is not attached to the DOM');
}
}
export class TimeoutError extends CustomError {}

View File

@ -31,7 +31,6 @@ import { RawKeyboardImpl, RawMouseImpl } from './ffInput';
import { FFNetworkManager, headersArray } from './ffNetworkManager';
import { Protocol } from './protocol';
import { selectors } from '../selectors';
import { NotConnectedError } from '../errors';
import { rewriteErrorMessage } from '../utils/stackTrace';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
@ -411,14 +410,14 @@ export class FFPage implements PageDelegate {
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
return await this._session.send('Page.scrollIntoViewIfNeeded', {
frameId: handle._context.frame._id,
objectId: handle._objectId,
rect,
}).then(() => 'success' as const).catch(e => {
}).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node is detached from document'))
throw new NotConnectedError();
return 'notconnected';
throw e;
});
}

View File

@ -19,7 +19,6 @@ import * as fs from 'fs';
import * as util from 'util';
import { ConsoleMessage } from './console';
import * as dom from './dom';
import { NotConnectedError } from './errors';
import { Events } from './events';
import { assert, helper, RegisteredListener, assertMaxArguments, debugAssert } from './helper';
import * as js from './javascript';
@ -452,7 +451,7 @@ export class Frame {
throw new Error('options.waitFor is not supported, did you mean options.state?');
const { state = 'visible' } = options;
if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
throw new Error(`Unsupported waitFor option "${state}"`);
throw new Error(`Unsupported state option "${state}"`);
const { world, task } = selectors._waitForSelectorTask(selector, state);
return runAbortableTask(async progress => {
progress.log(apiLog, `waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
@ -707,23 +706,21 @@ export class Frame {
private async _retryWithSelectorIfNotConnected<R>(
selector: string, options: types.TimeoutOptions,
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R>): Promise<R> {
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R | 'notconnected'>): Promise<R> {
return runAbortableTask(async progress => {
while (progress.isRunning()) {
try {
progress.log(apiLog, `waiting for selector "${selector}"`);
const { world, task } = selectors._waitForSelectorTask(selector, 'attached');
const handle = await this._scheduleRerunnableTask(progress, world, task);
const element = handle.asElement() as dom.ElementHandle<Element>;
progress.cleanupWhenAborted(() => element.dispose());
const result = await action(progress, element);
element.dispose();
return result;
} catch (e) {
if (!(e instanceof NotConnectedError))
throw e;
progress.log(apiLog, `waiting for selector "${selector}"`);
const { world, task } = selectors._waitForSelectorTask(selector, 'attached');
const handle = await this._scheduleRerunnableTask(progress, world, task);
const element = handle.asElement() as dom.ElementHandle<Element>;
progress.cleanupWhenAborted(() => element.dispose());
const result = await action(progress, element);
element.dispose();
if (result === 'notconnected') {
progress.log(apiLog, 'element was detached from the DOM, retrying');
continue;
}
return result;
}
return undefined as any;
}, this._page._logger, this._page._timeoutSettings.timeout(options));
@ -803,7 +800,7 @@ export class Frame {
else if (helper.isNumber(polling))
assert(polling > 0, 'Cannot poll with non-positive interval: ' + polling);
else
throw new Error('Unknown polling options: ' + polling);
throw new Error('Unknown polling option: ' + polling);
const predicateBody = helper.isString(pageFunction) ? 'return (' + pageFunction + ')' : 'return (' + pageFunction + ')(arg)';
const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();

View File

@ -200,11 +200,11 @@ export default class InjectedScript {
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
}
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): types.InjectedScriptResult<string[]> {
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): types.InjectedScriptResult<string[] | 'notconnected'> {
if (node.nodeName.toLowerCase() !== 'select')
return { status: 'error', error: 'Element is not a <select> element.' };
return { error: 'Element is not a <select> element.' };
if (!node.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
const element = node as HTMLSelectElement;
const options = Array.from(element.options);
@ -228,16 +228,16 @@ export default class InjectedScript {
}
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return { status: 'success', value: options.filter(option => option.selected).map(option => option.value) };
return { value: options.filter(option => option.selected).map(option => option.value) };
}
waitForEnabledAndFill(node: Node, value: string): types.InjectedScriptPoll<types.InjectedScriptResult<boolean>> {
waitForEnabledAndFill(node: Node, value: string): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'needsinput' | 'done'>> {
return this.poll('raf', progress => {
if (node.nodeType !== Node.ELEMENT_NODE)
return { status: 'error', error: 'Node is not of type HTMLElement' };
return { error: 'Node is not of type HTMLElement' };
const element = node as HTMLElement;
if (!element.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return false;
@ -248,11 +248,11 @@ export default class InjectedScript {
const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local']);
const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']);
if (!kTextInputTypes.has(type) && !kDateTypes.has(type))
return { status: 'error', error: 'Cannot fill input of type "' + type + '".' };
return { error: 'Cannot fill input of type "' + type + '".' };
if (type === 'number') {
value = value.trim();
if (isNaN(Number(value)))
return { status: 'error', error: 'Cannot type text into input[type=number].' };
return { error: 'Cannot type text into input[type=number].' };
}
if (input.disabled) {
progress.logRepeating(' element is disabled - waiting...');
@ -267,10 +267,10 @@ export default class InjectedScript {
input.focus();
input.value = value;
if (input.value !== value)
return { status: 'error', error: `Malformed ${type} "${value}"` };
return { error: `Malformed ${type} "${value}"` };
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return { status: 'success', value: false }; // We have already changed the value, no need to input it.
return { value: 'done' }; // We have already changed the value, no need to input it.
}
} else if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement;
@ -283,54 +283,56 @@ export default class InjectedScript {
return false;
}
} else if (!element.isContentEditable) {
return { status: 'error', error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
return { error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
}
const result = this.selectText(node);
if (result.status === 'success')
return { status: 'success', value: true }; // Still need to input the value.
return result;
if (result.error)
return { error: result.error };
if (result.value === 'notconnected')
return { value: 'notconnected' };
return { value: 'needsinput' }; // Still need to input the value.
});
}
selectText(node: Node): types.InjectedScriptResult {
selectText(node: Node): types.InjectedScriptResult<'notconnected' | 'done'> {
if (node.nodeType !== Node.ELEMENT_NODE)
return { status: 'error', error: 'Node is not of type HTMLElement' };
return { error: 'Node is not of type HTMLElement' };
if (!node.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
const element = node as HTMLElement;
if (!this.isVisible(element))
return { status: 'error', error: 'Element is not visible' };
return { error: 'Element is not visible' };
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
input.select();
input.focus();
return { status: 'success' };
return { value: 'done' };
}
if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement;
textarea.selectionStart = 0;
textarea.selectionEnd = textarea.value.length;
textarea.focus();
return { status: 'success' };
return { value: 'done' };
}
const range = element.ownerDocument!.createRange();
range.selectNodeContents(element);
const selection = element.ownerDocument!.defaultView!.getSelection();
if (!selection)
return { status: 'error', error: 'Element belongs to invisible iframe.' };
return { error: 'Element belongs to invisible iframe.' };
selection.removeAllRanges();
selection.addRange(range);
element.focus();
return { status: 'success' };
return { value: 'done' };
}
focusNode(node: Node): types.InjectedScriptResult {
focusNode(node: Node): types.InjectedScriptResult<'notconnected' | 'done'> {
if (!node.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
if (!(node as any)['focus'])
return { status: 'error', error: 'Node is not an HTML or SVG element.' };
return { error: 'Node is not an HTML or SVG element.' };
(node as HTMLElement | SVGElement).focus();
return { status: 'success' };
return { value: 'done' };
}
isCheckboxChecked(node: Node) {
@ -379,26 +381,26 @@ export default class InjectedScript {
input.dispatchEvent(new Event('change', { 'bubbles': true }));
}
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.InjectedScriptPoll<types.InjectedScriptResult> {
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'done'>> {
return this._runCancellablePoll(async progress => {
if (!node.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element)
return { status: 'notconnected' };
return { value: 'notconnected' };
let lastRect: types.Rect | undefined;
let counter = 0;
let samePositionCounter = 0;
let lastTime = 0;
return this._pollRaf(progress, (): types.InjectedScriptResult | false => {
return this._pollRaf(progress, (): types.InjectedScriptResult<'notconnected' | 'done'> | false => {
// First raf happens in the same animation frame as evaluation, so it does not produce
// any client rect difference compared to synchronous call. We skip the synchronous call
// and only force layout during actual rafs as a small optimisation.
if (++counter === 1)
return false;
if (!node.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
// Drop frames that are shorter than 16ms - WebKit Win bug.
const time = performance.now();
@ -426,7 +428,7 @@ export default class InjectedScript {
const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
if (isDisplayed && isStable && isVisible && !isDisabled)
return { status: 'success' };
return { value: 'done' };
if (!isDisplayed || !isVisible)
progress.logRepeating(` element is not visible - waiting...`);
@ -439,15 +441,15 @@ export default class InjectedScript {
});
}
checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult<boolean> {
checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult<'notconnected' | 'nothittarget' | 'done'> {
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element || !element.isConnected)
return { status: 'notconnected' };
return { value: 'notconnected' };
element = element.closest('button, [role=button]') || element;
let hitElement = this.deepElementFromPoint(document, point.x, point.y);
while (hitElement && hitElement !== element)
hitElement = this._parentElementOrShadowHost(hitElement);
return { status: 'success', value: hitElement === element };
return { value: hitElement === element ? 'done' : 'nothittarget' };
}
dispatchEvent(node: Node, type: string, eventInit: Object) {

View File

@ -69,7 +69,7 @@ export interface PageDelegate {
setInputFiles(handle: dom.ElementHandle<HTMLInputElement>, files: types.FilePayload[]): Promise<void>;
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>;
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'>;
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'>;
rafCountForStablePosition(): number;
getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>;

View File

@ -154,10 +154,7 @@ export type JSCoverageOptions = {
reportAnonymousScripts?: boolean,
};
export type InjectedScriptResult<T = undefined> =
(T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) |
{ status: 'notconnected' } |
{ status: 'error', error: string };
export type InjectedScriptResult<T> = { error?: string, value?: T };
export type InjectedScriptProgress = {
canceled: boolean,

View File

@ -36,7 +36,6 @@ import { WKBrowserContext } from './wkBrowser';
import { selectors } from '../selectors';
import * as jpeg from 'jpeg-js';
import * as png from 'pngjs';
import { NotConnectedError } from '../errors';
import { ConsoleMessageLocation } from '../console';
import { JSHandle } from '../javascript';
@ -746,15 +745,15 @@ export class WKPage implements PageDelegate {
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'success' | 'invisible'> {
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
return await this._session.send('DOM.scrollIntoViewIfNeeded', {
objectId: handle._objectId,
rect,
}).then(() => 'success' as const).catch(e => {
}).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
return 'invisible';
return 'notvisible';
if (e instanceof Error && e.message.includes('Node is detached from document'))
throw new NotConnectedError();
return 'notconnected';
throw e;
});
}

View File

@ -621,7 +621,6 @@ describe('Page.click', function() {
const error = await promise;
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
expect(error.message).toContain('Element is not attached to the DOM');
expect(error.name).toContain('NotConnectedError');
});
it('should fail when element detaches after animation', async({page, server}) => {
await page.goto(server.PREFIX + '/input/animating-button.html');
@ -632,7 +631,6 @@ describe('Page.click', function() {
const error = await promise;
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
expect(error.message).toContain('Element is not attached to the DOM');
expect(error.name).toContain('NotConnectedError');
});
it('should retry when element detaches after animation', async({page, server}) => {
await page.goto(server.PREFIX + '/input/animating-button.html');

View File

@ -463,25 +463,25 @@ describe('Frame.waitForSelector', function() {
await page.waitForSelector('.zombo', { timeout: 10 }).catch(e => error = e);
expect(error.stack).toContain('waittask.spec.js');
});
it('should throw for unknown waitFor option', async({page, server}) => {
it('should throw for unknown state option', async({page, server}) => {
await page.setContent('<section>test</section>');
const error = await page.waitForSelector('section', { state: 'foo' }).catch(e => e);
expect(error.message).toContain('Unsupported waitFor option');
expect(error.message).toContain('Unsupported state option "foo"');
});
it('should throw for visibility option', async({page, server}) => {
await page.setContent('<section>test</section>');
const error = await page.waitForSelector('section', { visibility: 'hidden' }).catch(e => e);
expect(error.message).toBe('options.visibility is not supported, did you mean options.state?');
});
it('should throw for true waitFor option', async({page, server}) => {
it('should throw for true state option', async({page, server}) => {
await page.setContent('<section>test</section>');
const error = await page.waitForSelector('section', { state: true }).catch(e => e);
expect(error.message).toContain('Unsupported waitFor option');
expect(error.message).toContain('Unsupported state option "true"');
});
it('should throw for false waitFor option', async({page, server}) => {
it('should throw for false state option', async({page, server}) => {
await page.setContent('<section>test</section>');
const error = await page.waitForSelector('section', { state: false }).catch(e => e);
expect(error.message).toContain('Unsupported waitFor option');
expect(error.message).toContain('Unsupported state option "false"');
});
it('should support >> selector syntax', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);