Skip to content

Commit

Permalink
api: introduce ElementHandle.waitForSelector (#3452)
Browse files Browse the repository at this point in the history
This is similar to Frame.waitForSelector, but relative to the handle.
  • Loading branch information
dgozman authored Aug 14, 2020
1 parent a03c761 commit 85c93e9
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 19 deletions.
23 changes: 23 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
38 changes: 34 additions & 4 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down Expand Up @@ -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) {
Expand All @@ -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> {
Expand Down
8 changes: 1 addition & 7 deletions src/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}

Expand Down
13 changes: 13 additions & 0 deletions src/rpc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
9 changes: 8 additions & 1 deletion src/rpc/client/elementHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[] } {
Expand Down
13 changes: 13 additions & 0 deletions src/rpc/protocol.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/rpc/server/elementHandlerDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/rpc/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
37 changes: 36 additions & 1 deletion test/wait-for-selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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];
Expand Down
20 changes: 14 additions & 6 deletions utils/generate_types/overrides.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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>;
}
Expand Down Expand Up @@ -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>;
}
Expand Down Expand Up @@ -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> {
Expand Down

0 comments on commit 85c93e9

Please sign in to comment.