api: ElementHandle.waitForElementState (#3501)

This method waits for visible, hidden, stable or enabled state,
similar to the actionability checks performed before actions.

This gives a bit more control to the user. Some examples:
- Allows to wait for something to be stable before taking a screenshot.
- Allows to wait for the element to be hidden/detached after a specific action.
This commit is contained in:
Dmitry Gozman 2020-08-17 16:22:34 -07:00 committed by GitHub
parent 58fc6b4003
commit 0e9793c452
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 241 additions and 12 deletions

View File

@ -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)
<!-- GEN:stop -->
<!-- GEN:toc-extends-JSHandle -->
@ -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

View File

@ -226,14 +226,6 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
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<types.Point | 'error:notvisible' | 'error:notinviewport'> {
const intersectQuadWithViewport = (quad: types.Quad): types.Quad => {
return quad.map(point => ({
@ -623,6 +615,45 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return result;
}
async waitForElementState(state: 'visible' | 'hidden' | 'stable' | 'enabled', options: types.TimeoutOptions = {}): Promise<void> {
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<ElementHandle<Element> | null> {
const { state = 'visible' } = options;
if (!['attached', 'detached', 'visible', 'hidden'].includes(state))

View File

@ -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;

View File

@ -1620,6 +1620,7 @@ export interface ElementHandleChannel extends JSHandleChannel {
textContent(params?: ElementHandleTextContentParams): Promise<ElementHandleTextContentResult>;
type(params: ElementHandleTypeParams): Promise<ElementHandleTypeResult>;
uncheck(params: ElementHandleUncheckParams): Promise<ElementHandleUncheckResult>;
waitForElementState(params: ElementHandleWaitForElementStateParams): Promise<ElementHandleWaitForElementStateResult>;
waitForSelector(params: ElementHandleWaitForSelectorParams): Promise<ElementHandleWaitForSelectorResult>;
}
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,

View File

@ -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<T extends Node = Node> extends JSHandle<T> {
});
}
async waitForElementState(state: 'visible' | 'hidden' | 'stable' | 'enabled', options: ElementHandleWaitForElementStateOptions = {}): Promise<void> {
return this._wrapApiCall('elementHandle.waitForElementState', async () => {
return await this._elementChannel.waitForElementState({ state, ...options });
});
}
async waitForSelector(selector: string, options: ElementHandleWaitForSelectorOptions = {}): Promise<ElementHandle<Element> | null> {
return this._wrapApiCall('elementHandle.waitForSelector', async () => {
const result = await this._elementChannel.waitForSelector({ selector, ...options });

View File

@ -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

View File

@ -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<void> {
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)) };
}

View File

@ -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),

View File

@ -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(`<div style='display:none'>content</div>`);
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(`<div>content</div>`);
const div = await page.$('div');
await div.waitForElementState('visible');
});
it('should timeout waiting for visible', async ({ page }) => {
await page.setContent(`<div style='display:none'>content</div>`);
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(`<div style='display:none'>content</div>`);
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(`<div>content</div>`);
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(`<div></div>`);
const div = await page.$('div');
await div.waitForElementState('hidden');
});
it('should wait for hidden when detached', async ({ page }) => {
await page.setContent(`<div>content</div>`);
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('<button disabled><span>Target</span></button>');
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(`<button disabled>Target</button>`);
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;
});