feat(actions): requery the element when it was detached during the action (#1853)

This commit is contained in:
Dmitry Gozman 2020-04-18 18:29:31 -07:00 committed by GitHub
parent e466508ab1
commit 55b4bc99bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 241 additions and 149 deletions

View File

@ -36,6 +36,7 @@ import { CRPDF } from './crPdf';
import { CRBrowserContext } from './crBrowser';
import * as types from '../types';
import { ConsoleMessage } from '../console';
import { NotConnectedError } from '../errors';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
@ -765,6 +766,8 @@ class FrameSession {
objectId: toRemoteObject(handle).objectId,
rect,
}).catch(e => {
if (e instanceof Error && e.message.includes('Node is detached from document'))
throw new NotConnectedError();
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
e.message = 'Node is either not visible or not an HTMLElement';
throw e;

View File

@ -14,19 +14,19 @@
* limitations under the License.
*/
import * as debug from 'debug';
import * as fs from 'fs';
import * as mime from 'mime';
import * as path from 'path';
import * as util from 'util';
import * as frames from './frames';
import { assert, debugError, helper } from './helper';
import Injected from './injected/injected';
import { assert, debugError, helper, debugInput } from './helper';
import { Injected, InjectedResult } from './injected/injected';
import * as input from './input';
import * as js from './javascript';
import { Page } from './page';
import { selectors } from './selectors';
import * as types from './types';
import { NotConnectedError, TimeoutError } from './errors';
export type PointerActionOptions = {
modifiers?: input.Modifier[];
@ -37,8 +37,6 @@ export type ClickOptions = PointerActionOptions & input.MouseClickOptions;
export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOptions;
const debugInput = debug('pw:input');
export class FrameExecutionContext extends js.ExecutionContext {
readonly frame: frames.Frame;
private _injectedPromise?: Promise<js.JSHandle>;
@ -220,10 +218,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const position = options ? options.position : undefined;
await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
const point = position ? await this._offsetPoint(position) : await this._clickablePoint();
point.x = (point.x * 100 | 0) / 100;
point.y = (point.y * 100 | 0) / 100;
if (!force)
await this._waitForHitTargetAt(point, deadline);
@ -270,7 +266,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"');
}
return await this._page._frameManager.waitForSignalsCreatedBy<string[]>(async () => {
return this._evaluateInUtility(({ injected, node }, selectOptions) => injected.selectOptions(node, selectOptions), selectOptions);
const injectedResult = await this._evaluateInUtility(({ injected, node }, selectOptions) => injected.selectOptions(node, selectOptions), selectOptions);
return handleInjectedResult(injectedResult, '');
}, deadline, options);
}
@ -278,10 +275,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
const deadline = this._page._timeoutSettings.computeDeadline(options);
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
const errorOrNeedsInput = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value);
if (typeof errorOrNeedsInput === 'string')
throw new Error(errorOrNeedsInput);
if (errorOrNeedsInput) {
const injectedResult = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value);
const needsInput = handleInjectedResult(injectedResult, '');
if (needsInput) {
if (value)
await this._page.keyboard.insertText(value);
else
@ -291,19 +287,21 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async selectText(): Promise<void> {
const error = await this._evaluateInUtility(({ injected, node }) => injected.selectText(node), {});
if (typeof error === 'string')
throw new Error(error);
const injectedResult = await this._evaluateInUtility(({ injected, node }) => injected.selectText(node), {});
handleInjectedResult(injectedResult, '');
}
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options?: types.NavigatingActionWaitOptions) {
const deadline = this._page._timeoutSettings.computeDeadline(options);
const multiple = await this._evaluateInUtility(({ node }) => {
const injectedResult = await this._evaluateInUtility(({ node }): InjectedResult<boolean> => {
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
throw new Error('Node is not an HTMLInputElement');
return { status: 'error', error: 'Node is not an HTMLInputElement' };
if (!node.isConnected)
return { status: 'notconnected' };
const input = node as Node as HTMLInputElement;
return input.multiple;
return { status: 'success', value: input.multiple };
}, {});
const multiple = handleInjectedResult(injectedResult, '');
let ff: string[] | types.FilePayload[];
if (!Array.isArray(files))
ff = [ files ] as string[] | types.FilePayload[];
@ -329,14 +327,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
}
async focus() {
const errorMessage = await this._evaluateInUtility(({ node }) => {
if (!(node as any)['focus'])
return 'Node is not an HTML or SVG element.';
(node as Node as HTMLElement | SVGElement).focus();
return false;
}, {});
if (errorMessage)
throw new Error(errorMessage);
const injectedResult = await this._evaluateInUtility(({ injected, node }) => injected.focusNode(node), {});
handleInjectedResult(injectedResult, '');
}
async type(text: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
@ -416,7 +408,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const stablePromise = this._evaluateInUtility(({ injected, node }, timeout) => {
return injected.waitForDisplayedAtStablePosition(node, timeout);
}, helper.timeUntilDeadline(deadline));
await helper.waitWithDeadline(stablePromise, 'element to be displayed and not moving', deadline);
const timeoutMessage = 'element to be displayed and not moving';
const injectedResult = await helper.waitWithDeadline(stablePromise, timeoutMessage, deadline);
handleInjectedResult(injectedResult, timeoutMessage);
debugInput('...done');
}
@ -434,7 +428,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
const hitTargetPromise = this._evaluateInUtility(({ injected, node }, { timeout, point }) => {
return injected.waitForHitTargetAt(node, timeout, point);
}, { timeout: helper.timeUntilDeadline(deadline), point });
await helper.waitWithDeadline(hitTargetPromise, 'element to receive pointer events', deadline);
const timeoutMessage = 'element to receive pointer events';
const injectedResult = await helper.waitWithDeadline(hitTargetPromise, timeoutMessage, deadline);
handleInjectedResult(injectedResult, timeoutMessage);
debugInput('...done');
}
}
@ -446,3 +442,13 @@ export function toFileTransferPayload(files: types.FilePayload[]): types.FileTra
data: file.buffer.toString('base64')
}));
}
function handleInjectedResult<T = undefined>(injectedResult: InjectedResult<T>, timeoutMessage: string): T {
if (injectedResult.status === 'notconnected')
throw new NotConnectedError();
if (injectedResult.status === 'timeout')
throw new TimeoutError(`waiting for ${timeoutMessage} failed: timeout exceeded`);
if (injectedResult.status === 'error')
throw new Error(injectedResult.error);
return injectedResult.value as T;
}

