From 27656a1b1174105ce032a547c455ca472ac1ce0c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 11 Feb 2020 14:40:14 -0800 Subject: [PATCH] feat(click): waitForInteractable option, defaults to true --- docs/api.md | 40 +++++++++-- src/dom.ts | 123 +++++++++++++++++++++++----------- src/frames.ts | 16 ++--- src/injected/injected.ts | 14 +++- src/page.ts | 12 ++-- test/assets/input/button.html | 2 + test/click.spec.js | 90 +++++++++++++++++++++++++ test/elementhandle.spec.js | 2 +- 8 files changed, 236 insertions(+), 63 deletions(-) diff --git a/docs/api.md b/docs/api.md index c7ab0a6b7ec58..c18cf3f4647de 100644 --- a/docs/api.md +++ b/docs/api.md @@ -760,6 +760,7 @@ To disable authentication, pass `null`. - `selector` <[string]> A selector to search for checkbox or radio button to check. If there are multiple elements satisfying the selector, the first will be checked. - `options` <[Object]> - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 checked. The Promise will be rejected if there is no element matching `selector`. @@ -779,6 +780,7 @@ Shortcut for [page.mainFrame().check(selector[, options])](#framecheckselector-o - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 clicked. The Promise will be rejected if there is no element matching `selector`. @@ -835,6 +837,7 @@ Get the browser context that the page belongs to. - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the double click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 double clicked. The Promise will be rejected if there is no element matching `selector`. @@ -1119,6 +1122,7 @@ Shortcut for [page.mainFrame().goto(url, options)](#framegotourl-options) - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the hover, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 hovered. Promise gets rejected if there's no element matching `selector`. @@ -1389,6 +1393,7 @@ Shortcut for [page.mainFrame().title()](#frametitle). - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the triple click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 triple clicked. The Promise will be rejected if there is no element matching `selector`. @@ -1425,8 +1430,9 @@ Shortcut for [page.mainFrame().type(selector, text[, options])](#frametypeselect - `selector` <[string]> A selector to search for uncheckbox to check. If there are multiple elements satisfying the selector, the first will be checked. - `options` <[Object]> - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 checked. The Promise will be rejected if there is no element matching `selector`. +- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully unchecked. The Promise will be rejected if there is no element matching `selector`. This method fetches an element with `selector`, if element is not already unchecked, it scrolls it into view if needed, and then uses [page.click](#pageclickselector-options) to click in the center of the element. If there's no element matching `selector`, the method throws an error. @@ -1795,6 +1801,7 @@ Adds a `` tag into the page with the desired url or a ` A selector to search for checkbox to check. If there are multiple elements satisfying the selector, the first will be checked. - `options` <[Object]> - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 checked. The Promise will be rejected if there is no element matching `selector`. @@ -1815,6 +1822,7 @@ If there's no element matching `selector`, the method throws an error. - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 clicked. The Promise will be rejected if there is no element matching `selector`. @@ -1845,6 +1853,7 @@ Gets the full HTML contents of the frame, including the doctype. - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the double click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 double clicked. The Promise will be rejected if there is no element matching `selector`. @@ -1980,6 +1989,7 @@ console.log(frame === contentFrame); // -> true - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the hover, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 hovered. Promise gets rejected if there's no element matching `selector`. @@ -2055,6 +2065,7 @@ frame.select('select#colors', { value: 'blue' }, { index: 2 }, 'red'); - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the triple click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 triple clicked. The Promise will be rejected if there is no element matching `selector`. @@ -2087,8 +2098,9 @@ await frame.type('#mytextarea', 'World', {delay: 100}); // Types slower, like a - `selector` <[string]> A selector to search for uncheckbox to check. If there are multiple elements satisfying the selector, the first will be checked. - `options` <[Object]> - `waitFor` <"visible"|"hidden"|"any"|"nowait"> Wait for element to become visible (`visible`), hidden (`hidden`), present in dom (`any`) or do not wait at all (`nowait`). Defaults to `visible`. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. - `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 checked. The Promise will be rejected if there is no element matching `selector`. +- returns: <[Promise]> Promise which resolves when the element matching `selector` is successfully unchecked. The Promise will be rejected if there is no element matching `selector`. This method fetches an element with `selector`, if element is not already unchecked, it scrolls it into view if needed, and then uses [frame.click](#frameclickselector-options) to click in the center of the element. If there's no element matching `selector`, the method throws an error. @@ -2263,7 +2275,7 @@ ElementHandle instances can be used as arguments in [`page.$eval()`](#pageevalse - [elementHandle.$$eval(selector, pageFunction[, ...args])](#elementhandleevalselector-pagefunction-args) - [elementHandle.$eval(selector, pageFunction[, ...args])](#elementhandleevalselector-pagefunction-args-1) - [elementHandle.boundingBox()](#elementhandleboundingbox) -- [elementHandle.check()](#elementhandlecheck) +- [elementHandle.check([options])](#elementhandlecheckoptions) - [elementHandle.click([options])](#elementhandleclickoptions) - [elementHandle.contentFrame()](#elementhandlecontentframe) - [elementHandle.dblclick([options])](#elementhandledblclickoptions) @@ -2279,7 +2291,7 @@ ElementHandle instances can be used as arguments in [`page.$eval()`](#pageevalse - [elementHandle.toString()](#elementhandletostring) - [elementHandle.tripleclick([options])](#elementhandletripleclickoptions) - [elementHandle.type(text[, options])](#elementhandletypetext-options) -- [elementHandle.uncheck()](#elementhandleuncheck) +- [elementHandle.uncheck([options])](#elementhandleuncheckoptions) - [elementHandle.visibleRatio()](#elementhandlevisibleratio) @@ -2352,7 +2364,10 @@ expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe('10'); This method returns the bounding box of the element (relative to the main frame), or `null` if the element is not visible. -#### elementHandle.check() +#### elementHandle.check([options]) +- `options` <[Object]> + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. + - `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 checked. Promise gets rejected if the operation fails. If element is not already checked, it scrolls it into view if needed, and then uses [elementHandle.click](#elementhandleclickoptions) to click in the center of the element. @@ -2366,6 +2381,8 @@ If element is not already checked, it scrolls it into view if needed, and then u - x <[number]> - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. + - `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 clicked. Promise gets rejected if the element is detached from DOM. This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to click in the center of the element. @@ -2382,6 +2399,8 @@ If the element is detached from DOM, the method throws an error. - x <[number]> - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the double click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. + - `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 double clicked. Promise gets rejected if the element is detached from DOM. This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to click in the center of the element. @@ -2409,6 +2428,8 @@ Calls [focus](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus - x <[number]> - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the hover, and then restores current modifiers back. If not specified, currently pressed modifiers are used. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. + - `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 hovered. This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to hover over the center of the element. @@ -2494,6 +2515,8 @@ This method expects `elementHandle` to point to an [input element](https://devel - x <[number]> - y <[number]> - `modifiers` <[Array]<"Alt"|"Control"|"Meta"|"Shift">> Modifier keys to press. Ensures that only these modifiers are pressed during the triple click, and then restores current modifiers back. If not specified, currently pressed modifiers are used. + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. + - `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 triple clicked. Promise gets rejected if the element is detached from DOM. This method scrolls element into view if needed, and then uses [page.mouse](#pagemouse) to click in the center of the element. @@ -2525,8 +2548,11 @@ await elementHandle.type('some text'); await elementHandle.press('Enter'); ``` -#### elementHandle.uncheck() -- returns: <[Promise]> Promise which resolves when the element is successfully checked. Promise gets rejected if the operation fails. +#### elementHandle.uncheck([options]) +- `options` <[Object]> + - `waitForInteractable` <[boolean]> Whether to wait for element to become static (not moving) and receive pointer events at the click point. + - `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 unchecked. Promise gets rejected if the operation fails. If element is not already unchecked, it scrolls it into view if needed, and then uses [elementHandle.click](#elementhandleclickoptions) to click in the center of the element. diff --git a/src/dom.ts b/src/dom.ts index ab2c3a05faf33..e841772509723 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -25,6 +25,8 @@ import { Page } from './page'; import * as platform from './platform'; import { Selectors } from './selectors'; +export type WaitForInteractableOptions = types.TimeoutOptions & { waitForInteractable?: boolean }; + export class FrameExecutionContext extends js.ExecutionContext { readonly frame: frames.Frame; @@ -230,10 +232,15 @@ export class ElementHandle extends js.JSHandle { return point; } - async _performPointerAction(action: (point: types.Point) => Promise, options?: input.PointerActionOptions): Promise { + async _performPointerAction(action: (point: types.Point) => Promise, options?: input.PointerActionOptions & WaitForInteractableOptions): Promise { + const { waitForInteractable = true } = (options || {}); + if (waitForInteractable) + await this._waitForStablePosition(options); const relativePoint = options ? options.relativePoint : undefined; await this._scrollRectIntoViewIfNeeded(relativePoint ? { x: relativePoint.x, y: relativePoint.y, width: 0, height: 0 } : undefined); const point = relativePoint ? await this._relativePoint(relativePoint) : await this._clickablePoint(); + if (waitForInteractable) + await this._waitForHitTargetAt(point, options); let restoreModifiers: input.Modifier[] | undefined; if (options && options.modifiers) restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); @@ -242,19 +249,19 @@ export class ElementHandle extends js.JSHandle { await this._page.keyboard._ensureModifiers(restoreModifiers); } - hover(options?: input.PointerActionOptions): Promise { + hover(options?: input.PointerActionOptions & WaitForInteractableOptions): Promise { return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options); } - click(options?: input.ClickOptions): Promise { + click(options?: input.ClickOptions & WaitForInteractableOptions): Promise { return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options); } - dblclick(options?: input.MultiClickOptions): Promise { + dblclick(options?: input.MultiClickOptions & WaitForInteractableOptions): Promise { return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options); } - tripleclick(options?: input.MultiClickOptions): Promise { + tripleclick(options?: input.MultiClickOptions & WaitForInteractableOptions): Promise { return this._performPointerAction(point => this._page.mouse.tripleclick(point.x, point.y, options), options); } @@ -402,19 +409,20 @@ export class ElementHandle extends js.JSHandle { await this._page.keyboard.type(text, options); } - async press(key: string, options: { delay?: number; text?: string; } | undefined) { + async press(key: string, options?: { delay?: number, text?: string }) { await this.focus(); await this._page.keyboard.press(key, options); } - async check() { - await this._setChecked(true); + + async check(options?: WaitForInteractableOptions) { + await this._setChecked(true, options); } - async uncheck() { - await this._setChecked(false); + async uncheck(options?: WaitForInteractableOptions) { + await this._setChecked(false, options); } - private async _setChecked(state: boolean) { + private async _setChecked(state: boolean, options: WaitForInteractableOptions = {}) { const isCheckboxChecked = async (): Promise => { return this._evaluateInUtility((node: Node) => { if (node.nodeType !== Node.ELEMENT_NODE) @@ -442,7 +450,7 @@ export class ElementHandle extends js.JSHandle { if (await isCheckboxChecked() === state) return; - await this.click(); + await this.click(options); if (await isCheckboxChecked() !== state) throw new Error('Unable to click checkbox'); } @@ -497,6 +505,52 @@ export class ElementHandle extends js.JSHandle { return visibleRatio; }); } + + async _waitForStablePosition(options: types.TimeoutOptions = {}): Promise { + const context = await this._context.frame._utilityContext(); + const stablePromise = context.evaluate((injected: Injected, node: Node, timeout: number) => { + if (!node.isConnected) + throw new Error('Element is not attached to the DOM'); + const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; + if (!element) + throw new Error('Element is not attached to the DOM'); + + let lastRect: types.Rect | undefined; + return injected.poll('raf', undefined, timeout, () => { + const clientRect = element.getBoundingClientRect(); + const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height }; + const isStable = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height; + lastRect = rect; + return isStable; + }); + }, await context._injected(), this, options.timeout || 0); + await helper.waitWithTimeout(stablePromise, 'element to stop moving', options.timeout || 0); + } + + async _waitForHitTargetAt(point: types.Point, options: types.TimeoutOptions = {}): Promise { + const frame = await this.ownerFrame(); + if (frame && frame.parentFrame()) { + const element = await frame.frameElement(); + const box = await element.boundingBox(); + if (!box) + throw new Error('Element is not attached to the DOM'); + // Translate from viewport coordinates to frame coordinates. + point = { x: point.x - box.x, y: point.y - box.y }; + } + const context = await this._context.frame._utilityContext(); + const hitTargetPromise = context.evaluate((injected: Injected, node: Node, timeout: number, point: types.Point) => { + 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 injected.poll('raf', undefined, timeout, () => { + let hitElement = injected.utils.deepElementFromPoint(document, point.x, point.y); + while (hitElement && hitElement !== element) + hitElement = injected.utils.parentElementOrShadowHost(hitElement); + return hitElement === element; + }); + }, await context._injected(), this, options.timeout || 0, point); + await helper.waitWithTimeout(hitTargetPromise, 'element to receive mouse events', options.timeout || 0); + } } function normalizeSelector(selector: string): string { @@ -514,51 +568,44 @@ function normalizeSelector(selector: string): string { export type Task = (context: FrameExecutionContext) => Promise; -export function waitForFunctionTask(selector: string | undefined, pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]) { - const { polling = 'raf' } = options; +function assertPolling(polling: types.Polling) { if (helper.isString(polling)) assert(polling === 'raf' || polling === 'mutation', 'Unknown polling option: ' + polling); else if (helper.isNumber(polling)) assert(polling > 0, 'Cannot poll with non-positive interval: ' + polling); else throw new Error('Unknown polling options: ' + polling); +} + +export function waitForFunctionTask(selector: string | undefined, pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]): Task { + const { polling = 'raf' } = options; + assertPolling(polling); const predicateBody = helper.isString(pageFunction) ? 'return (' + pageFunction + ')' : 'return (' + pageFunction + ')(...args)'; if (selector !== undefined) selector = normalizeSelector(selector); return async (context: FrameExecutionContext) => context.evaluateHandle((injected: Injected, selector: string | undefined, predicateBody: string, polling: types.Polling, timeout: number, ...args) => { const innerPredicate = new Function('...args', predicateBody); - if (polling === 'raf') - return injected.pollRaf(selector, predicate, timeout); - if (polling === 'mutation') - return injected.pollMutation(selector, predicate, timeout); - return injected.pollInterval(selector, polling, predicate, timeout); - - function predicate(element: Element | undefined): any { + return injected.poll(polling, selector, timeout, (element: Element | undefined): any => { if (selector === undefined) return innerPredicate(...args); return innerPredicate(element, ...args); - } + }); }, await context._injected(), selector, predicateBody, polling, options.timeout || 0, ...args); } export function waitForSelectorTask(selector: string, visibility: types.Visibility, timeout: number): Task { - return async (context: FrameExecutionContext) => { - selector = normalizeSelector(selector); - return context.evaluateHandle((injected: Injected, selector: string, visibility: types.Visibility, timeout: number) => { - if (visibility !== 'any') - return injected.pollRaf(selector, predicate, timeout); - return injected.pollMutation(selector, predicate, timeout); - - function predicate(element: Element | undefined): Element | boolean { - if (!element) - return visibility === 'hidden'; - if (visibility === 'any') - return element; - return injected.isVisible(element) === (visibility === 'visible') ? element : false; - } - }, await context._injected(), selector, visibility, timeout); - }; + selector = normalizeSelector(selector); + return async (context: FrameExecutionContext) => context.evaluateHandle((injected: Injected, selector: string, visibility: types.Visibility, timeout: number) => { + const polling = visibility === 'any' ? 'mutation' : 'raf'; + return injected.poll(polling, selector, timeout, (element: Element | undefined): Element | boolean => { + if (!element) + return visibility === 'hidden'; + if (visibility === 'any') + return element; + return injected.isVisible(element) === (visibility === 'visible') ? element : false; + }); + }, await context._injected(), selector, visibility, timeout); } export const setFileInputFunction = async (element: HTMLInputElement, payloads: types.FilePayload[]) => { diff --git a/src/frames.ts b/src/frames.ts index adc5b17dc20a2..502ed5da80a2e 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -780,19 +780,19 @@ export class Frame { return result!; } - async click(selector: string, options?: WaitForOptions & ClickOptions) { + async click(selector: string, options?: WaitForOptions & ClickOptions & dom.WaitForInteractableOptions) { const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options); await handle.click(options); await handle.dispose(); } - async dblclick(selector: string, options?: WaitForOptions & MultiClickOptions) { + async dblclick(selector: string, options?: WaitForOptions & MultiClickOptions & dom.WaitForInteractableOptions) { const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options); await handle.dblclick(options); await handle.dispose(); } - async tripleclick(selector: string, options?: WaitForOptions & MultiClickOptions) { + async tripleclick(selector: string, options?: WaitForOptions & MultiClickOptions & dom.WaitForInteractableOptions) { const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options); await handle.tripleclick(options); await handle.dispose(); @@ -810,7 +810,7 @@ export class Frame { await handle.dispose(); } - async hover(selector: string, options?: WaitForOptions & PointerActionOptions) { + async hover(selector: string, options?: WaitForOptions & PointerActionOptions & dom.WaitForInteractableOptions) { const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options); await handle.hover(options); await handle.dispose(); @@ -830,15 +830,15 @@ export class Frame { await handle.dispose(); } - async check(selector: string, options?: WaitForOptions) { + async check(selector: string, options?: WaitForOptions & dom.WaitForInteractableOptions) { const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options); - await handle.check(); + await handle.check(options); await handle.dispose(); } - async uncheck(selector: string, options?: WaitForOptions) { + async uncheck(selector: string, options?: WaitForOptions & dom.WaitForInteractableOptions) { const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options); - await handle.uncheck(); + await handle.uncheck(options); await handle.dispose(); } diff --git a/src/injected/injected.ts b/src/injected/injected.ts index 1afca5191d2eb..46fd65ce70ee1 100644 --- a/src/injected/injected.ts +++ b/src/injected/injected.ts @@ -145,7 +145,7 @@ class Injected { return !!(rect.top || rect.bottom || rect.width || rect.height); } - pollMutation(selector: string | undefined, predicate: Predicate, timeout: number): Promise { + private _pollMutation(selector: string | undefined, predicate: Predicate, timeout: number): Promise { let timedOut = false; if (timeout) setTimeout(() => timedOut = true, timeout); @@ -178,7 +178,7 @@ class Injected { return result; } - pollRaf(selector: string | undefined, predicate: Predicate, timeout: number): Promise { + private _pollRaf(selector: string | undefined, predicate: Predicate, timeout: number): Promise { let timedOut = false; if (timeout) setTimeout(() => timedOut = true, timeout); @@ -203,7 +203,7 @@ class Injected { return result; } - pollInterval(selector: string | undefined, pollInterval: number, predicate: Predicate, timeout: number): Promise { + private _pollInterval(selector: string | undefined, pollInterval: number, predicate: Predicate, timeout: number): Promise { let timedOut = false; if (timeout) setTimeout(() => timedOut = true, timeout); @@ -226,6 +226,14 @@ class Injected { onTimeout(); return result; } + + poll(polling: 'raf' | 'mutation' | number, selector: string | undefined, timeout: number, predicate: Predicate): Promise { + if (polling === 'raf') + return this._pollRaf(selector, predicate, timeout); + if (polling === 'mutation') + return this._pollMutation(selector, predicate, timeout); + return this._pollInterval(selector, polling, predicate, timeout); + } } export default Injected; diff --git a/src/page.ts b/src/page.ts index 8f7aa4989dbef..ecda620449889 100644 --- a/src/page.ts +++ b/src/page.ts @@ -485,15 +485,15 @@ export class Page extends platform.EventEmitter { return this._closed; } - async click(selector: string, options?: frames.WaitForOptions & input.ClickOptions) { + async click(selector: string, options?: frames.WaitForOptions & input.ClickOptions & dom.WaitForInteractableOptions) { return this.mainFrame().click(selector, options); } - async dblclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions) { + async dblclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions & dom.WaitForInteractableOptions) { return this.mainFrame().dblclick(selector, options); } - async tripleclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions) { + async tripleclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions & dom.WaitForInteractableOptions) { return this.mainFrame().tripleclick(selector, options); } @@ -505,7 +505,7 @@ export class Page extends platform.EventEmitter { return this.mainFrame().focus(selector, options); } - async hover(selector: string, options?: frames.WaitForOptions & input.PointerActionOptions) { + async hover(selector: string, options?: frames.WaitForOptions & input.PointerActionOptions & dom.WaitForInteractableOptions) { return this.mainFrame().hover(selector, options); } @@ -517,11 +517,11 @@ export class Page extends platform.EventEmitter { return this.mainFrame().type(selector, text, options); } - async check(selector: string, options?: frames.WaitForOptions) { + async check(selector: string, options?: frames.WaitForOptions & dom.WaitForInteractableOptions) { return this.mainFrame().check(selector, options); } - async uncheck(selector: string, options?: frames.WaitForOptions) { + async uncheck(selector: string, options?: frames.WaitForOptions & dom.WaitForInteractableOptions) { return this.mainFrame().uncheck(selector, options); } diff --git a/test/assets/input/button.html b/test/assets/input/button.html index 164536e5e7f05..da1abb6e84b21 100644 --- a/test/assets/input/button.html +++ b/test/assets/input/button.html @@ -13,6 +13,8 @@ window.pageX = undefined; window.pageY = undefined; window.shiftKey = undefined; + window.pageX = undefined; + window.pageY = undefined; document.querySelector('button').addEventListener('click', e => { result = 'Clicked'; offsetX = e.offsetX; diff --git a/test/click.spec.js b/test/click.spec.js index 2dd4e1cb32a08..8a56bfe75577f 100644 --- a/test/click.spec.js +++ b/test/click.spec.js @@ -378,6 +378,96 @@ module.exports.describe = function({testRunner, expect, playwright, FFOX, CHROMI expect(await page.evaluate(() => pageY)).toBe(expected.y); }); + it('should wait for stable position', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.$eval('button', button => { + button.style.transition = 'margin 500ms linear 0s'; + button.style.marginLeft = '200px'; + button.style.borderWidth = '0'; + button.style.width = '200px'; + button.style.height = '20px'; + document.body.style.margin = '0'; + }); + await page.click('button'); + expect(await page.evaluate(() => window.result)).toBe('Clicked'); + expect(await page.evaluate(() => pageX)).toBe(300); + expect(await page.evaluate(() => pageY)).toBe(10); + }); + it('should timeout waiting for stable position', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await button.evaluate(button => { + button.style.transition = 'margin 5s linear 0s'; + button.style.marginLeft = '200px'; + }); + const error = await button.click({ timeout: 100 }).catch(e => e); + expect(error.message).toContain('timeout 100ms exceeded'); + }); + it('should wait for becoming hit target', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + await page.$eval('button', button => { + button.style.borderWidth = '0'; + button.style.width = '200px'; + button.style.height = '20px'; + document.body.style.margin = '0'; + document.body.style.position = 'relative'; + const flyOver = document.createElement('div'); + flyOver.className = 'flyover'; + flyOver.style.position = 'absolute'; + flyOver.style.width = '400px'; + flyOver.style.height = '20px'; + flyOver.style.left = '-200px'; + flyOver.style.top = '0'; + flyOver.style.background = 'red'; + document.body.appendChild(flyOver); + }); + let clicked = false; + const clickPromise = page.click('button').then(() => clicked = true); + expect(clicked).toBe(false); + + await page.$eval('.flyover', flyOver => flyOver.style.left = '0'); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await page.evaluate(() => new Promise(requestAnimationFrame)); + expect(clicked).toBe(false); + + await page.$eval('.flyover', flyOver => flyOver.style.left = '200px'); + await clickPromise; + expect(clicked).toBe(true); + expect(await page.evaluate(() => window.result)).toBe('Clicked'); + }); + it('should timeout waiting for hit target', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate(() => { + document.body.style.position = 'relative'; + const blocker = document.createElement('div'); + blocker.style.position = 'absolute'; + blocker.style.width = '400px'; + blocker.style.height = '20px'; + blocker.style.left = '0'; + blocker.style.top = '0'; + document.body.appendChild(blocker); + }); + const error = await button.click({ timeout: 100 }).catch(e => e); + expect(error.message).toContain('timeout 100ms exceeded'); + }); + it('should fail when obscured and not waiting for interactable', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate(() => { + document.body.style.position = 'relative'; + const blocker = document.createElement('div'); + blocker.style.position = 'absolute'; + blocker.style.width = '400px'; + blocker.style.height = '20px'; + blocker.style.left = '0'; + blocker.style.top = '0'; + document.body.appendChild(blocker); + }); + await button.click({ waitForInteractable: false }); + expect(await page.evaluate(() => window.result)).toBe('Was not clicked'); + }); + it('should update modifiers correctly', async({page, server}) => { await page.goto(server.PREFIX + '/input/button.html'); await page.click('button', { modifiers: ['Shift'] }); diff --git a/test/elementhandle.spec.js b/test/elementhandle.spec.js index c54e445b4bb4a..67c96bcc105fc 100644 --- a/test/elementhandle.spec.js +++ b/test/elementhandle.spec.js @@ -228,7 +228,7 @@ module.exports.describe = function({testRunner, expect, FFOX, CHROMIUM, WEBKIT}) await page.evaluate(button => button.remove(), button); let error = null; await button.click().catch(err => error = err); - expect(error.message).toContain('Node is detached from document'); + expect(error.message).toContain('Element is not attached to the DOM'); }); it('should throw for hidden nodes', async({page, server}) => { await page.goto(server.PREFIX + '/input/button.html');