Skip to content

Commit

Permalink
feat: implement Element.waitForSelector (#7825)
Browse files Browse the repository at this point in the history
Co-authored-by: Johan Bay <jobay@google.com>
Co-authored-by: Mathias Bynens <mathias@qiwi.be>
  • Loading branch information
3 people committed Dec 9, 2021
1 parent a604646 commit c034294
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 15 deletions.
14 changes: 14 additions & 0 deletions docs/api.md
Expand Up @@ -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)
Expand Down Expand Up @@ -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]<?[ElementHandle]>> 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:
Expand Down
51 changes: 36 additions & 15 deletions src/common/DOMWorld.ts
Expand Up @@ -58,6 +58,7 @@ export interface WaitForSelectorOptions {
visible?: boolean;
hidden?: boolean;
timeout?: number;
root?: ElementHandle;
}

/**
Expand Down Expand Up @@ -631,13 +632,14 @@ export class DOMWorld {
waitForHidden ? ' to be hidden' : ''
}`;
async function predicate(
root: Element | Document,
selector: string,
waitForVisible: boolean,
waitForHidden: boolean
): Promise<Node | null | boolean> {
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 = {
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -737,6 +742,7 @@ export interface WaitTaskOptions {
timeout: number;
binding?: PageBinding;
args: SerializableOrJSHandle[];
root?: ElementHandle;
}

/**
Expand All @@ -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))
Expand All @@ -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;
Expand Down Expand Up @@ -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_;
}
Expand Down Expand Up @@ -890,11 +909,13 @@ export class WaitTask {
}

async function waitForPredicatePageFunction(
root: Element | Document | null,
predicateBody: string,
polling: string,
timeout: number,
...args: unknown[]
): Promise<unknown> {
root = root || document;
const predicate = new Function('...args', predicateBody);
let timedOut = false;
if (timeout) setTimeout(() => (timedOut = true), timeout);
Expand All @@ -906,7 +927,7 @@ async function waitForPredicatePageFunction(
* @returns {!Promise<*>}
*/
async function pollMutation(): Promise<unknown> {
const success = await predicate(...args);
const success = await predicate(root, ...args);
if (success) return Promise.resolve(success);

let fulfill;
Expand All @@ -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,
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down
44 changes: 44 additions & 0 deletions src/common/JSHandle.ts
Expand Up @@ -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<ElementHandle | null> {
return this._context._world.waitForSelector(selector, {
...options,
root: this,
});
}

asElement(): ElementHandle<ElementType> | null {
return this;
}
Expand Down
50 changes: 50 additions & 0 deletions test/elementhandle.spec.ts
Expand Up @@ -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(
'<div id="not-foo"></div><div class="bar">bar2</div><div class="foo">Foo1</div>'
);
let element = await waitFor;
expect(element).toBeDefined();

const innerWaitFor = element.waitForSelector('.bar');
await element.evaluate((el) => {
el.innerHTML = '<div class="bar">bar1</div>';
});
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();
Expand Down Expand Up @@ -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(
'<div id="not-foo"></div><div class="bar">bar2</div><div class="foo">Foo1</div>'
);
let element = await waitFor;
expect(element).toBeDefined();

const innerWaitFor = element.waitForSelector('getByClass/bar');

await element.evaluate((el) => {
el.innerHTML = '<div class="bar">bar1</div>';
});

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(() => {});
Expand Down

0 comments on commit c034294

Please sign in to comment.