View File

@ -23,4 +23,10 @@ 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,6 +31,7 @@ import { RawKeyboardImpl, RawMouseImpl } from './ffInput';
import { FFNetworkManager, headersArray } from './ffNetworkManager';
import { Protocol } from './protocol';
import { selectors } from '../selectors';
import { NotConnectedError } from '../errors';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
@ -422,6 +423,10 @@ export class FFPage implements PageDelegate {
frameId: handle._context.frame._id,
objectId: toRemoteObject(handle).objectId!,
rect,
}).catch(e => {
if (e instanceof Error && e.message.includes('Node is detached from document'))
throw new NotConnectedError();
throw e;
});
}

View File

@ -19,9 +19,9 @@ import * as fs from 'fs';
import * as util from 'util';
import { ConsoleMessage } from './console';
import * as dom from './dom';
import { TimeoutError } from './errors';
import { TimeoutError, NotConnectedError } from './errors';
import { Events } from './events';
import { assert, helper, RegisteredListener } from './helper';
import { assert, helper, RegisteredListener, debugInput } from './helper';
import * as js from './javascript';
import * as network from './network';
import { Page } from './page';
@ -693,72 +693,82 @@ export class Frame {
return result!;
}
private async _retryWithSelectorIfNotConnected<R>(
selector: string, options: types.TimeoutOptions,
action: (handle: dom.ElementHandle<Element>, deadline: number) => Promise<R>): Promise<R> {
const deadline = this._page._timeoutSettings.computeDeadline(options);
while (!helper.isPastDeadline(deadline)) {
try {
const { world, task } = selectors._waitForSelectorTask(selector, 'attached', deadline);
const handle = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selector}"`);
const element = handle.asElement() as dom.ElementHandle<Element>;
try {
return await action(element, deadline);
} finally {
element.dispose();
}
} catch (e) {
if (!(e instanceof NotConnectedError))
throw e;
debugInput('Element was detached from the DOM, retrying');
}
}
throw new TimeoutError(`waiting for selector "${selector}" failed: timeout exceeded`);
}
async click(selector: string, options: dom.ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.click(helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.click(helper.optionsWithUpdatedTimeout(options, deadline)));
}
async dblclick(selector: string, options: dom.MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.dblclick(helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.dblclick(helper.optionsWithUpdatedTimeout(options, deadline)));
}
async fill(selector: string, value: string, options: types.NavigatingActionWaitOptions = {}) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.fill(value, helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.fill(value, helper.optionsWithUpdatedTimeout(options, deadline)));
}
async focus(selector: string, options?: types.TimeoutOptions) {
const { handle } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.focus();
handle.dispose();
async focus(selector: string, options: types.TimeoutOptions = {}) {
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.focus());
}
async hover(selector: string, options?: dom.PointerActionOptions & types.PointerActionWaitOptions) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.hover(helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
async hover(selector: string, options: dom.PointerActionOptions & types.PointerActionWaitOptions = {}) {
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.hover(helper.optionsWithUpdatedTimeout(options, deadline)));
}
async selectOption(selector: string, values: string | dom.ElementHandle | types.SelectOption | string[] | dom.ElementHandle[] | types.SelectOption[], options?: types.NavigatingActionWaitOptions): Promise<string[]> {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
const result = await handle.selectOption(values, helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
return result;
async selectOption(selector: string, values: string | dom.ElementHandle | types.SelectOption | string[] | dom.ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions = {}): Promise<string[]> {
return await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.selectOption(values, helper.optionsWithUpdatedTimeout(options, deadline)));
}
async setInputFiles(selector: string, files: string | types.FilePayload | string[] | types.FilePayload[], options?: types.NavigatingActionWaitOptions): Promise<void> {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
const result = await handle.setInputFiles(files, helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
return result;
async setInputFiles(selector: string, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}): Promise<void> {
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.setInputFiles(files, helper.optionsWithUpdatedTimeout(options, deadline)));
}
async type(selector: string, text: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.type(text, helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
async type(selector: string, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.type(text, helper.optionsWithUpdatedTimeout(options, deadline)));
}
async press(selector: string, key: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.press(key, helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
async press(selector: string, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.press(key, helper.optionsWithUpdatedTimeout(options, deadline)));
}
async check(selector: string, options?: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.check(helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
async check(selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.check(helper.optionsWithUpdatedTimeout(options, deadline)));
}
async uncheck(selector: string, options?: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
await handle.uncheck(helper.optionsWithUpdatedTimeout(options, deadline));
handle.dispose();
async uncheck(selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
await this._retryWithSelectorIfNotConnected(selector, options,
(handle, deadline) => handle.uncheck(helper.optionsWithUpdatedTimeout(options, deadline)));
}
async waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: types.WaitForFunctionOptions & types.WaitForElementOptions = {}, arg?: any): Promise<js.JSHandle | null> {
@ -773,14 +783,6 @@ export class Frame {
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout)));
}
private async _waitForSelectorInUtilityContext(selector: string, options?: types.WaitForElementOptions): Promise<{ handle: dom.ElementHandle<Element>, deadline: number }> {
const { waitFor = 'attached' } = options || {};
const deadline = this._page._timeoutSettings.computeDeadline(options);
const { world, task } = selectors._waitForSelectorTask(selector, waitFor, deadline);
const result = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selectorToString(selector, waitFor)}"`);
return { handle: result.asElement() as dom.ElementHandle<Element>, deadline };
}
async waitForFunction<R, Arg>(pageFunction: types.Func1<Arg, R>, arg: Arg, options?: types.WaitForFunctionOptions): Promise<types.SmartHandle<R>>;
async waitForFunction<R>(pageFunction: types.Func1<void, R>, arg?: any, options?: types.WaitForFunctionOptions): Promise<types.SmartHandle<R>>;
async waitForFunction<R, Arg>(pageFunction: types.Func1<Arg, R>, arg: Arg, options: types.WaitForFunctionOptions = {}): Promise<types.SmartHandle<R>> {

View File

@ -24,6 +24,7 @@ import { TimeoutError } from './errors';
import * as types from './types';
export const debugError = debug(`pw:error`);
export const debugInput = debug('pw:input');
export type RegisteredListener = {
emitter: EventEmitter;
@ -346,6 +347,10 @@ class Helper {
return seconds * 1000 + (nanoseconds / 1000000 | 0);
}
static isPastDeadline(deadline: number) {
return deadline !== Number.MAX_SAFE_INTEGER && this.monotonicTime() >= deadline;
}
static timeUntilDeadline(deadline: number): number {
return Math.min(deadline - this.monotonicTime(), 2147483647); // 2^31-1 safe setTimeout in Node.
}

View File

@ -16,9 +16,14 @@
import * as types from '../types';
type Predicate = () => any;
type Predicate<T> = () => T;
export type InjectedResult<T = undefined> =
(T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) |
{ status: 'notconnected' } |
{ status: 'timeout' } |
{ status: 'error', error: string };
class Injected {
export class Injected {
isVisible(element: Element): boolean {
if (!element.ownerDocument || !element.ownerDocument.defaultView)
return true;
@ -29,7 +34,7 @@ class Injected {
return !!(rect.top || rect.bottom || rect.width || rect.height);
}
private _pollMutation(predicate: Predicate, timeout: number): Promise<any> {
private _pollMutation<T>(predicate: Predicate<T>, timeout: number): Promise<T | undefined> {
let timedOut = false;
if (timeout)
setTimeout(() => timedOut = true, timeout);
@ -39,7 +44,7 @@ class Injected {
return Promise.resolve(success);
let fulfill: (result?: any) => void;
const result = new Promise(x => fulfill = x);
const result = new Promise<T | undefined>(x => fulfill = x);
const observer = new MutationObserver(() => {
if (timedOut) {
observer.disconnect();
@ -60,13 +65,13 @@ class Injected {
return result;
}
private _pollRaf(predicate: Predicate, timeout: number): Promise<any> {
private _pollRaf<T>(predicate: Predicate<T>, timeout: number): Promise<T | undefined> {
let timedOut = false;
if (timeout)
setTimeout(() => timedOut = true, timeout);
let fulfill: (result?: any) => void;
const result = new Promise(x => fulfill = x);
const result = new Promise<T | undefined>(x => fulfill = x);
const onRaf = () => {
if (timedOut) {
@ -84,13 +89,13 @@ class Injected {
return result;
}
private _pollInterval(pollInterval: number, predicate: Predicate, timeout: number): Promise<any> {
private _pollInterval<T>(pollInterval: number, predicate: Predicate<T>, timeout: number): Promise<T | undefined> {
let timedOut = false;
if (timeout)
setTimeout(() => timedOut = true, timeout);
let fulfill: (result?: any) => void;
const result = new Promise(x => fulfill = x);
const result = new Promise<T | undefined>(x => fulfill = x);
const onTimeout = () => {
if (timedOut) {
fulfill();
@ -107,7 +112,7 @@ class Injected {
return result;
}
poll(polling: 'raf' | 'mutation' | number, timeout: number, predicate: Predicate): Promise<any> {
poll<T>(polling: 'raf' | 'mutation' | number, timeout: number, predicate: Predicate<T>): Promise<T | undefined> {
if (polling === 'raf')
return this._pollRaf(predicate, timeout);
if (polling === 'mutation')
@ -122,9 +127,11 @@ class Injected {
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
}
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]) {
selectOptions(node: Node, optionsToSelect: (Node | types.SelectOption)[]): InjectedResult<string[]> {
if (node.nodeName.toLowerCase() !== 'select')
throw new Error('Element is not a <select> element.');
return { status: 'error', error: 'Element is not a <select> element.' };
if (!node.isConnected)
return { status: 'notconnected' };
const element = node as HTMLSelectElement;
const options = Array.from(element.options);
@ -148,81 +155,97 @@ class Injected {
}
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return options.filter(option => option.selected).map(option => option.value);
return { status: 'success', value: options.filter(option => option.selected).map(option => option.value) };
}
fill(node: Node, value: string) {
fill(node: Node, value: string): InjectedResult<boolean> {
if (node.nodeType !== Node.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
return { status: 'error', error: 'Node is not of type HTMLElement' };
const element = node as HTMLElement;
if (!element.isConnected)
return { status: 'notconnected' };
if (!this.isVisible(element))
return 'Element is not visible';
return { status: 'error', error: 'Element is not visible' };
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 'Cannot fill input of type "' + type + '".';
return { status: 'error', error: 'Cannot fill input of type "' + type + '".' };
if (type === 'number') {
value = value.trim();
if (!value || isNaN(Number(value)))
return 'Cannot type text into input[type=number].';
return { status: 'error', error: 'Cannot type text into input[type=number].' };
}
if (input.disabled)
return 'Cannot fill a disabled input.';
return { status: 'error', error: 'Cannot fill a disabled input.' };
if (input.readOnly)
return 'Cannot fill a readonly input.';
return { status: 'error', error: 'Cannot fill a readonly input.' };
if (kDateTypes.has(type)) {
value = value.trim();
input.focus();
input.value = value;
if (input.value !== value)
return `Malformed ${type} "${value}"`;
return { status: 'error', error: `Malformed ${type} "${value}"` };
element.dispatchEvent(new Event('input', { 'bubbles': true }));
element.dispatchEvent(new Event('change', { 'bubbles': true }));
return false; // We have already changed the value, no need to input it.
return { status: 'success', value: false }; // 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)
return 'Cannot fill a disabled textarea.';
return { status: 'error', error: 'Cannot fill a disabled textarea.' };
if (textarea.readOnly)
return 'Cannot fill a readonly textarea.';
return { status: 'error', error: 'Cannot fill a readonly textarea.' };
} else if (!element.isContentEditable) {
return 'Element is not an <input>, <textarea> or [contenteditable] element.';
return { status: 'error', error: 'Element is not an <input>, <textarea> or [contenteditable] element.' };
}
return this.selectText(node);
const result = this.selectText(node);
if (result.status === 'success')
return { status: 'success', value: true }; // Still need to input the value.
return result;
}
selectText(node: Node) {
selectText(node: Node): InjectedResult {
if (node.nodeType !== Node.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
return { status: 'error', error: 'Node is not of type HTMLElement' };
if (!node.isConnected)
return { status: 'notconnected' };
const element = node as HTMLElement;
if (!this.isVisible(element))
return 'Element is not visible';
return { status: 'error', error: 'Element is not visible' };
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
input.select();
input.focus();
return true;
return { status: 'success' };
}
if (element.nodeName.toLowerCase() === 'textarea') {
const textarea = element as HTMLTextAreaElement;
textarea.selectionStart = 0;
textarea.selectionEnd = textarea.value.length;
textarea.focus();
return true;
return { status: 'success' };
}
const range = element.ownerDocument!.createRange();
range.selectNodeContents(element);
const selection = element.ownerDocument!.defaultView!.getSelection();
if (!selection)
return 'Element belongs to invisible iframe.';
return { status: 'error', error: 'Element belongs to invisible iframe.' };
selection.removeAllRanges();
selection.addRange(range);
element.focus();
return true;
return { status: 'success' };
}
focusNode(node: Node): InjectedResult {
if (!node.isConnected)
return { status: 'notconnected' };
if (!(node as any)['focus'])
return { status: 'error', error: 'Node is not an HTML or SVG element.' };
(node as HTMLElement | SVGElement).focus();
return { status: 'success' };
}
isCheckboxChecked(node: Node) {
@ -271,53 +294,47 @@ class Injected {
input.dispatchEvent(new Event('change', { 'bubbles': true }));
}
async waitForDisplayedAtStablePosition(node: Node, timeout: number) {
async waitForDisplayedAtStablePosition(node: Node, timeout: number): Promise<InjectedResult> {
if (!node.isConnected)
throw new Error('Element is not attached to the DOM');
return { status: 'notconnected' };
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
if (!element)
throw new Error('Element is not attached to the DOM');
return { status: 'notconnected' };
let lastRect: types.Rect | undefined;
let counter = 0;
const result = await this.poll('raf', timeout, () => {
const result = await this.poll('raf', timeout, (): 'notconnected' | boolean => {
// 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 'Element is not attached to the DOM';
return 'notconnected';
const clientRect = element.getBoundingClientRect();
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
const isDisplayedAndStable = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height && rect.width > 0 && rect.height > 0;
lastRect = rect;
return isDisplayedAndStable;
return !!isDisplayedAndStable;
});
if (typeof result === 'string')
throw new Error(result);
if (!result)
throw new Error(`waiting for element to be displayed and not moving failed: timeout exceeded`);
return { status: result === 'notconnected' ? 'notconnected' : (result ? 'success' : 'timeout') };
}
async waitForHitTargetAt(node: Node, timeout: number, point: types.Point) {
async waitForHitTargetAt(node: Node, timeout: number, point: types.Point): Promise<InjectedResult> {
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
while (element && window.getComputedStyle(element).pointerEvents === 'none')
element = element.parentElement;
if (!element)
throw new Error('Element is not attached to the DOM');
const result = await this.poll('raf', timeout, () => {
return { status: 'notconnected' };
const result = await this.poll('raf', timeout, (): 'notconnected' | boolean => {
if (!element!.isConnected)
return 'Element is not attached to the DOM';
return 'notconnected';
let hitElement = this._deepElementFromPoint(document, point.x, point.y);
while (hitElement && hitElement !== element)
hitElement = this._parentElementOrShadowHost(hitElement);
return hitElement === element;
});
if (typeof result === 'string')
throw new Error(result);
if (!result)
throw new Error(`waiting for element to receive mouse events failed: timeout exceeded`);
return { status: result === 'notconnected' ? 'notconnected' : (result ? 'success' : 'timeout') };
}
private _parentElementOrShadowHost(element: Element): Element | undefined {
@ -342,5 +359,3 @@ class Injected {
return element;
}
}
export default Injected;

View File

@ -17,7 +17,7 @@
import * as types from '../types';
import { createAttributeEngine } from './attributeSelectorEngine';
import { createCSSEngine } from './cssSelectorEngine';
import Injected from './injected';
import { Injected } from './injected';
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { createTextSelector } from './textSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine';

View File

@ -36,6 +36,7 @@ import { WKBrowserContext } from './wkBrowser';
import { selectors } from '../selectors';
import * as jpeg from 'jpeg-js';
import * as png from 'pngjs';
import { NotConnectedError } from '../errors';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
const BINDING_CALL_MESSAGE = '__playwright_binding_call__';
@ -713,6 +714,8 @@ export class WKPage implements PageDelegate {
objectId: toRemoteObject(handle).objectId!,
rect,
}).catch(e => {
if (e instanceof Error && e.message.includes('Node is detached from document'))
throw new NotConnectedError();
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
e.message = 'Node is either not visible or not an HTMLElement';
throw e;

View File

@ -0,0 +1,24 @@
<style>
body, html { margin: 0; padding: 0; }
@keyframes move {
from { marign-left: 0; }
to { margin-left: 100px; }
}
</style>
<script>
function addButton() {
const button = document.createElement('button');
button.textContent = 'Click me';
button.style.animation = '3s linear move';
button.style.animationIterationCount = 'infinite';
button.addEventListener('click', () => window.clicked = true);
document.body.appendChild(button);
}
function stopButton(remove) {
const button = document.querySelector('button');
button.style.marginLeft = button.getBoundingClientRect().left + 'px';
button.style.animation = '';
if (remove)
button.remove();
}
</script>

View File

@ -554,24 +554,47 @@ describe('Page.click', function() {
if (error2)
expect(error2.message).toContain('timeout exceeded');
});
it('should fail when element detaches after animation', async({page, server}) => {
await page.setContent(`<style>body, html { margin: 0; padding: 0; }</style><button onclick="window.clicked=true">Click me</button>`);
await page.$eval('button', button => {
button.style.transition = 'margin-left 100000ms linear';
});
await page.$eval('button', button => {
button.style.marginLeft = '100000px';
});
it('should report nice error when element is detached and force-clicked', async({page, server}) => {
await page.goto(server.PREFIX + '/input/animating-button.html');
await page.evaluate(() => addButton());
const handle = await page.$('button');
const promise = handle.click().catch(e => e);
await page.$eval('button', button => {
button.style.marginLeft = button.getBoundingClientRect().left + 'px';
button.style.transition = '';
button.remove();
});
await page.evaluate(() => stopButton(true));
const promise = handle.click({ force: true }).catch(e => e);
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');
await page.evaluate(() => addButton());
const handle = await page.$('button');
const promise = handle.click().catch(e => e);
await page.evaluate(() => stopButton(true));
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');
await page.evaluate(() => addButton());
let clicked = false;
const promise = page.click('button').then(() => clicked = true);
expect(clicked).toBe(false);
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
await page.evaluate(() => stopButton(true));
await page.evaluate(() => addButton());
expect(clicked).toBe(false);
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
await page.evaluate(() => stopButton(true));
await page.evaluate(() => addButton());
expect(clicked).toBe(false);
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
await page.evaluate(() => stopButton(false));
await promise;
expect(clicked).toBe(true);
expect(await page.evaluate(() => window.clicked)).toBe(true);
});
});

View File

@ -364,7 +364,7 @@ describe('ElementHandle.screenshot', function() {
const elementHandle = await page.$('h1');
await page.evaluate(element => element.remove(), elementHandle);
const screenshotError = await elementHandle.screenshot().catch(error => error);
expect(screenshotError.message).toContain('Node is detached');
expect(screenshotError.message).toContain('Element is not attached to the DOM');
});
it('should not hang with zero width/height element', async({page, server}) => {
await page.setContent('<div style="width: 50px; height: 0"></div>');