diff --git a/docs/api.md b/docs/api.md index 4ebe20e65c..e11bbc34c5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2748,6 +2748,7 @@ ElementHandle instances can be used as an argument in [`page.$eval()`](#pageeval - [elementHandle.toString()](#elementhandletostring) - [elementHandle.type(text[, options])](#elementhandletypetext-options) - [elementHandle.uncheck([options])](#elementhandleuncheckoptions) +- [elementHandle.waitForElementState(state[, options])](#elementhandlewaitforelementstatestate-options) - [elementHandle.waitForSelector(selector[, options])](#elementhandlewaitforselectorselector-options) @@ -3111,6 +3112,21 @@ If the element is detached from the DOM at any moment during the action, this me When all steps combined have not finished during the specified `timeout`, this method rejects with a [TimeoutError]. Passing zero timeout disables this. +#### elementHandle.waitForElementState(state[, options]) +- `state` <"visible"|"hidden"|"stable"|"enabled"> A state to wait for, see below for more details. +- `options` <[Object]> + - `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 that resolves when the element satisfies the `state`. + +Depending on the `state` parameter, this method waits for one of the [actionability](./actionability.md) checks to pass. This method throws when the element is detached while waiting, unless waiting for the `"hidden"` state. +- `"visible"` Wait until the element is [visible](./actionability.md#visible). +- `"hidden"` Wait until the element is [not visible](./actionability.md#visible) or [not attached](./actionability.md#attached). Note that waiting for hidden does not throw when the element detaches. +- `"stable"` Wait until the element is both [visible](./actionability.md#visible) and [stable](./actionability.md#stable). +- `"enabled"` Wait until the element is [enabled](./actionability.md#enabled). + +If the element does not satisfy the condition for the `timeout` milliseconds, this method will throw. + + #### elementHandle.waitForSelector(selector[, options]) - `selector` <[string]> A selector of an element to wait for, relative to the element handle. See [working with selectors](#working-with-selectors) for more details. - `options` <[Object]> @@ -3131,7 +3147,7 @@ const div = await page.$('div'); const span = await div.waitForSelector('span', { state: 'attached' }); ``` -> **NOTE** This method works does not work across navigations, use [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options) instead. +> **NOTE** This method does not work across navigations, use [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options) instead. ### class: JSHandle diff --git a/src/dom.ts b/src/dom.ts index cabaaf5212..284dc4b5ac 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -226,14 +226,6 @@ export class ElementHandle extends js.JSHandle { this._page._timeoutSettings.timeout(options)); } - private async _waitForVisible(progress: Progress): Promise<'error:notconnected' | 'done'> { - const poll = await this._evaluateHandleInUtility(([injected, node]) => { - return injected.waitForNodeVisible(node); - }, {}); - const pollHandler = new InjectedScriptPollHandler(progress, poll); - return throwFatalDOMError(await pollHandler.finish()); - } - private async _clickablePoint(): Promise { const intersectQuadWithViewport = (quad: types.Quad): types.Quad => { return quad.map(point => ({ @@ -623,6 +615,45 @@ export class ElementHandle extends js.JSHandle { return result; } + async waitForElementState(state: 'visible' | 'hidden' | 'stable' | 'enabled', options: types.TimeoutOptions = {}): Promise { + return this._page._runAbortableTask(async progress => { + if (state === 'visible') { + const poll = await this._evaluateHandleInUtility(([injected, node]) => { + return injected.waitForNodeVisible(node); + }, {}); + const pollHandler = new InjectedScriptPollHandler(progress, poll); + assertDone(throwRetargetableDOMError(await pollHandler.finish())); + return; + } + if (state === 'hidden') { + const poll = await this._evaluateHandleInUtility(([injected, node]) => { + return injected.waitForNodeHidden(node); + }, {}); + const pollHandler = new InjectedScriptPollHandler(progress, poll); + assertDone(await pollHandler.finish()); + return; + } + if (state === 'enabled') { + const poll = await this._evaluateHandleInUtility(([injected, node]) => { + return injected.waitForNodeEnabled(node); + }, {}); + const pollHandler = new InjectedScriptPollHandler(progress, poll); + assertDone(throwRetargetableDOMError(await pollHandler.finish())); + return; + } + if (state === 'stable') { + const rafCount = this._page._delegate.rafCountForStablePosition(); + const poll = await this._evaluateHandleInUtility(([injected, node, rafCount]) => { + return injected.waitForDisplayedAtStablePosition(node, rafCount, false /* waitForEnabled */); + }, rafCount); + const pollHandler = new InjectedScriptPollHandler(progress, poll); + assertDone(throwRetargetableDOMError(await pollHandler.finish())); + return; + } + throw new Error(`state: expected one of (visible|hidden|stable|enabled)`); + }, this._page._timeoutSettings.timeout(options)); + } + async waitForSelector(selector: string, options: types.WaitForElementOptions = {}): Promise | null> { const { state = 'visible' } = options; if (!['attached', 'detached', 'visible', 'hidden'].includes(state)) diff --git a/src/injected/injectedScript.ts b/src/injected/injectedScript.ts index 2f2a87b25a..710b74ad25 100644 --- a/src/injected/injectedScript.ts +++ b/src/injected/injectedScript.ts @@ -357,6 +357,32 @@ export default class InjectedScript { }); } + waitForNodeHidden(node: Node): types.InjectedScriptPoll<'done'> { + return this.pollRaf((progress, continuePolling) => { + const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement; + if (!node.isConnected || !element) + return 'done'; + if (this.isVisible(element)) { + progress.logRepeating(' element is visible - waiting...'); + return continuePolling; + } + return 'done'; + }); + } + + waitForNodeEnabled(node: Node): types.InjectedScriptPoll<'error:notconnected' | 'done'> { + return this.pollRaf((progress, continuePolling) => { + const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement; + if (!node.isConnected || !element) + return 'error:notconnected'; + if (this._isElementDisabled(element)) { + progress.logRepeating(' element is not enabled - waiting...'); + return continuePolling; + } + return 'done'; + }); + } + focusNode(node: Node, resetSelectionIfNotFocused?: boolean): FatalDOMError | 'error:notconnected' | 'done' { if (!node.isConnected) return 'error:notconnected'; @@ -463,8 +489,7 @@ export default class InjectedScript { const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element) : undefined; const isVisible = !!style && style.visibility !== 'hidden'; - const elementOrButton = element.closest('button, [role=button]') || element; - const isDisabled = waitForEnabled && ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled'); + const isDisabled = waitForEnabled && this._isElementDisabled(element); if (isDisplayed && isStable && isVisible && !isDisabled) return 'done'; @@ -526,6 +551,11 @@ export default class InjectedScript { node.dispatchEvent(event); } + private _isElementDisabled(element: Element): boolean { + const elementOrButton = element.closest('button, [role=button]') || element; + return ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled'); + } + private _parentElementOrShadowHost(element: Element): Element | undefined { if (element.parentElement) return element.parentElement; diff --git a/src/rpc/channels.ts b/src/rpc/channels.ts index 81069c5521..79219e244b 100644 --- a/src/rpc/channels.ts +++ b/src/rpc/channels.ts @@ -1620,6 +1620,7 @@ export interface ElementHandleChannel extends JSHandleChannel { textContent(params?: ElementHandleTextContentParams): Promise; type(params: ElementHandleTypeParams): Promise; uncheck(params: ElementHandleUncheckParams): Promise; + waitForElementState(params: ElementHandleWaitForElementStateParams): Promise; waitForSelector(params: ElementHandleWaitForSelectorParams): Promise; } export type ElementHandleEvalOnSelectorParams = { @@ -1914,6 +1915,14 @@ export type ElementHandleUncheckOptions = { timeout?: number, }; export type ElementHandleUncheckResult = void; +export type ElementHandleWaitForElementStateParams = { + state: 'visible' | 'hidden' | 'stable' | 'enabled', + timeout?: number, +}; +export type ElementHandleWaitForElementStateOptions = { + timeout?: number, +}; +export type ElementHandleWaitForElementStateResult = void; export type ElementHandleWaitForSelectorParams = { selector: string, timeout?: number, diff --git a/src/rpc/client/elementHandle.ts b/src/rpc/client/elementHandle.ts index 43856a16b3..88954278a6 100644 --- a/src/rpc/client/elementHandle.ts +++ b/src/rpc/client/elementHandle.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ElementHandleChannel, JSHandleInitializer, ElementHandleScrollIntoViewIfNeededOptions, ElementHandleHoverOptions, ElementHandleClickOptions, ElementHandleDblclickOptions, ElementHandleFillOptions, ElementHandleSetInputFilesOptions, ElementHandlePressOptions, ElementHandleCheckOptions, ElementHandleUncheckOptions, ElementHandleScreenshotOptions, ElementHandleTypeOptions, ElementHandleSelectTextOptions, ElementHandleWaitForSelectorOptions } from '../channels'; +import { ElementHandleChannel, JSHandleInitializer, ElementHandleScrollIntoViewIfNeededOptions, ElementHandleHoverOptions, ElementHandleClickOptions, ElementHandleDblclickOptions, ElementHandleFillOptions, ElementHandleSetInputFilesOptions, ElementHandlePressOptions, ElementHandleCheckOptions, ElementHandleUncheckOptions, ElementHandleScreenshotOptions, ElementHandleTypeOptions, ElementHandleSelectTextOptions, ElementHandleWaitForSelectorOptions, ElementHandleWaitForElementStateOptions } from '../channels'; import { Frame } from './frame'; import { FuncOn, JSHandle, serializeArgument, parseResult } from './jsHandle'; import { ChannelOwner } from './channelOwner'; @@ -209,6 +209,12 @@ export class ElementHandle extends JSHandle { }); } + async waitForElementState(state: 'visible' | 'hidden' | 'stable' | 'enabled', options: ElementHandleWaitForElementStateOptions = {}): Promise { + return this._wrapApiCall('elementHandle.waitForElementState', async () => { + return await this._elementChannel.waitForElementState({ state, ...options }); + }); + } + async waitForSelector(selector: string, options: ElementHandleWaitForSelectorOptions = {}): Promise | null> { return this._wrapApiCall('elementHandle.waitForSelector', async () => { const result = await this._elementChannel.waitForSelector({ selector, ...options }); diff --git a/src/rpc/protocol.yml b/src/rpc/protocol.yml index ffc24f2345..70bb50ee47 100644 --- a/src/rpc/protocol.yml +++ b/src/rpc/protocol.yml @@ -1577,6 +1577,17 @@ ElementHandle: noWaitAfter: boolean? timeout: number? + waitForElementState: + parameters: + state: + type: enum + literals: + - visible + - hidden + - stable + - enabled + timeout: number? + waitForSelector: parameters: selector: string diff --git a/src/rpc/server/elementHandlerDispatcher.ts b/src/rpc/server/elementHandlerDispatcher.ts index a13f62344f..032906b6e8 100644 --- a/src/rpc/server/elementHandlerDispatcher.ts +++ b/src/rpc/server/elementHandlerDispatcher.ts @@ -149,6 +149,10 @@ export class ElementHandleDispatcher extends JSHandleDispatcher implements Eleme return { value: serializeResult(await this._elementHandle._$$evalExpression(params.selector, params.expression, params.isFunction, parseArgument(params.arg))) }; } + async waitForElementState(params: { state: 'visible' | 'hidden' | 'stable' | 'enabled' } & types.TimeoutOptions): Promise { + await this._elementHandle.waitForElementState(params.state, params); + } + async waitForSelector(params: { selector: string } & types.WaitForElementOptions): Promise<{ element?: ElementHandleChannel }> { return { element: ElementHandleDispatcher.createNullable(this._scope, await this._elementHandle.waitForSelector(params.selector, params)) }; } diff --git a/src/rpc/validator.ts b/src/rpc/validator.ts index 47c7c7266b..f20b41feb3 100644 --- a/src/rpc/validator.ts +++ b/src/rpc/validator.ts @@ -759,6 +759,10 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme { noWaitAfter: tOptional(tBoolean), timeout: tOptional(tNumber), }); + scheme.ElementHandleWaitForElementStateParams = tObject({ + state: tEnum(['visible', 'hidden', 'stable', 'enabled']), + timeout: tOptional(tNumber), + }); scheme.ElementHandleWaitForSelectorParams = tObject({ selector: tString, timeout: tOptional(tNumber), diff --git a/test/elementhandle-wait-for-element-state.spec.ts b/test/elementhandle-wait-for-element-state.spec.ts new file mode 100644 index 0000000000..49adeeea7a --- /dev/null +++ b/test/elementhandle-wait-for-element-state.spec.ts @@ -0,0 +1,118 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import './base.fixture'; + +async function giveItAChanceToResolve(page) { + for (let i = 0; i < 5; i++) + await page.evaluate(() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))); +} + +it('should wait for visible', async ({ page }) => { + await page.setContent(`
content
`); + const div = await page.$('div'); + let done = false; + const promise = div.waitForElementState('visible').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await div.evaluate(div => div.style.display = 'block'); + await promise; +}); + +it('should wait for already visible', async ({ page }) => { + await page.setContent(`
content
`); + const div = await page.$('div'); + await div.waitForElementState('visible'); +}); + +it('should timeout waiting for visible', async ({ page }) => { + await page.setContent(`
content
`); + const div = await page.$('div'); + const error = await div.waitForElementState('visible', { timeout: 1000 }).catch(e => e); + expect(error.message).toContain('Timeout 1000ms exceeded'); +}); + +it('should throw waiting for visible when detached', async ({ page }) => { + await page.setContent(`
content
`); + const div = await page.$('div'); + const promise = div.waitForElementState('visible').catch(e => e); + await div.evaluate(div => div.remove()); + const error = await promise; + expect(error.message).toContain('Element is not attached to the DOM'); +}); + +it('should wait for hidden', async ({ page }) => { + await page.setContent(`
content
`); + const div = await page.$('div'); + let done = false; + const promise = div.waitForElementState('hidden').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await div.evaluate(div => div.style.display = 'none'); + await promise; +}); + +it('should wait for already hidden', async ({ page }) => { + await page.setContent(`
`); + const div = await page.$('div'); + await div.waitForElementState('hidden'); +}); + +it('should wait for hidden when detached', async ({ page }) => { + await page.setContent(`
content
`); + const div = await page.$('div'); + let done = false; + const promise = div.waitForElementState('hidden').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await div.evaluate(div => div.remove()); + await promise; +}); + +it('should wait for enabled button', async({page, server}) => { + await page.setContent(''); + const span = await page.$('text=Target'); + let done = false; + const promise = span.waitForElementState('enabled').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await span.evaluate(span => (span.parentElement as HTMLButtonElement).disabled = false); + await promise; +}); + +it('should throw waiting for enabled when detached', async ({ page }) => { + await page.setContent(``); + const button = await page.$('button'); + const promise = button.waitForElementState('enabled').catch(e => e); + await button.evaluate(button => button.remove()); + const error = await promise; + expect(error.message).toContain('Element is not attached to the DOM'); +}); + +it('should wait for stable position', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.$eval('button', button => { + button.style.transition = 'margin 10000ms linear 0s'; + button.style.marginLeft = '20000px'; + }); + let done = false; + const promise = button.waitForElementState('stable').then(() => done = true); + await giveItAChanceToResolve(page); + expect(done).toBe(false); + await button.evaluate(button => button.style.transition = ''); + await promise; +});