From 85c93e91a71cc4bb34676489cc00810bebc9abfd Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 14 Aug 2020 14:47:24 -0700 Subject: [PATCH] api: introduce ElementHandle.waitForSelector (#3452) This is similar to Frame.waitForSelector, but relative to the handle. --- docs/api.md | 23 +++++++++++++ src/dom.ts | 38 +++++++++++++++++++--- src/frames.ts | 8 +---- src/rpc/channels.ts | 13 ++++++++ src/rpc/client/elementHandle.ts | 9 ++++- src/rpc/protocol.yml | 13 ++++++++ src/rpc/server/elementHandlerDispatcher.ts | 4 +++ src/rpc/validator.ts | 5 +++ test/wait-for-selector.spec.ts | 37 ++++++++++++++++++++- utils/generate_types/overrides.d.ts | 20 ++++++++---- 10 files changed, 151 insertions(+), 19 deletions(-) diff --git a/docs/api.md b/docs/api.md index 6292c46f51ab3..4ebe20e65c100 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.waitForSelector(selector[, options])](#elementhandlewaitforselectorselector-options) - [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(`
`); +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. diff --git a/src/dom.ts b/src/dom.ts index 82528a92aff1d..1f2c2d34aebda 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -637,6 +637,36 @@ export class ElementHandle extends js.JSHandle { return result; } + async waitForSelector(selector: string, options: types.WaitForElementOptions = {}): Promise | 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; + return handle._adoptTo(await this._context.frame._mainContext()); + }, this._page._timeoutSettings.timeout(options), 'elementHandle.waitForSelector'); + } + + async _adoptTo(context: FrameExecutionContext): Promise> { + 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 = (injectedScript: js.JSHandle) => Promise>>; -export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden'): SchedulableTask { - return injectedScript => injectedScript.evaluateHandle((injected, { parsed, state }) => { +export function waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden', root?: ElementHandle): SchedulableTask { + 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 { diff --git a/src/frames.ts b/src/frames.ts index fa5cdf3c585f6..2c88073fbcfcc 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -577,13 +577,7 @@ export class Frame { return null; } const handle = result.asElement() as dom.ElementHandle; - 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')); } diff --git a/src/rpc/channels.ts b/src/rpc/channels.ts index 47095bdd3f315..81069c55219e1 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; + waitForSelector(params: ElementHandleWaitForSelectorParams): Promise; } 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 = { diff --git a/src/rpc/client/elementHandle.ts b/src/rpc/client/elementHandle.ts index a6ed20c038d0d..43856a16b33a3 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 } 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 extends JSHandle { return parseResult(result.value); }); } + + async waitForSelector(selector: string, options: ElementHandleWaitForSelectorOptions = {}): Promise | null> { + return this._wrapApiCall('elementHandle.waitForSelector', async () => { + const result = await this._elementChannel.waitForSelector({ selector, ...options }); + return ElementHandle.fromNullable(result.element) as ElementHandle | null; + }); + } } export function convertSelectOptionValues(values: string | ElementHandle | SelectOption | string[] | ElementHandle[] | SelectOption[] | null): { elements?: ElementHandleChannel[], options?: SelectOption[] } { diff --git a/src/rpc/protocol.yml b/src/rpc/protocol.yml index 1a25f6cef28f4..ffc24f23454b1 100644 --- a/src/rpc/protocol.yml +++ b/src/rpc/protocol.yml @@ -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: diff --git a/src/rpc/server/elementHandlerDispatcher.ts b/src/rpc/server/elementHandlerDispatcher.ts index 9a61f8e073ab2..a13f62344f0d8 100644 --- a/src/rpc/server/elementHandlerDispatcher.ts +++ b/src/rpc/server/elementHandlerDispatcher.ts @@ -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 { diff --git a/src/rpc/validator.ts b/src/rpc/validator.ts index 1815ac1683f73..47c7c7266bbe1 100644 --- a/src/rpc/validator.ts +++ b/src/rpc/validator.ts @@ -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), diff --git a/test/wait-for-selector.spec.ts b/test/wait-for-selector.spec.ts index d6f2d56e5a0d7..be7c6d1ce9f76 100644 --- a/test/wait-for-selector.spec.ts +++ b/test/wait-for-selector.spec.ts @@ -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(`extra
target
`); + 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(`
`); + const div = await page.$('div'); + const promise = div.waitForSelector('span', { state: 'attached' }); + await div.evaluate(div => div.innerHTML = 'target'); + const span = await promise; + expect(await span.evaluate(e => e.textContent)).toBe('target'); +}); + +it('elementHandle.waitForSelector should timeout', async({page}) => { + await page.setContent(`
`); + 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(`
`); + 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]; diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index af675ce7968e0..490a9c84451ac 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -46,9 +46,12 @@ type ElementHandleForTag = ElementHandle< type HTMLOrSVGElement = SVGElement | HTMLElement; type HTMLOrSVGElementHandle = ElementHandle; -type WaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & { +type PageWaitForSelectorOptionsNotHidden = PageWaitForSelectorOptions & { state: 'visible'|'attached'; -} +}; +type ElementHandleWaitForSelectorOptionsNotHidden = ElementHandleWaitForSelectorOptions & { + state: 'visible'|'attached'; +}; export interface Page { evaluate(pageFunction: PageFunction, arg: Arg): Promise; @@ -76,8 +79,8 @@ export interface Page { waitForFunction(pageFunction: PageFunction, arg: Arg, options?: PageWaitForFunctionOptions): Promise>; waitForFunction(pageFunction: PageFunction, arg?: any, options?: PageWaitForFunctionOptions): Promise>; - waitForSelector(selector: K, options?: WaitForSelectorOptionsNotHidden): Promise>; - waitForSelector(selector: string, options?: WaitForSelectorOptionsNotHidden): Promise; + waitForSelector(selector: K, options?: PageWaitForSelectorOptionsNotHidden): Promise>; + waitForSelector(selector: string, options?: PageWaitForSelectorOptionsNotHidden): Promise; waitForSelector(selector: K, options: PageWaitForSelectorOptions): Promise | null>; waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise; } @@ -108,8 +111,8 @@ export interface Frame { waitForFunction(pageFunction: PageFunction, arg: Arg, options?: PageWaitForFunctionOptions): Promise>; waitForFunction(pageFunction: PageFunction, arg?: any, options?: PageWaitForFunctionOptions): Promise>; - waitForSelector(selector: K, options?: WaitForSelectorOptionsNotHidden): Promise>; - waitForSelector(selector: string, options?: WaitForSelectorOptionsNotHidden): Promise; + waitForSelector(selector: K, options?: PageWaitForSelectorOptionsNotHidden): Promise>; + waitForSelector(selector: string, options?: PageWaitForSelectorOptionsNotHidden): Promise; waitForSelector(selector: K, options: PageWaitForSelectorOptions): Promise | null>; waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise; } @@ -149,6 +152,11 @@ export interface ElementHandle extends JSHandle { $$eval(selector: string, pageFunction: PageFunctionOn, arg: Arg): Promise; $$eval(selector: K, pageFunction: PageFunctionOn, arg?: any): Promise; $$eval(selector: string, pageFunction: PageFunctionOn, arg?: any): Promise; + + waitForSelector(selector: K, options?: ElementHandleWaitForSelectorOptionsNotHidden): Promise>; + waitForSelector(selector: string, options?: ElementHandleWaitForSelectorOptionsNotHidden): Promise; + waitForSelector(selector: K, options: ElementHandleWaitForSelectorOptions): Promise | null>; + waitForSelector(selector: string, options: ElementHandleWaitForSelectorOptions): Promise; } export interface BrowserType {