From 9c5f7e3d84d04e487e48c8efcdb3e642086f1ca7 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 19 Jun 2026 14:54:26 +0100 Subject: [PATCH] feat(locator): introduce Locator.waitForFunction() Waits until a predicate, called with the matching element as its first argument, returns a truthy value. The locator is re-resolved on every poll, so it tolerates the element being re-rendered. Useful for synchronization inside helpers without asserting. Drive-by: fixes wrong type overrides for Locator. Closes https://github.com/microsoft/playwright/issues/41317 --- docs/src/api/class-locator.md | 47 +++++++ packages/playwright-client/types/types.d.ts | 127 +++++------------- .../playwright-core/src/client/channels.d.ts | 6 +- packages/playwright-core/src/client/frame.ts | 2 +- .../playwright-core/src/client/locator.ts | 13 ++ .../playwright-core/src/protocol/validator.ts | 4 +- .../playwright-core/src/server/channels.d.ts | 6 +- .../src/server/dispatchers/frameDispatcher.ts | 7 +- packages/playwright-core/src/server/frames.ts | 39 ++++-- packages/playwright-core/types/types.d.ts | 127 +++++------------- packages/protocol/spec/frame.yml | 6 +- tests/library/inspector/cli-codegen-1.spec.ts | 2 +- tests/page/locator-wait-for-function.spec.ts | 80 +++++++++++ utils/generate_types/overrides.d.ts | 22 +-- 14 files changed, 266 insertions(+), 222 deletions(-) create mode 100644 tests/page/locator-wait-for-function.spec.ts diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 7bbff0ce3298a..a7f1fbd9f1bf7 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -2796,3 +2796,50 @@ orderSent.WaitForAsync(); ### option: Locator.waitFor.timeout = %%-input-timeout-js-%% * since: v1.16 + +## async method: Locator.waitForFunction +* since: v1.62 + +Returns when [`param: expression`] returns a truthy value, called with the matching element as a first argument, and [`param: arg`] as a second argument. + +This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is re-resolved on each retry, so it tolerates the element being re-rendered while waiting. + +If [`param: expression`] returns a [Promise], this method will wait for the promise to resolve before checking its value. + +If [`param: expression`] throws or rejects, this method throws. + +**Usage** + +Wait for an attribute to appear: + +```js +const toggle = page.getByRole('button', { name: 'Menu' }); +await toggle.click(); +await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); +``` + +Passing argument to [`param: expression`]: + +```js +await page.getByTestId('status').waitForFunction((element, value) => { + return element.textContent === value; +}, 'Ready'); +``` + +### param: Locator.waitForFunction.expression = %%-evaluate-expression-%% +* since: v1.62 + +### param: Locator.waitForFunction.expression = %%-js-evaluate-pagefunction-%% +* since: v1.62 + +### param: Locator.waitForFunction.arg +* since: v1.62 +- `arg` ?<[EvaluationArgument]> + +Optional argument to pass to [`param: expression`]. + +### option: Locator.waitForFunction.timeout = %%-wait-for-function-timeout-%% +* since: v1.62 + +### option: Locator.waitForFunction.timeout = %%-wait-for-function-timeout-js-%% +* since: v1.62 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 4f4e40c7cdb18..5f01196f87178 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -12852,78 +12852,7 @@ export interface Locator { * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression). * @param options */ - evaluate(pageFunction: PageFunctionOn, arg: Arg, options?: { - timeout?: number; - }): Promise; - /** - * Execute JavaScript code in the page, taking the matching element as an argument. - * - * **Details** - * - * Returns the return value of - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression), called with the - * matching element as a first argument, and - * [`arg`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-arg) as a second argument. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression) returns a - * [Promise], this method will wait for the promise to resolve and return its value. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression) throws or - * rejects, this method throws. - * - * **Usage** - * - * Passing argument to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression): - * - * ```js - * const result = await page.getByTestId('myId').evaluate((element, [x, y]) => { - * return element.textContent + ' ' + x * y; - * }, [7, 8]); - * console.log(result); // prints "myId text 56" - * ``` - * - * @param pageFunction Function to be evaluated in the page context. - * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression). - * @param options - */ - evaluate(pageFunction: PageFunctionOn, options?: { - timeout?: number; - }): Promise; - /** - * Execute JavaScript code in the page, taking the matching element as an argument, and return a - * [JSHandle](https://playwright.dev/docs/api/class-jshandle) with the result. - * - * **Details** - * - * Returns the return value of - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression) as - * a[JSHandle](https://playwright.dev/docs/api/class-jshandle), called with the matching element as a first argument, - * and [`arg`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-arg) as a second argument. - * - * The only difference between - * [locator.evaluate(pageFunction[, arg, options])](https://playwright.dev/docs/api/class-locator#locator-evaluate) - * and - * [locator.evaluateHandle(pageFunction[, arg, options])](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle) - * is that - * [locator.evaluateHandle(pageFunction[, arg, options])](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle) - * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression) - * returns a [Promise], this method will wait for the promise to resolve and return its value. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression) throws - * or rejects, this method throws. - * - * See [page.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-page#page-evaluate-handle) for - * more details. - * @param pageFunction Function to be evaluated in the page context. - * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression). - * @param options - */ - evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + evaluate(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise; /** * Execute JavaScript code in the page, taking the matching element as an argument, and return a * [JSHandle](https://playwright.dev/docs/api/class-jshandle) with the result. @@ -12956,7 +12885,7 @@ export interface Locator { * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression). * @param options */ - evaluateHandle(pageFunction: PageFunctionOn): Promise>; + evaluateHandle(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise>; /** * Execute JavaScript code in the page, taking all matching elements as an argument. * @@ -12984,35 +12913,47 @@ export interface Locator { * @param arg Optional argument to pass to * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression). */ - evaluateAll(pageFunction: PageFunctionOn, arg: Arg): Promise; + evaluateAll(pageFunction: PageFunctionOn, arg?: Arg): Promise; /** - * Execute JavaScript code in the page, taking all matching elements as an argument. + * Returns when + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) returns + * a truthy value, called with the matching element as a first argument, and + * [`arg`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-arg) as a second argument. * - * **Details** + * This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is + * re-resolved on each retry, so it tolerates the element being re-rendered while waiting. * - * Returns the return value of - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression), called with - * an array of all matching elements as a first argument, and - * [`arg`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-arg) as a second argument. + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * returns a [Promise], this method will wait for the promise to resolve before checking its value. * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression) returns a - * [Promise], this method will wait for the promise to resolve and return its value. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression) throws or - * rejects, this method throws. + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * throws or rejects, this method throws. * * **Usage** * + * Wait for an attribute to appear: + * * ```js - * const locator = page.locator('div'); - * const moreThanTen = await locator.evaluateAll((divs, min) => divs.length > min, 10); + * const toggle = page.getByRole('button', { name: 'Menu' }); + * await toggle.click(); + * await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); + * ``` + * + * Passing argument to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression): + * + * ```js + * await page.getByTestId('status').waitForFunction((element, value) => { + * return element.textContent === value; + * }, 'Ready'); * ``` * * @param pageFunction Function to be evaluated in the page context. * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression). + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression). + * @param options */ - evaluateAll(pageFunction: PageFunctionOn): Promise; + waitForFunction(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise; /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. @@ -13021,17 +12962,13 @@ export interface Locator { * multiple elements match the locator, throws. * @param options */ - elementHandle(options?: { - timeout?: number; - }): Promise>; + elementHandle(options?: { timeout?: number }): Promise>; /** * Highlight the corresponding element(s) on the screen. Useful for debugging, don't commit the code that uses * [locator.highlight([options])](https://playwright.dev/docs/api/class-locator#locator-highlight). * @param options */ - highlight(options?: { - style?: string | { [key: string]: string | number }; - }): Promise; + highlight(options?: { style?: string | { [key: string]: string | number } }): Promise; /** * Returns a human-readable representation of the locator, using the * [locator.description()](https://playwright.dev/docs/api/class-locator#locator-description) if one exists; diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 3387f0c5999f7..ea59dfbb5807a 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -2981,13 +2981,17 @@ export type FrameWaitForFunctionParams = { arg: SerializedArgument, timeout: number, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionOptions = { isFunction?: boolean, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionResult = { - handle: JSHandleChannel, + handle?: JSHandleChannel, }; export type FrameWaitForSelectorParams = { selector: string, diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index c6b9695ff997d..5699713a6be52 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -484,7 +484,7 @@ export class Frame extends ChannelOwner implements api.Fr arg: serializeArgument(arg), timeout: this._timeout(options), }); - return JSHandle.from(result.handle) as any as structs.SmartHandle; + return JSHandle.from(result.handle!) as any as structs.SmartHandle; } async title(): Promise { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 89c892c540b12..297b2e6c5c837 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -20,6 +20,7 @@ import { escapeForTextSelector } from '@isomorphic/stringUtils'; import { isString } from '@isomorphic/rtti'; import { monotonicTime } from '@isomorphic/time'; import { ElementHandle } from './elementHandle'; +import { serializeArgument } from './jsHandle'; import { DisposableStub } from './disposable'; import type { ExpectResult, Frame } from './frame'; @@ -390,6 +391,18 @@ export class Locator implements api.Locator { await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options, timeout: this._frame._timeout(options) }); } + async waitForFunction(pageFunction: structs.PageFunctionOn, arg?: Arg, options?: TimeoutOptions): Promise { + await this._frame._channel.waitForFunction({ + selector: this._selector, + strict: true, + expression: String(pageFunction), + isFunction: typeof pageFunction === 'function', + arg: serializeArgument(arg), + timeout: this._frame._timeout(options), + pollingInterval: 100, + }); + } + async _expect(expression: string, options: FrameExpectParams): Promise { return this._frame._expect(expression, { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index e001312d87adf..5fe1cce5d2052 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1632,9 +1632,11 @@ scheme.FrameWaitForFunctionParams = tObject({ arg: tType('SerializedArgument'), timeout: tFloat, pollingInterval: tOptional(tFloat), + selector: tOptional(tString), + strict: tOptional(tBoolean), }); scheme.FrameWaitForFunctionResult = tObject({ - handle: tChannel(['ElementHandle', 'JSHandle']), + handle: tOptional(tChannel(['ElementHandle', 'JSHandle'])), }); scheme.FrameWaitForSelectorParams = tObject({ selector: tString, diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index e70d98db233b4..e8c7c0ddc8765 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -2984,13 +2984,17 @@ export type FrameWaitForFunctionParams = { arg: SerializedArgument, timeout: number, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionOptions = { isFunction?: boolean, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionResult = { - handle: JSHandleChannel, + handle?: JSHandleChannel, }; export type FrameWaitForSelectorParams = { selector: string, diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index f8b58d74ed22a..9ae0d8ffbebbb 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -255,7 +255,12 @@ export class FrameDispatcher extends Dispatcher { - return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, await this._frame.waitForFunctionExpression(progress, params.expression, params.isFunction, parseArgument(params.arg), params)) }; + const handle = await this._frame.waitForFunctionExpression(progress, params.expression, params.isFunction, parseArgument(params.arg), params); + if (params.selector !== undefined) { + handle.dispose(); + return {}; + } + return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, handle) }; } async title(params: channels.FrameTitleParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 392fe5203f56e..dc8e86ef84607 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1599,30 +1599,49 @@ export class Frame extends SdkObject { return { matches, received }; } - async waitForFunctionExpression(progress: Progress, expression: string, isFunction: boolean | undefined, arg: any, options: { pollingInterval?: number }, world: types.World = 'main'): Promise> { + async waitForFunctionExpression(progress: Progress, expression: string, isFunction: boolean | undefined, arg: any, options: { pollingInterval?: number, selector?: string, strict?: boolean }, world: types.World = 'main'): Promise> { if (typeof options.pollingInterval === 'number') assert(options.pollingInterval > 0, 'Cannot poll with non-positive interval: ' + options.pollingInterval); expression = js.normalizeEvaluationExpression(expression, isFunction); - return this.retryWithProgressAndTimeouts(progress, [100], async () => { - const context = world === 'main' ? await progress.race(this.mainContext()) : await progress.race(this.utilityContext()); - const injectedScript = await progress.race(context.injectedScript()); - const handle = await progress.race(injectedScript.evaluateHandle((injected, { expression, isFunction, polling, arg }) => { + if (options.selector !== undefined) + progress.log(`waiting for ${this._asLocator(options.selector)}`); + return this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { + let injectedScript: js.JSHandle; + let info: SelectorInfo | undefined; + if (options.selector !== undefined) { + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(options.selector, { strict: options.strict, mainWorld: true })); + if (!resolved) + return continuePolling; + injectedScript = resolved.injected; + info = resolved.info; + } else { + const context = world === 'main' ? await progress.race(this.mainContext()) : await progress.race(this.utilityContext()); + injectedScript = await progress.race(context.injectedScript()); + } + const handle = await progress.race(injectedScript.evaluateHandle((injected, { info, expression, isFunction, polling, arg }) => { let evaledExpression: any; const predicate = (): R => { + const args = [arg]; + if (info) { + const element = injected.querySelector(info.parsed, document, info.strict); + if (!element) + return undefined as any; + args.unshift(element); + } // NOTE: make sure to use `globalThis.eval` instead of `self.eval` due to a bug with sandbox isolation // in firefox. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1814898 let result = evaledExpression ?? globalThis.eval(expression); if (isFunction === true) { evaledExpression = result; - result = result(arg); + result = result(...args); } else if (isFunction === false) { result = result; } else { // auto detect. if (typeof result === 'function') { evaledExpression = result; - result = result(arg); + result = result(...args); } } return result; @@ -1653,12 +1672,14 @@ export class Frame extends SdkObject { next(); return { result, abort: () => aborted = true }; - }, { expression, isFunction, polling: options.pollingInterval, arg })); + }, { info, expression, isFunction, polling: options.pollingInterval, arg })); try { return await progress.race(handle.evaluateHandle(h => h.result)); } catch (error) { // Note: it is important to await "abort()" to prevent any side effects - // after this method returns. + // after this method returns. We intentionally do not race against progress + // here - it is already resolved/aborted, and the abort must run to completion. + // eslint-disable-next-line progress/await-must-use-progress await handle.evaluate(h => h.abort()).catch(() => {}); throw error; } finally { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 4f4e40c7cdb18..5f01196f87178 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12852,78 +12852,7 @@ export interface Locator { * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression). * @param options */ - evaluate(pageFunction: PageFunctionOn, arg: Arg, options?: { - timeout?: number; - }): Promise; - /** - * Execute JavaScript code in the page, taking the matching element as an argument. - * - * **Details** - * - * Returns the return value of - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression), called with the - * matching element as a first argument, and - * [`arg`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-arg) as a second argument. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression) returns a - * [Promise], this method will wait for the promise to resolve and return its value. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression) throws or - * rejects, this method throws. - * - * **Usage** - * - * Passing argument to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression): - * - * ```js - * const result = await page.getByTestId('myId').evaluate((element, [x, y]) => { - * return element.textContent + ' ' + x * y; - * }, [7, 8]); - * console.log(result); // prints "myId text 56" - * ``` - * - * @param pageFunction Function to be evaluated in the page context. - * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-option-expression). - * @param options - */ - evaluate(pageFunction: PageFunctionOn, options?: { - timeout?: number; - }): Promise; - /** - * Execute JavaScript code in the page, taking the matching element as an argument, and return a - * [JSHandle](https://playwright.dev/docs/api/class-jshandle) with the result. - * - * **Details** - * - * Returns the return value of - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression) as - * a[JSHandle](https://playwright.dev/docs/api/class-jshandle), called with the matching element as a first argument, - * and [`arg`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-arg) as a second argument. - * - * The only difference between - * [locator.evaluate(pageFunction[, arg, options])](https://playwright.dev/docs/api/class-locator#locator-evaluate) - * and - * [locator.evaluateHandle(pageFunction[, arg, options])](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle) - * is that - * [locator.evaluateHandle(pageFunction[, arg, options])](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle) - * returns [JSHandle](https://playwright.dev/docs/api/class-jshandle). - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression) - * returns a [Promise], this method will wait for the promise to resolve and return its value. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression) throws - * or rejects, this method throws. - * - * See [page.evaluateHandle(pageFunction[, arg])](https://playwright.dev/docs/api/class-page#page-evaluate-handle) for - * more details. - * @param pageFunction Function to be evaluated in the page context. - * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression). - * @param options - */ - evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; + evaluate(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise; /** * Execute JavaScript code in the page, taking the matching element as an argument, and return a * [JSHandle](https://playwright.dev/docs/api/class-jshandle) with the result. @@ -12956,7 +12885,7 @@ export interface Locator { * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-handle-option-expression). * @param options */ - evaluateHandle(pageFunction: PageFunctionOn): Promise>; + evaluateHandle(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise>; /** * Execute JavaScript code in the page, taking all matching elements as an argument. * @@ -12984,35 +12913,47 @@ export interface Locator { * @param arg Optional argument to pass to * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression). */ - evaluateAll(pageFunction: PageFunctionOn, arg: Arg): Promise; + evaluateAll(pageFunction: PageFunctionOn, arg?: Arg): Promise; /** - * Execute JavaScript code in the page, taking all matching elements as an argument. + * Returns when + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) returns + * a truthy value, called with the matching element as a first argument, and + * [`arg`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-arg) as a second argument. * - * **Details** + * This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is + * re-resolved on each retry, so it tolerates the element being re-rendered while waiting. * - * Returns the return value of - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression), called with - * an array of all matching elements as a first argument, and - * [`arg`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-arg) as a second argument. + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * returns a [Promise], this method will wait for the promise to resolve before checking its value. * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression) returns a - * [Promise], this method will wait for the promise to resolve and return its value. - * - * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression) throws or - * rejects, this method throws. + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * throws or rejects, this method throws. * * **Usage** * + * Wait for an attribute to appear: + * * ```js - * const locator = page.locator('div'); - * const moreThanTen = await locator.evaluateAll((divs, min) => divs.length > min, 10); + * const toggle = page.getByRole('button', { name: 'Menu' }); + * await toggle.click(); + * await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); + * ``` + * + * Passing argument to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression): + * + * ```js + * await page.getByTestId('status').waitForFunction((element, value) => { + * return element.textContent === value; + * }, 'Ready'); * ``` * * @param pageFunction Function to be evaluated in the page context. * @param arg Optional argument to pass to - * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression). + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression). + * @param options */ - evaluateAll(pageFunction: PageFunctionOn): Promise; + waitForFunction(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise; /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. @@ -13021,17 +12962,13 @@ export interface Locator { * multiple elements match the locator, throws. * @param options */ - elementHandle(options?: { - timeout?: number; - }): Promise>; + elementHandle(options?: { timeout?: number }): Promise>; /** * Highlight the corresponding element(s) on the screen. Useful for debugging, don't commit the code that uses * [locator.highlight([options])](https://playwright.dev/docs/api/class-locator#locator-highlight). * @param options */ - highlight(options?: { - style?: string | { [key: string]: string | number }; - }): Promise; + highlight(options?: { style?: string | { [key: string]: string | number } }): Promise; /** * Returns a human-readable representation of the locator, using the * [locator.description()](https://playwright.dev/docs/api/class-locator#locator-description) if one exists; diff --git a/packages/protocol/spec/frame.yml b/packages/protocol/spec/frame.yml index eb0917e05e476..2ff4909e77f07 100644 --- a/packages/protocol/spec/frame.yml +++ b/packages/protocol/spec/frame.yml @@ -720,8 +720,12 @@ Frame: timeout: float # When present, polls on interval. Otherwise, polls on raf. pollingInterval: float? + # When present, first argument is the matched element. + selector: string? + strict: boolean? returns: - handle: JSHandle + # Missing when "selector" is present. + handle: JSHandle? flags: snapshot: true pause: true diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index 97929c01ae78e..d793079e48ff9 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -402,7 +402,7 @@ await page.Locator("#input").FillAsync(\"てすと\");`); const { page, recorder } = await openRecorder(); await recorder.setContentAndWait(``); const textarea = page.locator('textarea'); - await textarea.evaluate(e => e.addEventListener('input', () => (window as any).lastInputValue = e.value)); + await textarea.evaluate(e => e.addEventListener('input', () => (window as any).lastInputValue = e.value)); const waitForOutputPromise = recorder.waitForOutput('JavaScript', 'Hello\\n'); await textarea.type('Hello\n'); // Issue was that the input event was not fired for the last newline, so we check for that. diff --git a/tests/page/locator-wait-for-function.spec.ts b/tests/page/locator-wait-for-function.spec.ts new file mode 100644 index 0000000000000..adce6bb11ab37 --- /dev/null +++ b/tests/page/locator-wait-for-function.spec.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test as it, expect } from './pageTest'; + +it('should wait for an attribute to appear', async ({ page }) => { + await page.setContent(''); + await page.evaluate(() => window.builtins.setTimeout(() => document.querySelector('#toggle')!.setAttribute('aria-expanded', 'true'), 1000)); + await page.locator('#toggle').waitForFunction(element => element.hasAttribute('aria-expanded')); +}); + +it('should return immediately when already truthy', async ({ page }) => { + await page.setContent('
yes
'); + expect(await page.locator('#target').waitForFunction(element => element.textContent === 'yes')).toBe(undefined); +}); + +it('should accept ElementHandle arguments', async ({ page }) => { + await page.setContent('
value
'); + const handle = await page.$('#b'); + await page.locator('#a').waitForFunction((element, other) => other.textContent === 'value', handle); +}); + +it('should accept string expression', async ({ page }) => { + await page.setContent('
yes
'); + await page.locator('#target').waitForFunction(`element => element.textContent === 'yes'`); +}); + +it('should resolve a promise returned by the predicate', async ({ page }) => { + await page.setContent('
yes
'); + await page.locator('#target').waitForFunction(async element => element.textContent === 'yes'); +}); + +it('should wait for element to appear and survive rerender', async ({ page }) => { + await page.setContent('nothing here'); + await page.evaluate(() => { + let count = 0; + let prev: Element | null = null; + const tick = () => { + ++count; + const next = document.createElement('div'); + next.id = 'target'; + next.textContent = String(count); + if (prev) + prev.remove(); + document.body.appendChild(next); + prev = next; + if (count < 3) + window.builtins.setTimeout(tick, 500); + }; + window.builtins.setTimeout(tick, 500); + }); + await page.locator('#target').waitForFunction(element => element.textContent === '3'); +}); + +it('should throw when predicate throws', async ({ page }) => { + await page.setContent('
no
'); + const error = await page.locator('#target').waitForFunction(() => { + throw new Error('oh my'); + }).catch(e => e); + expect(error.message).toContain('oh my'); +}); + +it('should throw on strict mode violation', async ({ page }) => { + await page.setContent('
1
2
'); + const error = await page.locator('div.x').waitForFunction(() => true).catch(e => e); + expect(error.message).toContain('strict mode violation'); +}); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index e2ec1db55e21d..ccfd73db92dd5 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -190,22 +190,12 @@ export interface ElementHandle extends JSHandle { } export interface Locator { - evaluate(pageFunction: PageFunctionOn, arg: Arg, options?: { - timeout?: number; - }): Promise; - evaluate(pageFunction: PageFunctionOn, options?: { - timeout?: number; - }): Promise; - evaluateHandle(pageFunction: PageFunctionOn, arg: Arg): Promise>; - evaluateHandle(pageFunction: PageFunctionOn): Promise>; - evaluateAll(pageFunction: PageFunctionOn, arg: Arg): Promise; - evaluateAll(pageFunction: PageFunctionOn): Promise; - elementHandle(options?: { - timeout?: number; - }): Promise>; - highlight(options?: { - style?: string | { [key: string]: string | number }; - }): Promise; + evaluate(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise; + evaluateHandle(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise>; + evaluateAll(pageFunction: PageFunctionOn, arg?: Arg): Promise; + waitForFunction(pageFunction: PageFunctionOn, arg?: Arg, options?: { timeout?: number }): Promise; + elementHandle(options?: { timeout?: number }): Promise>; + highlight(options?: { style?: string | { [key: string]: string | number } }): Promise; toString(): string; }