api: introduce ElementHandle.waitForSelector (#3452)

This is similar to Frame.waitForSelector, but relative to the handle.
This commit is contained in:
Dmitry Gozman 2020-08-14 14:47:24 -07:00 committed by GitHub
parent a03c761201
commit 85c93e91a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 151 additions and 19 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.waitForSelector(selector[, options])](#elementhandlewaitforselectorselector-options)
<!-- GEN:stop -->
<!-- GEN:toc-extends-JSHandle -->
- [jsHandle.asElement()](#jshandleaselement)
@ -3110,6 +3111,28 @@ 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.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]>
- `state` <"attached"|"detached"|"visible"|"hidden"> Defaults to `'visible'`. Can be either:
- `'attached'` - wait for element to be present in DOM.
- `'detached'` - wait for element to not be present in DOM.
- `'visible'` - wait for element to have non-empty bounding box and no `visibility:hidden`. Note that element without any content or with `display:none` has an empty bounding box and is not considered visible.
- `'hidden'` - wait for element to be either detached from DOM, or have an empty bounding box or `visibility:hidden`. This is opposite to the `'visible'` option.
- `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]<[null]|[ElementHandle]>> Promise that resolves when element specified by selector satisfies `state` option. Resolves to `null` if waiting for `hidden` or `detached`.
Wait for the `selector` relative to the element handle to satisfy `state` option (either appear/disappear from dom, or become visible/hidden). If at the moment of calling the method `selector` already satisfies the condition, the method will return immediately. If the selector doesn't satisfy the condition for the `timeout` milliseconds, the function will throw.
```js
await page.setContent(`<div><span></span></div>`);
const div = await page.$('div');
// Waiting for the 'span' selector relative to the 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.
### class: JSHandle
JSHandle represents an in-page JavaScript object. JSHandles can be created with the [page.evaluateHandle](#pageevaluatehandlepagefunction-arg) method.

View file

@ -637,6 +637,36 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return result;
}
async waitForSelector(selector: string, options: types.WaitForElementOptions = {}): Promise<ElementHandle<Element> | null> {
const { state = 'visible' } = options;
if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
throw new Error(`state: expected one of (attached|detached|visible|hidden)`);
const info = selectors._parseSelector(selector);
const task = waitForSelectorTask(info, state, this);
return this._page._runAbortableTask(async progress => {
progress.logger.info(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
const context = await this._context.frame._context(info.world);
const injected = await context.injectedScript();
const pollHandler = new InjectedScriptPollHandler(progress, await task(injected));
const result = await pollHandler.finishHandle();
if (!result.asElement()) {
result.dispose();
return null;
}
const handle = result.asElement() as ElementHandle<Element>;
return handle._adoptTo(await this._context.frame._mainContext());
}, this._page._timeoutSettings.timeout(options), 'elementHandle.waitForSelector');
}
async _adoptTo(context: FrameExecutionContext): Promise<ElementHandle<T>> {
if (this._context !== context) {
const adopted = await this._page._delegate.adoptElementHandle(this, context);
this.dispose();
return adopted;
}
return this;
}
async _waitForDisplayedAtStablePosition(progress: Progress, waitForEnabled: boolean): Promise<'error:notconnected' | 'done'> {
if (waitForEnabled)
progress.logger.info(` waiting for element to be visible, enabled and not moving`);
@ -782,12 +812,12 @@ function roundPoint(point: types.Point): types.Point {
export type SchedulableTask<T> = (injectedScript: js.JSHandle<InjectedScript>) => Promise<js.JSHandle<types.InjectedScriptPoll<T>>>;
export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden'): SchedulableTask<Element | undefined> {
return injectedScript => injectedScript.evaluateHandle((injected, { parsed, state }) => {
export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden', root?: ElementHandle): SchedulableTask<Element | undefined> {
return injectedScript => injectedScript.evaluateHandle((injected, { parsed, state, root }) => {
let lastElement: Element | undefined;
return injected.pollRaf((progress, continuePolling) => {
const element = injected.querySelector(parsed, document);
const element = injected.querySelector(parsed, root || document);
const visible = element ? injected.isVisible(element) : false;
if (lastElement !== element) {
@ -809,7 +839,7 @@ export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' |
return !visible ? undefined : continuePolling;
}
});
}, { parsed: selector.parsed, state });
}, { parsed: selector.parsed, state, root });
}
export function dispatchEventTask(selector: SelectorInfo, type: string, eventInit: Object): SchedulableTask<undefined> {

View file

@ -577,13 +577,7 @@ export class Frame {
return null;
}
const handle = result.asElement() as dom.ElementHandle<Element>;
const mainContext = await this._mainContext();
if (handle && handle._context !== mainContext) {
const adopted = await this._page._delegate.adoptElementHandle(handle, mainContext);
handle.dispose();
return adopted;
}
return handle;
return handle._adoptTo(await this._mainContext());
}, this._page._timeoutSettings.timeout(options), this._apiName('waitForSelector'));
}

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>;
waitForSelector(params: ElementHandleWaitForSelectorParams): Promise<ElementHandleWaitForSelectorResult>;
}
export type ElementHandleEvalOnSelectorParams = {
selector: string,
@ -1913,6 +1914,18 @@ export type ElementHandleUncheckOptions = {
timeout?: number,
};
export type ElementHandleUncheckResult = void;
export type ElementHandleWaitForSelectorParams = {
selector: string,
timeout?: number,
state?: 'attached' | 'detached' | 'visible' | 'hidden',
};
export type ElementHandleWaitForSelectorOptions = {
timeout?: number,
state?: 'attached' | 'detached' | 'visible' | 'hidden',
};
export type ElementHandleWaitForSelectorResult = {
element?: ElementHandleChannel,
};
// ----------- Request -----------
export type RequestInitializer = {

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 } from '../channels';
import { ElementHandleChannel, JSHandleInitializer, ElementHandleScrollIntoViewIfNeededOptions, ElementHandleHoverOptions, ElementHandleClickOptions, ElementHandleDblclickOptions, ElementHandleFillOptions, ElementHandleSetInputFilesOptions, ElementHandlePressOptions, ElementHandleCheckOptions, ElementHandleUncheckOptions, ElementHandleScreenshotOptions, ElementHandleTypeOptions, ElementHandleSelectTextOptions, ElementHandleWaitForSelectorOptions } from '../channels';
import { Frame } from './frame';
import { FuncOn, JSHandle, serializeArgument, parseResult } from './jsHandle';
import { ChannelOwner } from './channelOwner';
@ -208,6 +208,13 @@ export class ElementHandle<T extends Node = Node> extends JSHandle<T> {
return parseResult(result.value);
});
}
async waitForSelector(selector: string, options: ElementHandleWaitForSelectorOptions = {}): Promise<ElementHandle<Element> | null> {
return this._wrapApiCall('elementHandle.waitForSelector', async () => {
const result = await this._elementChannel.waitForSelector({ selector, ...options });
return ElementHandle.fromNullable(result.element) as ElementHandle<Element> | null;
});
}
}
export function convertSelectOptionValues(values: string | ElementHandle | SelectOption | string[] | ElementHandle[] | SelectOption[] | null): { elements?: ElementHandleChannel[], options?: SelectOption[] } {

View file

@ -1577,6 +1577,19 @@ ElementHandle:
noWaitAfter: boolean?
timeout: number?
waitForSelector:
parameters:
selector: string
timeout: number?
state:
type: enum?
literals:
- attached
- detached
- visible
- hidden
returns:
element: ElementHandle?
Request:

View file

@ -148,6 +148,10 @@ export class ElementHandleDispatcher extends JSHandleDispatcher implements Eleme
async evalOnSelectorAll(params: { selector: string, expression: string, isFunction: boolean, arg: SerializedArgument }): Promise<{ value: SerializedValue }> {
return { value: serializeResult(await this._elementHandle._$$evalExpression(params.selector, params.expression, params.isFunction, parseArgument(params.arg))) };
}
async waitForSelector(params: { selector: string } & types.WaitForElementOptions): Promise<{ element?: ElementHandleChannel }> {
return { element: ElementHandleDispatcher.createNullable(this._scope, await this._elementHandle.waitForSelector(params.selector, params)) };
}
}
export function convertSelectOptionValues(elements?: ElementHandleChannel[], options?: types.SelectOption[]): string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[] | null {

View file

@ -759,6 +759,11 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
noWaitAfter: tOptional(tBoolean),
timeout: tOptional(tNumber),
});
scheme.ElementHandleWaitForSelectorParams = tObject({
selector: tString,
timeout: tOptional(tNumber),
state: tOptional(tEnum(['attached', 'detached', 'visible', 'hidden'])),
});
scheme.RequestResponseParams = tOptional(tObject({}));
scheme.RouteAbortParams = tObject({
errorCode: tOptional(tString),

View file

@ -47,6 +47,41 @@ it('should immediately resolve promise if node exists', async({page, server}) =>
await frame.waitForSelector('div', { state: 'attached'});
});
it('elementHandle.waitForSelector should immediately resolve if node exists', async({page}) => {
await page.setContent(`<span>extra</span><div><span>target</span></div>`);
const div = await page.$('div');
const span = await div.waitForSelector('span', { state: 'attached' });
expect(await span.evaluate(e => e.textContent)).toBe('target');
});
it('elementHandle.waitForSelector should wait', async({page}) => {
await page.setContent(`<div></div>`);
const div = await page.$('div');
const promise = div.waitForSelector('span', { state: 'attached' });
await div.evaluate(div => div.innerHTML = '<span>target</span>');
const span = await promise;
expect(await span.evaluate(e => e.textContent)).toBe('target');
});
it('elementHandle.waitForSelector should timeout', async({page}) => {
await page.setContent(`<div></div>`);
const div = await page.$('div');
const error = await div.waitForSelector('span', { timeout: 100 }).catch(e => e);
expect(error.message).toContain('Timeout 100ms exceeded.');
});
it('elementHandle.waitForSelector should throw on navigation', async({page, server}) => {
await page.setContent(`<div></div>`);
const div = await page.$('div');
const promise = div.waitForSelector('span').catch(e => e);
// Give it some time before navigating.
for (let i = 0; i < 10; i++)
await page.evaluate(() => 1);
await page.goto(server.EMPTY_PAGE);
const error = await promise;
expect(error.message).toContain('Execution context was destroyed, most likely because of a navigation');
});
it('should work with removed MutationObserver', async({page, server}) => {
await page.evaluate(() => delete window.MutationObserver);
const [handle] = await Promise.all([
@ -158,7 +193,7 @@ it('should work when node is added through innerHTML', async({page, server}) =>
await watchdog;
});
it('Page.$ waitFor is shortcut for main frame', async({page, server}) => {
it('page.waitForSelector is shortcut for main frame', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE);
const otherFrame = page.frames()[1];

View file

@ -46,9 +46,12 @@ type ElementHandleForTag<K extends keyof HTMLElementTagNameMap> = ElementHandle<
type HTMLOrSVGElement = SVGElement | HTMLElement;
type HTMLOrSVGElementHandle = ElementHandle<HTMLOrSVGElement>;
type WaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & {
type PageWaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & {
state: 'visible'|'attached';
}
};
type ElementHandleWaitForSelectorOptionsNotHidden = ElementHandleWaitForSelectorOptions & {
state: 'visible'|'attached';
};
export interface Page {
evaluate<R, Arg>(pageFunction: PageFunction<Arg, R>, arg: Arg): Promise<R>;
@ -76,8 +79,8 @@ export interface Page {
waitForFunction<R, Arg>(pageFunction: PageFunction<Arg, R>, arg: Arg, options?: PageWaitForFunctionOptions): Promise<SmartHandle<R>>;
waitForFunction<R>(pageFunction: PageFunction<void, R>, arg?: any, options?: PageWaitForFunctionOptions): Promise<SmartHandle<R>>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options?: WaitForSelectorOptionsNotHidden): Promise<ElementHandleForTag<K>>;
waitForSelector(selector: string, options?: WaitForSelectorOptionsNotHidden): Promise<HTMLOrSVGElementHandle>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options?: PageWaitForSelectorOptionsNotHidden): Promise<ElementHandleForTag<K>>;
waitForSelector(selector: string, options?: PageWaitForSelectorOptionsNotHidden): Promise<HTMLOrSVGElementHandle>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options: PageWaitForSelectorOptions): Promise<ElementHandleForTag<K> | null>;
waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise<null|HTMLOrSVGElementHandle>;
}
@ -108,8 +111,8 @@ export interface Frame {
waitForFunction<R, Arg>(pageFunction: PageFunction<Arg, R>, arg: Arg, options?: PageWaitForFunctionOptions): Promise<SmartHandle<R>>;
waitForFunction<R>(pageFunction: PageFunction<void, R>, arg?: any, options?: PageWaitForFunctionOptions): Promise<SmartHandle<R>>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options?: WaitForSelectorOptionsNotHidden): Promise<ElementHandleForTag<K>>;
waitForSelector(selector: string, options?: WaitForSelectorOptionsNotHidden): Promise<HTMLOrSVGElementHandle>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options?: PageWaitForSelectorOptionsNotHidden): Promise<ElementHandleForTag<K>>;
waitForSelector(selector: string, options?: PageWaitForSelectorOptionsNotHidden): Promise<HTMLOrSVGElementHandle>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options: PageWaitForSelectorOptions): Promise<ElementHandleForTag<K> | null>;
waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise<null|HTMLOrSVGElementHandle>;
}
@ -149,6 +152,11 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
$$eval<R, Arg, E extends HTMLOrSVGElement = HTMLOrSVGElement>(selector: string, pageFunction: PageFunctionOn<E[], Arg, R>, arg: Arg): Promise<R>;
$$eval<K extends keyof HTMLElementTagNameMap, R>(selector: K, pageFunction: PageFunctionOn<HTMLElementTagNameMap[K][], void, R>, arg?: any): Promise<R>;
$$eval<R, E extends HTMLOrSVGElement = HTMLOrSVGElement>(selector: string, pageFunction: PageFunctionOn<E[], void, R>, arg?: any): Promise<R>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options?: ElementHandleWaitForSelectorOptionsNotHidden): Promise<ElementHandleForTag<K>>;
waitForSelector(selector: string, options?: ElementHandleWaitForSelectorOptionsNotHidden): Promise<HTMLOrSVGElementHandle>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options: ElementHandleWaitForSelectorOptions): Promise<ElementHandleForTag<K> | null>;
waitForSelector(selector: string, options: ElementHandleWaitForSelectorOptions): Promise<null|HTMLOrSVGElementHandle>;
}
export interface BrowserType<Browser> {