mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(actions): requery the element when it was detached during the action (#1853)
This commit is contained in:
parent
e466508ab1
commit
55b4bc99bd
@ -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;
|
||||
|
62
src/dom.ts
62
src/dom.ts
@ -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;
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
108
src/frames.ts
108
src/frames.ts
@ -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>> {
|
||||
|
@ -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.
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
24
test/assets/input/animating-button.html
Normal file
24
test/assets/input/animating-button.html
Normal 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>
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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>');
|
||||
|
Loading…
x
Reference in New Issue
Block a user