From c03429444d05b39549489ad3da67d93b2be59f51 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 9 Dec 2021 12:51:14 +0100 Subject: [PATCH] feat: implement Element.waitForSelector (#7825) Co-authored-by: Johan Bay Co-authored-by: Mathias Bynens --- docs/api.md | 14 +++++++++++ src/common/DOMWorld.ts | 51 +++++++++++++++++++++++++++----------- src/common/JSHandle.ts | 44 ++++++++++++++++++++++++++++++++ test/elementhandle.spec.ts | 50 +++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 15 deletions(-) diff --git a/docs/api.md b/docs/api.md index 98a21d9e6b42e..50d882b457a70 100644 --- a/docs/api.md +++ b/docs/api.md @@ -336,6 +336,7 @@ * [elementHandle.toString()](#elementhandletostring) * [elementHandle.type(text[, options])](#elementhandletypetext-options) * [elementHandle.uploadFile(...filePaths)](#elementhandleuploadfilefilepaths) + * [elementHandle.waitForSelector(selector[, options])](#elementhandlewaitforselectorselector-options) - [class: HTTPRequest](#class-httprequest) * [httpRequest.abort([errorCode], [priority])](#httprequestaborterrorcode-priority) * [httpRequest.abortErrorReason()](#httprequestaborterrorreason) @@ -4872,6 +4873,19 @@ await elementHandle.press('Enter'); This method expects `elementHandle` to point to an [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). +#### elementHandle.waitForSelector(selector[, options]) + +- `selector` <[string]> A [selector] of an element to wait for +- `options` <[Object]> Optional waiting parameters + - `visible` <[boolean]> wait for element to be present in DOM and to be visible, i.e. to not have `display: none` or `visibility: hidden` CSS properties. Defaults to `false`. + - `hidden` <[boolean]> wait for element to not be found in the DOM or to be hidden, i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to `false`. + - `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method. +- returns: <[Promise]> Promise which resolves when element specified by selector string is added to DOM. Resolves to `null` if waiting for `hidden: true` and selector is not found in DOM. + +Wait for an element matching `selector` to appear within the `elementHandle`’s subtree. If the `selector` already matches an element at the moment of calling the method, the promise returned by the method resolves immediately. If the selector doesn’t appear after `timeout` milliseconds of waiting, the promise rejects. + +This method does not work across navigations or if the element is detached from DOM. + ### class: HTTPRequest Whenever the page sends a request, such as for a network resource, the following events are emitted by Puppeteer's page: diff --git a/src/common/DOMWorld.ts b/src/common/DOMWorld.ts index 0ff005e14b234..54b5dc206418c 100644 --- a/src/common/DOMWorld.ts +++ b/src/common/DOMWorld.ts @@ -58,6 +58,7 @@ export interface WaitForSelectorOptions { visible?: boolean; hidden?: boolean; timeout?: number; + root?: ElementHandle; } /** @@ -631,13 +632,14 @@ export class DOMWorld { waitForHidden ? ' to be hidden' : '' }`; async function predicate( + root: Element | Document, selector: string, waitForVisible: boolean, waitForHidden: boolean ): Promise { const node = predicateQueryHandler - ? ((await predicateQueryHandler(document, selector)) as Element) - : document.querySelector(selector); + ? ((await predicateQueryHandler(root, selector)) as Element) + : root.querySelector(selector); return checkWaitForOptions(node, waitForVisible, waitForHidden); } const waitTaskOptions: WaitTaskOptions = { @@ -648,6 +650,7 @@ export class DOMWorld { timeout, args: [selector, waitForVisible, waitForHidden], binding, + root: options.root, }; const waitTask = new WaitTask(waitTaskOptions); const jsHandle = await waitTask.promise; @@ -671,13 +674,14 @@ export class DOMWorld { const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; const title = `XPath \`${xpath}\`${waitForHidden ? ' to be hidden' : ''}`; function predicate( + root: Element | Document, xpath: string, waitForVisible: boolean, waitForHidden: boolean ): Node | null | boolean { const node = document.evaluate( xpath, - document, + root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null @@ -691,6 +695,7 @@ export class DOMWorld { polling, timeout, args: [xpath, waitForVisible, waitForHidden], + root: options.root, }; const waitTask = new WaitTask(waitTaskOptions); const jsHandle = await waitTask.promise; @@ -737,6 +742,7 @@ export interface WaitTaskOptions { timeout: number; binding?: PageBinding; args: SerializableOrJSHandle[]; + root?: ElementHandle; } /** @@ -755,6 +761,7 @@ export class WaitTask { _reject: (x: Error) => void; _timeoutTimer?: NodeJS.Timeout; _terminated = false; + _root: ElementHandle; constructor(options: WaitTaskOptions) { if (helper.isString(options.polling)) @@ -777,6 +784,7 @@ export class WaitTask { this._domWorld = options.domWorld; this._polling = options.polling; this._timeout = options.timeout; + this._root = options.root; this._predicateBody = getPredicateBody(options.predicateBody); this._args = options.args; this._binding = options.binding; @@ -823,13 +831,24 @@ export class WaitTask { } if (this._terminated || runCount !== this._runCount) return; try { - success = await context.evaluateHandle( - waitForPredicatePageFunction, - this._predicateBody, - this._polling, - this._timeout, - ...this._args - ); + if (this._root) { + success = await this._root.evaluateHandle( + waitForPredicatePageFunction, + this._predicateBody, + this._polling, + this._timeout, + ...this._args + ); + } else { + success = await context.evaluateHandle( + waitForPredicatePageFunction, + null, + this._predicateBody, + this._polling, + this._timeout, + ...this._args + ); + } } catch (error_) { error = error_; } @@ -890,11 +909,13 @@ export class WaitTask { } async function waitForPredicatePageFunction( + root: Element | Document | null, predicateBody: string, polling: string, timeout: number, ...args: unknown[] ): Promise { + root = root || document; const predicate = new Function('...args', predicateBody); let timedOut = false; if (timeout) setTimeout(() => (timedOut = true), timeout); @@ -906,7 +927,7 @@ async function waitForPredicatePageFunction( * @returns {!Promise<*>} */ async function pollMutation(): Promise { - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) return Promise.resolve(success); let fulfill; @@ -916,13 +937,13 @@ async function waitForPredicatePageFunction( observer.disconnect(); fulfill(); } - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) { observer.disconnect(); fulfill(success); } }); - observer.observe(document, { + observer.observe(root, { childList: true, subtree: true, attributes: true, @@ -941,7 +962,7 @@ async function waitForPredicatePageFunction( fulfill(); return; } - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) fulfill(success); else requestAnimationFrame(onRaf); } @@ -958,7 +979,7 @@ async function waitForPredicatePageFunction( fulfill(); return; } - const success = await predicate(...args); + const success = await predicate(root, ...args); if (success) fulfill(success); else setTimeout(onTimeout, pollInterval); } diff --git a/src/common/JSHandle.ts b/src/common/JSHandle.ts index cc4f2452b6688..43b33235377ab 100644 --- a/src/common/JSHandle.ts +++ b/src/common/JSHandle.ts @@ -351,6 +351,50 @@ export class ElementHandle< this._frameManager = frameManager; } + /** + * Wait for the `selector` to appear within the element. If at the moment of calling the + * method the `selector` already exists, the method will return immediately. If + * the `selector` doesn't appear after the `timeout` milliseconds of waiting, the + * function will throw. + * + * This method does not work across navigations or if the element is detached from DOM. + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * of an element to wait for + * @param options - Optional waiting parameters + * @returns Promise which resolves when element specified by selector string + * is added to DOM. Resolves to `null` if waiting for hidden: `true` and + * selector is not found in DOM. + * @remarks + * The optional parameters in `options` are: + * + * - `visible`: wait for the selected element to be present in DOM and to be + * visible, i.e. to not have `display: none` or `visibility: hidden` CSS + * properties. Defaults to `false`. + * + * - `hidden`: wait for the selected element to not be found in the DOM or to be hidden, + * i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to + * `false`. + * + * - `timeout`: maximum time to wait in milliseconds. Defaults to `30000` + * (30 seconds). Pass `0` to disable timeout. The default value can be changed + * by using the {@link Page.setDefaultTimeout} method. + */ + waitForSelector( + selector: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise { + return this._context._world.waitForSelector(selector, { + ...options, + root: this, + }); + } + asElement(): ElementHandle | null { return this; } diff --git a/test/elementhandle.spec.ts b/test/elementhandle.spec.ts index da5d4aaaad16f..742392d44c043 100644 --- a/test/elementhandle.spec.ts +++ b/test/elementhandle.spec.ts @@ -257,6 +257,29 @@ describe('ElementHandle specs', function () { }); }); + describe('Element.waitForSelector', () => { + it('should wait correctly with waitForSelector on an element', async () => { + const { page } = getTestState(); + const waitFor = page.waitForSelector('.foo'); + // Set the page content after the waitFor has been started. + await page.setContent( + '
bar2
Foo1
' + ); + let element = await waitFor; + expect(element).toBeDefined(); + + const innerWaitFor = element.waitForSelector('.bar'); + await element.evaluate((el) => { + el.innerHTML = '
bar1
'; + }); + element = await innerWaitFor; + expect(element).toBeDefined(); + expect( + await element.evaluate((el: HTMLElement) => el.innerText) + ).toStrictEqual('bar1'); + }); + }); + describe('ElementHandle.hover', function () { it('should work', async () => { const { page, server } = getTestState(); @@ -419,6 +442,33 @@ describe('ElementHandle specs', function () { expect(element).toBeDefined(); }); + it('should wait correctly with waitForSelector on an element', async () => { + const { page, puppeteer } = getTestState(); + puppeteer.registerCustomQueryHandler('getByClass', { + queryOne: (element, selector) => element.querySelector(`.${selector}`), + }); + const waitFor = page.waitForSelector('getByClass/foo'); + + // Set the page content after the waitFor has been started. + await page.setContent( + '
bar2
Foo1
' + ); + let element = await waitFor; + expect(element).toBeDefined(); + + const innerWaitFor = element.waitForSelector('getByClass/bar'); + + await element.evaluate((el) => { + el.innerHTML = '
bar1
'; + }); + + element = await innerWaitFor; + expect(element).toBeDefined(); + expect( + await element.evaluate((el: HTMLElement) => el.innerText) + ).toStrictEqual('bar1'); + }); + it('should wait correctly with waitFor', async () => { /* page.waitFor is deprecated so we silence the warning to avoid test noise */ sinon.stub(console, 'warn').callsFake(() => {});