fix(errors): strict error handling around element operations (#2567)

- Gave all possible dom errors distinct names, and throw them on the node side.
- Separated errors into FatalDOMError and RetargetableDOMError.
  Fatal errors are unrecoverable. Retargetable errors
  could be resolved by requerying the selector.
- This exposed a number of unhandled 'notconnected' cases.
- Added helper functions to handle errors and ensure TypeScript catches
  unhandled ones.
This commit is contained in:
Dmitry Gozman 2020-06-24 15:12:17 -07:00 committed by GitHub
parent 8fbd647099
commit 39ce35e154
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 345 additions and 290 deletions

View File

@ -1302,10 +1302,11 @@ const fs = require('fs');
- `options` <[Object]>
- `noWaitAfter` <[boolean]> Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. Defaults to `false`.
- `timeout` <[number]> Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by using the [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods.
- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully filled. The promise will be rejected if there is no element matching `selector`.
- returns: <[Promise]>
This method focuses the element and triggers an `input` event after filling.
If there's no text `<input>`, `<textarea>` or `[contenteditable]` element matching `selector`, the method throws an error. Note that you can pass an empty string to clear the input field.
This method waits for an element matching `selector`, waits for [actionability](./actionability.md) checks, focuses the element, fills it and triggers an `input` event after filling.
If the element matching `selector` is not an `<input>`, `<textarea>` or `[contenteditable]` element, this method throws an error.
Note that you can pass an empty string to clear the input field.
To send fine-grained keyboard events, use [`page.type`](#pagetypeselector-text-options).
@ -2253,10 +2254,11 @@ await resultHandle.dispose();
- `options` <[Object]>
- `noWaitAfter` <[boolean]> Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. Defaults to `false`.
- `timeout` <[number]> Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by using the [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods.
- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully filled. The promise will be rejected if there is no element matching `selector`.
- returns: <[Promise]>
This method focuses the element and triggers an `input` event after filling.
If there's no text `<input>`, `<textarea>` or `[contenteditable]` element matching `selector`, the method throws an error.
This method waits for an element matching `selector`, waits for [actionability](./actionability.md) checks, focuses the element, fills it and triggers an `input` event after filling.
If the element matching `selector` is not an `<input>`, `<textarea>` or `[contenteditable]` element, this method throws an error.
Note that you can pass an empty string to clear the input field.
To send fine-grained keyboard events, use [`frame.type`](#frametypeselector-text-options).
@ -2791,10 +2793,11 @@ await elementHandle.dispatchEvent('dragstart', { dataTransfer });
- `options` <[Object]>
- `noWaitAfter` <[boolean]> Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. Defaults to `false`.
- `timeout` <[number]> Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by using the [browserContext.setDefaultTimeout(timeout)](#browsercontextsetdefaulttimeouttimeout) or [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) methods.
- returns: <[Promise]> Promise which resolves when the element is successfully filled.
- returns: <[Promise]>
This method focuses the element and triggers an `input` event after filling.
If element is not a text `<input>`, `<textarea>` or `[contenteditable]` element, the method throws an error.
This method waits for [actionability](./actionability.md) checks, focuses the element, fills it and triggers an `input` event after filling.
If the element is not an `<input>`, `<textarea>` or `[contenteditable]` element, this method throws an error.
Note that you can pass an empty string to clear the input field.
#### elementHandle.focus()
- returns: <[Promise]>

View File

@ -243,7 +243,7 @@ export class CRPage implements PageDelegate {
return this._sessionForHandle(handle)._getBoundingBox(handle);
}
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> {
return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect);
}
@ -825,15 +825,15 @@ class FrameSession {
return {x, y, width, height};
}
async _scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
async _scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> {
return await this._client.send('DOM.scrollIntoViewIfNeeded', {
objectId: handle._objectId,
rect,
}).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
return 'notvisible';
return 'error:notvisible';
if (e instanceof Error && e.message.includes('Node is detached from document'))
return 'notconnected';
return 'error:notconnected';
throw e;
});
}

27
src/common/domErrors.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type FatalDOMError =
'error:notelement' |
'error:nothtmlelement' |
'error:notfillableelement' |
'error:notfillableinputtype' |
'error:notfillablenumberinput' |
'error:notvaliddate' |
'error:notinput' |
'error:notselect';
export type RetargetableDOMError = 'error:notconnected';

View File

@ -29,6 +29,7 @@ import { selectors } from './selectors';
import * as types from './types';
import { Progress, ProgressController } from './progress';
import DebugScript from './debug/injected/debugScript';
import { FatalDOMError, RetargetableDOMError } from './common/domErrors';
export class FrameExecutionContext extends js.ExecutionContext {
readonly frame: frames.Frame;
@ -161,12 +162,12 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async getAttribute(name: string): Promise<string | null> {
return this._evaluateInUtility(([injeced, node, name]) => {
return throwFatalDOMError(await this._evaluateInUtility(([injeced, node, name]) => {
if (node.nodeType !== Node.ELEMENT_NODE)
throw new Error('Not an element');
return 'error:notelement';
const element = node as unknown as Element;
return element.getAttribute(name);
}, name);
return { value: element.getAttribute(name) };
}, name)).value;
}
async textContent(): Promise<string | null> {
@ -174,23 +175,23 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async innerText(): Promise<string> {
return this._evaluateInUtility(([injected, node]) => {
return throwFatalDOMError(await this._evaluateInUtility(([injected, node]) => {
if (node.nodeType !== Node.ELEMENT_NODE)
throw new Error('Not an element');
return 'error:notelement';
if (node.namespaceURI !== 'http://www.w3.org/1999/xhtml')
throw new Error('Not an HTMLElement');
return 'error:nothtmlelement';
const element = node as unknown as HTMLElement;
return element.innerText;
}, {});
return { value: element.innerText };
}, {})).value;
}
async innerHTML(): Promise<string> {
return this._evaluateInUtility(([injected, node]) => {
return throwFatalDOMError(await this._evaluateInUtility(([injected, node]) => {
if (node.nodeType !== Node.ELEMENT_NODE)
throw new Error('Not an element');
return 'error:notelement';
const element = node as unknown as Element;
return element.innerHTML;
}, {});
return { value: element.innerHTML };
}, {})).value;
}
async dispatchEvent(type: string, eventInit: Object = {}) {
@ -198,21 +199,19 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
injected.dispatchEvent(node, type, eventInit), { type, eventInit });
}
async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<'notvisible' | 'notconnected' | 'done'> {
async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> {
return await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect);
}
async _waitAndScrollIntoViewIfNeeded(progress: Progress): Promise<void> {
while (progress.isRunning()) {
const waited = await this._waitForVisible(progress);
throwIfNotConnected(waited);
assertDone(throwRetargetableDOMError(await this._waitForVisible(progress)));
progress.throwIfAborted(); // Avoid action that has side-effects.
const result = await this._scrollRectIntoViewIfNeeded();
throwIfNotConnected(result);
if (result === 'notvisible')
const result = throwRetargetableDOMError(await this._scrollRectIntoViewIfNeeded());
if (result === 'error:notvisible')
continue;
assert(result === 'done');
assertDone(result);
return;
}
}
@ -223,15 +222,15 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
this._page._timeoutSettings.timeout(options), 'scrollIntoViewIfNeeded');
}
private async _waitForVisible(progress: Progress): Promise<'notconnected' | 'done'> {
private async _waitForVisible(progress: Progress): Promise<'error:notconnected' | 'done'> {
const poll = await this._evaluateHandleInUtility(([injected, node]) => {
return injected.waitForNodeVisible(node);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
return throwIfError(await pollHandler.finish());
return throwFatalDOMError(await pollHandler.finish());
}
private async _clickablePoint(): Promise<types.Point | 'notvisible' | 'notinviewport'> {
private async _clickablePoint(): Promise<types.Point | 'error:notvisible' | 'error:notinviewport'> {
const intersectQuadWithViewport = (quad: types.Quad): types.Quad => {
return quad.map(point => ({
x: Math.min(Math.max(point.x, 0), metrics.width),
@ -256,11 +255,11 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
this._page.mainFrame()._utilityContext().then(utility => utility.evaluateInternal(() => ({width: innerWidth, height: innerHeight}))),
] as const);
if (!quads || !quads.length)
return 'notvisible';
return 'error:notvisible';
const filtered = quads.map(quad => intersectQuadWithViewport(quad)).filter(quad => computeQuadArea(quad) > 1);
if (!filtered.length)
return 'notinviewport';
return 'error:notinviewport';
// Return the middle point of the first quad.
const result = { x: 0, y: 0 };
for (const point of filtered[0]) {
@ -270,13 +269,13 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return result;
}
private async _offsetPoint(offset: types.Point): Promise<types.Point | 'notvisible'> {
private async _offsetPoint(offset: types.Point): Promise<types.Point | 'error:notvisible'> {
const [box, border] = await Promise.all([
this.boundingBox(),
this._evaluateInUtility(([injected, node]) => injected.getElementBorderWidth(node), {}).catch(e => {}),
]);
if (!box || !border)
return 'notvisible';
return 'error:notvisible';
// Make point relative to the padding box to align with offsetX/offsetY.
return {
x: box.x + border.left + offset.x,
@ -284,43 +283,41 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
};
}
async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
async _retryPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
let first = true;
while (progress.isRunning()) {
progress.logger.info(`${first ? 'attempting' : 'retrying'} ${progress.apiName} action`);
const result = await this._performPointerAction(progress, action, options);
first = false;
if (result === 'notvisible') {
if (result === 'error:notvisible') {
if (options.force)
throw new Error('Element is not visible');
progress.logger.info(' element is not visible');
continue;
}
if (result === 'notinviewport') {
if (result === 'error:notinviewport') {
if (options.force)
throw new Error('Element is outside of the viewport');
progress.logger.info(' element is outside of the viewport');
continue;
}
if (result === 'nothittarget') {
if (result === 'error:nothittarget') {
if (options.force)
throw new Error('Element does not receive pointer events');
progress.logger.info(' element does not receive pointer events');
continue;
}
if (result === 'notconnected')
return result;
break;
return result;
}
return 'done';
}
async _performPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notvisible' | 'notconnected' | 'notinviewport' | 'nothittarget' | 'done'> {
async _performPointerAction(progress: Progress, action: (point: types.Point) => Promise<void>, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:nothittarget' | 'done'> {
const { force = false, position } = options;
if (!force) {
const result = await this._waitForDisplayedAtStablePositionAndEnabled(progress);
if (result === 'notconnected')
return 'notconnected';
if (result !== 'done')
return result;
}
if ((options as any).__testHookAfterStable)
await (options as any).__testHookAfterStable();
@ -328,17 +325,13 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
progress.logger.info(' 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 === 'notvisible')
return 'notvisible';
if (scrolled === 'notconnected')
return 'notconnected';
if (scrolled !== 'done')
return scrolled;
progress.logger.info(' done scrolling');
const maybePoint = position ? await this._offsetPoint(position) : await this._clickablePoint();
if (maybePoint === 'notvisible')
return 'notvisible';
if (maybePoint === 'notinviewport')
return 'notinviewport';
if (typeof maybePoint === 'string')
return maybePoint;
const point = roundPoint(maybePoint);
if (!force) {
@ -346,10 +339,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
await (options as any).__testHookBeforeHitTarget();
progress.logger.info(` checking that element receives pointer events at (${point.x},${point.y})`);
const hitTargetResult = await this._checkHitTargetAt(point);
if (hitTargetResult === 'notconnected')
return 'notconnected';
if (hitTargetResult === 'nothittarget')
return 'nothittarget';
if (hitTargetResult !== 'done')
return hitTargetResult;
progress.logger.info(` element does receive pointer events`);
}
@ -376,41 +367,45 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
hover(options: types.PointerActionOptions & types.PointerActionWaitOptions = {}): Promise<void> {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._hover(progress, options));
const result = await this._hover(progress, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'hover');
}
_hover(progress: Progress, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise<'notconnected' | 'done'> {
_hover(progress: Progress, options: types.PointerActionOptions & types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> {
return this._retryPointerAction(progress, point => this._page.mouse.move(point.x, point.y), options);
}
click(options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._click(progress, options));
const result = await this._click(progress, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'click');
}
_click(progress: Progress, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
_click(progress: Progress, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
return this._retryPointerAction(progress, point => this._page.mouse.click(point.x, point.y, options), options);
}
dblclick(options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._dblclick(progress, options));
const result = await this._dblclick(progress, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'dblclick');
}
_dblclick(progress: Progress, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
_dblclick(progress: Progress, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error: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 this._runAbortableTask(async progress => {
return throwIfNotConnected(await this._selectOption(progress, values, options));
const result = await this._selectOption(progress, values, options);
return throwRetargetableDOMError(result);
}, this._page._timeoutSettings.timeout(options), 'selectOption');
}
async _selectOption(progress: Progress, values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[] | null, options: types.NavigatingActionWaitOptions): Promise<string[] | 'notconnected'> {
async _selectOption(progress: Progress, values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[] | null, options: types.NavigatingActionWaitOptions): Promise<string[] | 'error:notconnected'> {
let vals: string[] | ElementHandle[] | types.SelectOption[];
if (values === null)
vals = [];
@ -432,18 +427,18 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
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 throwIfError(injectedResult);
return throwFatalDOMError(await this._evaluateInUtility(([injected, node, selectOptions]) => injected.selectOptions(node, selectOptions), selectOptions));
});
}
async fill(value: string, options: types.NavigatingActionWaitOptions = {}): Promise<void> {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._fill(progress, value, options));
const result = await this._fill(progress, value, options);
assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'fill');
}
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
progress.logger.info(`elementHandle.fill("${value}")`);
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
@ -452,16 +447,19 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return injected.waitForEnabledAndFill(node, value);
}, value);
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const filled = throwIfError(await pollHandler.finish());
const filled = throwFatalDOMError(await pollHandler.finish());
progress.throwIfAborted(); // Avoid action that has side-effects.
if (filled === 'notconnected')
return 'notconnected';
if (filled === 'error:notconnected')
return filled;
progress.logger.info(' element is visible, enabled and editable');
if (filled === 'needsinput') {
progress.throwIfAborted(); // Avoid action that has side-effects.
if (value)
await this._page.keyboard.insertText(value);
else
await this._page.keyboard.press('Delete');
} else {
assertDone(filled);
}
return 'done';
}, 'input');
@ -474,28 +472,29 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return injected.waitForVisibleAndSelectText(node);
}, {});
const pollHandler = new InjectedScriptPollHandler(progress, poll);
const result = throwIfError(await pollHandler.finish());
throwIfNotConnected(result);
const result = throwFatalDOMError(await pollHandler.finish());
assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'selectText');
}
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}) {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._setInputFiles(progress, files, options));
const result = await this._setInputFiles(progress, files, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'setInputFiles');
}
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> => {
async _setInputFiles(progress: Progress, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
const multiple = throwFatalDOMError(await this._evaluateInUtility(([injected, node]): 'error:notinput' | 'error:notconnected' | boolean => {
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
return { error: 'Node is not an HTMLInputElement', value: false };
return 'error:notinput';
if (!node.isConnected)
return { value: 'notconnected' };
return 'error:notconnected';
const input = node as Node as HTMLInputElement;
return { value: input.multiple };
return input.multiple;
}, {}));
if (multiple === 'notconnected')
return 'notconnected';
if (typeof multiple === 'string')
return multiple;
let ff: string[] | types.FilePayload[];
if (!Array.isArray(files))
ff = [ files ] as string[] | types.FilePayload[];
@ -524,27 +523,30 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async focus(): Promise<void> {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._focus(progress));
const result = await this._focus(progress);
return assertDone(throwRetargetableDOMError(result));
}, 0, 'focus');
}
async _focus(progress: Progress): Promise<'notconnected' | 'done'> {
async _focus(progress: Progress): Promise<'error:notconnected' | 'done'> {
progress.throwIfAborted(); // Avoid action that has side-effects.
return throwIfError(await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {}));
const result = await this._evaluateInUtility(([injected, node]) => injected.focusNode(node), {});
return throwFatalDOMError(result);
}
async type(text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}): Promise<void> {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._type(progress, text, options));
const result = await this._type(progress, text, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'type');
}
async _type(progress: Progress, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
async _type(progress: Progress, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
progress.logger.info(`elementHandle.type("${text}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
const focused = await this._focus(progress);
if (focused === 'notconnected')
return 'notconnected';
const result = await this._focus(progress);
if (result !== 'done')
return result;
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.type(text, options);
return 'done';
@ -553,16 +555,17 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
async press(key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}): Promise<void> {
return this._runAbortableTask(async progress => {
throwIfNotConnected(await this._press(progress, key, options));
const result = await this._press(progress, key, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'press');
}
async _press(progress: Progress, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<'notconnected' | 'done'> {
async _press(progress: Progress, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
progress.logger.info(`elementHandle.press("${key}")`);
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
const focused = await this._focus(progress);
if (focused === 'notconnected')
return 'notconnected';
const result = await this._focus(progress);
if (result !== 'done')
return result;
progress.throwIfAborted(); // Avoid action that has side-effects.
await this._page.keyboard.press(key, options);
return 'done';
@ -570,19 +573,28 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async check(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return this._runAbortableTask(progress => this._setChecked(progress, true, options), this._page._timeoutSettings.timeout(options), 'check');
return this._runAbortableTask(async progress => {
const result = await this._setChecked(progress, true, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'check');
}
async uncheck(options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
return this._runAbortableTask(progress => this._setChecked(progress, false, options), this._page._timeoutSettings.timeout(options), 'uncheck');
return this._runAbortableTask(async progress => {
const result = await this._setChecked(progress, false, options);
return assertDone(throwRetargetableDOMError(result));
}, this._page._timeoutSettings.timeout(options), 'uncheck');
}
async _setChecked(progress: Progress, state: boolean, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
async _setChecked(progress: Progress, state: boolean, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
if (await this._evaluateInUtility(([injected, node]) => injected.isCheckboxChecked(node), {}) === state)
return;
await this._click(progress, options);
return 'done';
const result = await this._click(progress, options);
if (result !== 'done')
return result;
if (await this._evaluateInUtility(([injected, node]) => injected.isCheckboxChecked(node), {}) !== state)
throw new Error('Unable to click checkbox');
return 'done';
}
async boundingBox(): Promise<types.Rect | null> {
@ -623,32 +635,29 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return result;
}
async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<'notconnected' | 'done'> {
async _waitForDisplayedAtStablePositionAndEnabled(progress: Progress): Promise<'error:notconnected' | 'done'> {
progress.logger.info(' 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(progress, await poll);
const result = throwIfError(await pollHandler.finish());
const result = await pollHandler.finish();
progress.logger.info(' element is visible, enabled and does not move');
return result;
}
async _checkHitTargetAt(point: types.Point): Promise<'notconnected' | 'nothittarget' | 'done'> {
async _checkHitTargetAt(point: types.Point): Promise<'error:notconnected' | 'error:nothittarget' | 'done'> {
const frame = await this.ownerFrame();
if (frame && frame.parentFrame()) {
const element = await frame.frameElement();
const box = await element.boundingBox();
if (!box)
return 'notconnected';
return 'error: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 throwIfError(injectedResult);
return this._evaluateInUtility(([injected, node, point]) => injected.checkHitTargetAt(node, point), point);
}
}
@ -729,18 +738,36 @@ export function toFileTransferPayload(files: types.FilePayload[]): types.FileTra
}));
}
function throwIfError<T>(injectedResult: types.InjectedScriptResult<T>): T {
if (injectedResult.error)
throw new Error(injectedResult.error);
return injectedResult.value!;
function throwFatalDOMError<T>(result: T | FatalDOMError): T {
if (result === 'error:notelement')
throw new Error('Node is not an element');
if (result === 'error:nothtmlelement')
throw new Error('Not an HTMLElement');
if (result === 'error:notfillableelement')
throw new Error('Element is not an <input>, <textarea> or [contenteditable] element');
if (result === 'error:notfillableinputtype')
throw new Error('Input of this type cannot be filled');
if (result === 'error:notfillablenumberinput')
throw new Error('Cannot type text into input[type=number]');
if (result === 'error:notvaliddate')
throw new Error(`Malformed value`);
if (result === 'error:notinput')
throw new Error('Node is not an HTMLInputElement');
if (result === 'error:notselect')
throw new Error('Element is not a <select> element.');
return result;
}
function throwIfNotConnected<T>(result: 'notconnected' | T): T {
if (result === 'notconnected')
function throwRetargetableDOMError<T>(result: T | RetargetableDOMError): T {
if (result === 'error:notconnected')
throw new Error('Element is not attached to the DOM');
return result;
}
function assertDone(result: 'done'): void {
// This function converts 'done' to void and ensures typescript catches unhandled errors.
}
function roundPoint(point: types.Point): types.Point {
return {
x: (point.x * 100 | 0) / 100,

View File

@ -410,16 +410,16 @@ 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<'notvisible' | 'notconnected' | 'done'> {
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> {
return await this._session.send('Page.scrollIntoViewIfNeeded', {
frameId: handle._context.frame._id,
objectId: handle._objectId,
rect,
}).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node is detached from document'))
return 'notconnected';
return 'error:notconnected';
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
return 'notvisible';
return 'error:notvisible';
throw e;
});
}

View File

@ -714,7 +714,7 @@ export class Frame {
private async _retryWithSelectorIfNotConnected<R>(
selector: string, options: types.TimeoutOptions,
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R | 'notconnected'>,
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>,
apiName: string): Promise<R> {
return this._runAbortableTask(async progress => {
while (progress.isRunning()) {
@ -725,7 +725,7 @@ export class Frame {
progress.cleanupWhenAborted(() => element.dispose());
const result = await action(progress, element);
element.dispose();
if (result === 'notconnected') {
if (result === 'error:notconnected') {
progress.logger.info('element was detached from the DOM, retrying');
continue;
}
@ -814,7 +814,9 @@ export class Frame {
const injectedScript = await context.injectedScript();
return context.evaluateHandleInternal(({ injectedScript, predicateBody, polling, arg }) => {
const innerPredicate = new Function('arg', predicateBody) as (arg: any) => R;
return injectedScript.poll(polling, () => innerPredicate(arg));
if (polling === 'raf')
return injectedScript.pollRaf((progress, continuePolling) => innerPredicate(arg) || continuePolling);
return injectedScript.pollInterval(polling, (progress, continuePolling) => innerPredicate(arg) || continuePolling);
}, { injectedScript, predicateBody, polling, arg });
};
return this._runAbortableTask(

View File

@ -21,9 +21,9 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { createTextSelector } from './textSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { ParsedSelector } from '../common/selectorParser';
import { FatalDOMError } from '../common/domErrors';
type Falsy = false | 0 | '' | undefined | null;
type Predicate<T> = (progress: types.InjectedScriptProgress) => T | Falsy;
type Predicate<T> = (progress: types.InjectedScriptProgress, continuePolling: symbol) => T | symbol;
export default class InjectedScript {
readonly engines: Map<string, SelectorEngine>;
@ -105,53 +105,59 @@ export default class InjectedScript {
return rect.width > 0 && rect.height > 0;
}
private _pollRaf<T>(progress: types.InjectedScriptProgress, predicate: Predicate<T>): Promise<T> {
let fulfill: (result: T) => void;
let reject: (error: Error) => void;
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
pollRaf<T>(predicate: Predicate<T>): types.InjectedScriptPoll<T> {
return this._runAbortableTask(progress => {
let fulfill: (result: T) => void;
let reject: (error: Error) => void;
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
const onRaf = () => {
if (progress.canceled)
return;
try {
const success = predicate(progress);
if (success)
fulfill(success);
else
requestAnimationFrame(onRaf);
} catch (e) {
reject(e);
}
};
const onRaf = () => {
if (progress.aborted)
return;
try {
const continuePolling = Symbol('continuePolling');
const success = predicate(progress, continuePolling);
if (success !== continuePolling)
fulfill(success as T);
else
requestAnimationFrame(onRaf);
} catch (e) {
reject(e);
}
};
onRaf();
return result;
onRaf();
return result;
});
}
private _pollInterval<T>(progress: types.InjectedScriptProgress, pollInterval: number, predicate: Predicate<T>): Promise<T> {
let fulfill: (result: T) => void;
let reject: (error: Error) => void;
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
pollInterval<T>(pollInterval: number, predicate: Predicate<T>): types.InjectedScriptPoll<T> {
return this._runAbortableTask(progress => {
let fulfill: (result: T) => void;
let reject: (error: Error) => void;
const result = new Promise<T>((f, r) => { fulfill = f; reject = r; });
const onTimeout = () => {
if (progress.canceled)
return;
try {
const success = predicate(progress);
if (success)
fulfill(success);
else
setTimeout(onTimeout, pollInterval);
} catch (e) {
reject(e);
}
};
const onTimeout = () => {
if (progress.aborted)
return;
try {
const continuePolling = Symbol('continuePolling');
const success = predicate(progress, continuePolling);
if (success !== continuePolling)
fulfill(success as T);
else
setTimeout(onTimeout, pollInterval);
} catch (e) {
reject(e);
}
};
onTimeout();
return result;
onTimeout();
return result;
});
}
private _runCancellablePoll<T>(poll: (progess: types.InjectedScriptProgress) => Promise<T>): types.InjectedScriptPoll<T> {
private _runAbortableTask<T>(task: (progess: types.InjectedScriptProgress) => Promise<T>): types.InjectedScriptPoll<T> {
let currentLogs: string[] = [];
let logReady = () => {};
const createLogsPromise = () => new Promise<types.InjectedScriptLogs>(fulfill => {
@ -164,7 +170,7 @@ export default class InjectedScript {
let lastLog = '';
const progress: types.InjectedScriptProgress = {
canceled: false,
aborted: false,
log: (message: string) => {
lastLog = message;
currentLogs.push(message);
@ -181,18 +187,12 @@ export default class InjectedScript {
return {
logs,
result: poll(progress),
cancel: () => { progress.canceled = true; },
result: task(progress),
cancel: () => { progress.aborted = true; },
takeLastLogs: () => currentLogs,
};
}
poll<T>(polling: 'raf' | number, predicate: Predicate<T>): types.InjectedScriptPoll<T> {
return this._runCancellablePoll(progress => {
return polling === 'raf' ? this._pollRaf(progress, predicate) : this._pollInterval(progress, polling, predicate);
});
}
getElementBorderWidth(node: Node): { left: number; top: number; } {
if (node.nodeType !== Node.ELEMENT_NODE || !node.ownerDocument || !node.ownerDocument.defaultView)
return { left: 0, top: 0 };
@ -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[] | 'notconnected'> {
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): string[] | 'error:notconnected' | FatalDOMError {
if (node.nodeName.toLowerCase() !== 'select')
return { error: 'Element is not a <select> element.' };
return 'error:notselect';
if (!node.isConnected)
return { value: 'notconnected' };
return 'error:notconnected';
const element = node as HTMLSelectElement;
const options = Array.from(element.options);
@ -228,93 +228,95 @@ export default class InjectedScript {
}
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return { value: options.filter(option => option.selected).map(option => option.value) };
return options.filter(option => option.selected).map(option => option.value);
}
waitForEnabledAndFill(node: Node, value: string): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'needsinput' | 'done'>> {
return this.poll('raf', progress => {
waitForEnabledAndFill(node: Node, value: string): types.InjectedScriptPoll<FatalDOMError | 'error:notconnected' | 'needsinput' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return { error: 'Node is not of type HTMLElement' };
return 'error:notelement';
const element = node as Element;
if (!element.isConnected)
return { value: 'notconnected' };
return 'error:notconnected';
if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return false;
return continuePolling;
}
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
const type = (input.getAttribute('type') || '').toLowerCase();
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 { error: 'Cannot fill input of type "' + type + '".' };
if (!kTextInputTypes.has(type) && !kDateTypes.has(type)) {
progress.log(` input of type "${type}" cannot be filled`);
return 'error:notfillableinputtype';
}
if (type === 'number') {
value = value.trim();
if (isNaN(Number(value)))
return { error: 'Cannot type text into input[type=number].' };
return 'error:notfillablenumberinput';
}
if (input.disabled) {
progress.logRepeating(' element is disabled - waiting...');
return false;
return continuePolling;
}
if (input.readOnly) {
progress.logRepeating(' element is readonly - waiting...');
return false;
return continuePolling;
}
if (kDateTypes.has(type)) {
value = value.trim();
input.focus();
input.value = value;
if (input.value !== value)
return { error: `Malformed ${type} "${value}"` };
return 'error:notvaliddate';
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return { value: 'done' }; // We have already changed the value, no need to input it.
return 'done'; // We have already changed the value, no need to input it.
}
} else if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement;
if (textarea.disabled) {
progress.logRepeating(' element is disabled - waiting...');
return false;
return continuePolling;
}
if (textarea.readOnly) {
progress.logRepeating(' element is readonly - waiting...');
return false;
return continuePolling;
}
} else if (!(element as HTMLElement).isContentEditable) {
return { error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
return 'error:notfillableelement';
}
const result = this._selectText(element);
if (result === 'notvisible') {
if (result === 'error:notvisible') {
progress.logRepeating(' element is not visible - waiting...');
return false;
return continuePolling;
}
return { value: 'needsinput' }; // Still need to input the value.
return 'needsinput'; // Still need to input the value.
});
}
waitForVisibleAndSelectText(node: Node): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'done'>> {
return this.poll('raf', progress => {
waitForVisibleAndSelectText(node: Node): types.InjectedScriptPoll<FatalDOMError | 'error:notconnected' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return { error: 'Node is not of type HTMLElement' };
return 'error:notelement';
if (!node.isConnected)
return { value: 'notconnected' };
return 'error:notconnected';
const element = node as Element;
if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return false;
return continuePolling;
}
const result = this._selectText(element);
if (result === 'notvisible') {
if (result === 'error:notvisible') {
progress.logRepeating(' element is not visible - waiting...');
return false;
return continuePolling;
}
return { value: result };
return result;
});
}
private _selectText(element: Element): 'notvisible' | 'done' {
private _selectText(element: Element): 'error:notvisible' | 'error:notconnected' | 'done' {
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
input.select();
@ -332,33 +334,33 @@ export default class InjectedScript {
range.selectNodeContents(element);
const selection = element.ownerDocument.defaultView!.getSelection();
if (!selection)
return 'notvisible';
return 'error:notvisible';
selection.removeAllRanges();
selection.addRange(range);
(element as HTMLElement | SVGElement).focus();
return 'done';
}
waitForNodeVisible(node: Node): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'done'>> {
return this.poll('raf', progress => {
waitForNodeVisible(node: Node): types.InjectedScriptPoll<'error:notconnected' | 'done'> {
return this.pollRaf((progress, continuePolling) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
if (!node.isConnected || !element)
return { value: 'notconnected' };
return 'error:notconnected';
if (!this.isVisible(element)) {
progress.logRepeating(' element is not visible - waiting...');
return false;
return continuePolling;
}
return { value: 'done' };
return 'done';
});
}
focusNode(node: Node): types.InjectedScriptResult<'notconnected' | 'done'> {
focusNode(node: Node): FatalDOMError | 'error:notconnected' | 'done' {
if (!node.isConnected)
return { value: 'notconnected' };
if (!(node as any)['focus'])
return { error: 'Node is not an HTML or SVG element.' };
return 'error:notconnected';
if (node.nodeType !== Node.ELEMENT_NODE)
return 'error:notelement';
(node as HTMLElement | SVGElement).focus();
return { value: 'done' };
return 'done';
}
isCheckboxChecked(node: Node) {
@ -407,75 +409,72 @@ export default class InjectedScript {
input.dispatchEvent(new Event('change', { 'bubbles': true }));
}
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.InjectedScriptPoll<types.InjectedScriptResult<'notconnected' | 'done'>> {
return this._runCancellablePoll(async progress => {
waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.InjectedScriptPoll<'error:notconnected' | 'done'> {
let lastRect: types.Rect | undefined;
let counter = 0;
let samePositionCounter = 0;
let lastTime = 0;
return this.pollRaf((progress, continuePolling) => {
// 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 continuePolling;
if (!node.isConnected)
return { value: 'notconnected' };
return 'error:notconnected';
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element)
return { value: 'notconnected' };
return 'error:notconnected';
let lastRect: types.Rect | undefined;
let counter = 0;
let samePositionCounter = 0;
let lastTime = 0;
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 { value: 'notconnected' };
// Drop frames that are shorter than 16ms - WebKit Win bug.
const time = performance.now();
if (rafCount > 1 && time - lastTime < 15)
return continuePolling;
lastTime = time;
// Drop frames that are shorter than 16ms - WebKit Win bug.
const time = performance.now();
if (rafCount > 1 && time - lastTime < 15)
return false;
lastTime = time;
// Note: this logic should be similar to isVisible() to avoid surprises.
const clientRect = element.getBoundingClientRect();
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
const samePosition = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height;
const isDisplayed = rect.width > 0 && rect.height > 0;
if (samePosition)
++samePositionCounter;
else
samePositionCounter = 0;
const isStable = samePositionCounter >= rafCount;
const isStableForLogs = isStable || !lastRect;
lastRect = rect;
// Note: this logic should be similar to isVisible() to avoid surprises.
const clientRect = element.getBoundingClientRect();
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
const samePosition = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height;
const isDisplayed = rect.width > 0 && rect.height > 0;
if (samePosition)
++samePositionCounter;
else
samePositionCounter = 0;
const isStable = samePositionCounter >= rafCount;
const isStableForLogs = isStable || !lastRect;
lastRect = rect;
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined;
const isVisible = !!style && style.visibility !== 'hidden';
const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined;
const isVisible = !!style && style.visibility !== 'hidden';
const elementOrButton = element.closest('button, [role=button]') || element;
const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
const elementOrButton = element.closest('button, [role=button]') || element;
const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled');
if (isDisplayed && isStable && isVisible && !isDisabled)
return 'done';
if (isDisplayed && isStable && isVisible && !isDisabled)
return { value: 'done' };
if (!isDisplayed || !isVisible)
progress.logRepeating(` element is not visible - waiting...`);
else if (!isStableForLogs)
progress.logRepeating(` element is moving - waiting...`);
else if (isDisabled)
progress.logRepeating(` element is disabled - waiting...`);
return false;
});
if (!isDisplayed || !isVisible)
progress.logRepeating(` element is not visible - waiting...`);
else if (!isStableForLogs)
progress.logRepeating(` element is moving - waiting...`);
else if (isDisabled)
progress.logRepeating(` element is disabled - waiting...`);
return continuePolling;
});
}
checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult<'notconnected' | 'nothittarget' | 'done'> {
checkHitTargetAt(node: Node, point: types.Point): 'error:notconnected' | 'error:nothittarget' | 'done' {
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element || !element.isConnected)
return { value: 'notconnected' };
return 'error: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 { value: hitElement === element ? 'done' : 'nothittarget' };
return hitElement === element ? 'done' : 'error:nothittarget';
}
dispatchEvent(node: Node, type: string, eventInit: Object) {

View File

@ -67,7 +67,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<'notvisible' | 'notconnected' | 'done'>;
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>;
rafCountForStablePosition(): number;
getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>;

View File

@ -18,7 +18,6 @@ import * as dom from './dom';
import * as frames from './frames';
import { helper, assert } from './helper';
import * as js from './javascript';
import * as types from './types';
import { ParsedSelector, parseSelector } from './common/selectorParser';
export class Selectors {
@ -110,14 +109,14 @@ export class Selectors {
return result;
}
_waitForSelectorTask(selector: string, state: 'attached' | 'detached' | 'visible' | 'hidden'): { world: 'main' | 'utility', task: frames.SchedulableTask<Element | boolean> } {
_waitForSelectorTask(selector: string, state: 'attached' | 'detached' | 'visible' | 'hidden'): { world: 'main' | 'utility', task: frames.SchedulableTask<Element | undefined> } {
const parsed = this._parseSelector(selector);
const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, state }) => {
let lastElement: Element | undefined;
return injected.poll('raf', (progress: types.InjectedScriptProgress) => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
const visible = element ? injected.isVisible(element) : false;
@ -131,13 +130,13 @@ export class Selectors {
switch (state) {
case 'attached':
return element || false;
return element ? element : continuePolling;
case 'detached':
return !element;
return !element ? undefined : continuePolling;
case 'visible':
return visible ? element : false;
return visible ? element : continuePolling;
case 'hidden':
return !visible;
return !visible ? undefined : continuePolling;
}
});
}, { parsed, state });
@ -145,16 +144,16 @@ export class Selectors {
return { world: this._needsMainContext(parsed) ? 'main' : 'utility', task };
}
_dispatchEventTask(selector: string, type: string, eventInit: Object): frames.SchedulableTask<Element> {
_dispatchEventTask(selector: string, type: string, eventInit: Object): frames.SchedulableTask<undefined> {
const parsed = this._parseSelector(selector);
const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, type, eventInit }) => {
return injected.poll('raf', () => {
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
if (element)
injected.dispatchEvent(element, type, eventInit);
return element || false;
return element ? undefined : continuePolling;
});
}, { parsed, type, eventInit });
};

View File

@ -137,10 +137,8 @@ export type JSCoverageOptions = {
reportAnonymousScripts?: boolean,
};
export type InjectedScriptResult<T> = { error?: string, value?: T };
export type InjectedScriptProgress = {
canceled: boolean,
aborted: boolean,
log: (message: string) => void,
logRepeating: (message: string) => void,
};

View File

@ -750,15 +750,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<'notvisible' | 'notconnected' | 'done'> {
async scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'> {
return await this._session.send('DOM.scrollIntoViewIfNeeded', {
objectId: handle._objectId,
rect,
}).then(() => 'done' as const).catch(e => {
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
return 'notvisible';
return 'error:notvisible';
if (e instanceof Error && e.message.includes('Node is detached from document'))
return 'notconnected';
return 'error:notconnected';
throw e;
});
}

View File

@ -1049,7 +1049,7 @@ describe('Page.fill', function() {
await page.$eval('input', (input, type) => input.setAttribute('type', type), type);
let error = null;
await page.fill('input', '').catch(e => error = e);
expect(error.message).toContain('Cannot fill input of type');
expect(error.message).toContain(`input of type "${type}" cannot be filled`);
}
});
it('should fill different input types', async({page, server}) => {
@ -1069,7 +1069,7 @@ describe('Page.fill', function() {
it.skip(WEBKIT)('should throw on incorrect date', async({page, server}) => {
await page.setContent('<input type=date>');
const error = await page.fill('input', '2020-13-05').catch(e => e);
expect(error.message).toContain('Malformed date "2020-13-05"');
expect(error.message).toContain('Malformed value');
});
it('should fill time input', async({page, server}) => {
await page.setContent('<input type=time>');
@ -1079,7 +1079,7 @@ describe('Page.fill', function() {
it.skip(WEBKIT)('should throw on incorrect time', async({page, server}) => {
await page.setContent('<input type=time>');
const error = await page.fill('input', '25:05').catch(e => e);
expect(error.message).toContain('Malformed time "25:05"');
expect(error.message).toContain('Malformed value');
});
it('should fill datetime-local input', async({page, server}) => {
await page.setContent('<input type=datetime-local>');
@ -1089,7 +1089,7 @@ describe('Page.fill', function() {
it.skip(WEBKIT || FFOX)('should throw on incorrect datetime-local', async({page, server}) => {
await page.setContent('<input type=datetime-local>');
const error = await page.fill('input', 'abc').catch(e => e);
expect(error.message).toContain('Malformed datetime-local "abc"');
expect(error.message).toContain('Malformed value');
});
it('should fill contenteditable', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html');
@ -1213,7 +1213,7 @@ describe('Page.fill', function() {
await page.setContent(`<input id="input" type="number"></input>`);
let error = null;
await page.fill('input', 'abc').catch(e => error = e);
expect(error.message).toContain('Cannot type text into input[type=number].');
expect(error.message).toContain('Cannot type text into input[type=number]');
});
it('should be able to clear', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html');