From 2646731f70c484bd9a9378be1b2fc79bbdd36ffa Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Fri, 17 Jul 2026 14:13:09 -0600 Subject: [PATCH] feat(page): support a callback in `addInitScript` add an optional `callback` to `page.addInitScript` and `browserContext.addInitScript` (js only) so the injected script can pass data back to the runner a function script receives the callback as an argument (after `arg`, or as its only argument when `arg` is omitted), and a string or file script can call it as `callback` the in-page callback returns a `Promise` resolving to whatever `callback` returns, runs on every navigation and in child frames, and for a `browserContext` covers all current and future pages the returned `Disposable` removes the init script, the binding, and the client binding entry the client composes the init script source and registers `callback` through the existing evaluate binding machinery --- docs/src/api/class-browsercontext.md | 24 ++ docs/src/api/class-page.md | 23 ++ packages/playwright-client/types/types.d.ts | 313 +++++++++++++++++- .../src/client/browserContext.ts | 19 +- .../src/client/clientHelper.ts | 65 +++- packages/playwright-core/src/client/page.ts | 19 +- packages/playwright-core/types/types.d.ts | 313 +++++++++++++++++- .../browsercontext-add-init-script.spec.ts | 57 ++++ tests/page/page-add-init-script.spec.ts | 124 +++++++ utils/generate_types/overrides.d.ts | 6 +- utils/generate_types/test/test.ts | 22 ++ 11 files changed, 953 insertions(+), 32 deletions(-) diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index a5679cf5a96a3..c3c0f928e0247 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -433,6 +433,16 @@ browser_context.add_init_script(path="preload.js") await Context.AddInitScriptAsync(scriptPath: "preload.js"); ``` +```js +// Pass a Node.js callback that any page in the context can call to report data back. +const loaded = []; +await browserContext.addInitScript(report => { + report(location.pathname); +}, message => { + loaded.push(message); +}); +``` + :::note The order of evaluation of multiple scripts installed via [`method: BrowserContext.addInitScript`] and [`method: Page.addInitScript`] is not defined. @@ -462,6 +472,20 @@ Script to be evaluated in all pages in the browser context. Optional argument to pass to [`param: script`] (only supported when passing a function). +### param: BrowserContext.addInitScript.callback +* since: v1.62 +* langs: js +- `callback` ?<[function]> + +Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is +called. When [`param: script`] is a function it receives the callback as an argument, after +[`param: arg`] or as its only argument when [`param: arg`] is omitted. When [`param: script`] is a +string or file, the callback is available as a `callback` variable. The in-page callback returns a +[Promise] that resolves to the value returned by [`param: callback`]. The same callback is shared by +all current and future pages in the context and keeps working across navigations until the returned +[Disposable] is disposed. Since the init script's return value is not awaited, this is best suited for +reporting data back to the runner rather than blocking page scripts. + ### param: BrowserContext.addInitScript.path * since: v1.8 * langs: python diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 04d0932668163..99e7d8026511a 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -589,6 +589,16 @@ await page.addInitScript(mock => { }, mock); ``` +```js +// Pass a Node.js callback that the page can call to report data back on every navigation. +const loaded = []; +await page.addInitScript(report => { + report(location.pathname); +}, message => { + loaded.push(message); +}); +``` + ```java // In your playwright script, assuming the preload.js file is in same directory page.addInitScript(Paths.get("./preload.js")); @@ -637,6 +647,19 @@ Script to be evaluated in all pages in the browser context. Optional argument to pass to [`param: script`] (only supported when passing a function). +### param: Page.addInitScript.callback +* since: v1.62 +* langs: js +- `callback` ?<[function]> + +Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is +called. When [`param: script`] is a function it receives the callback as an argument, after +[`param: arg`] or as its only argument when [`param: arg`] is omitted. When [`param: script`] is a +string or file, the callback is available as a `callback` variable. The in-page callback returns a +[Promise] that resolves to the value returned by [`param: callback`], and it keeps working across +navigations until the returned [Disposable] is disposed. Since the init script's return value is not +awaited, this is best suited for reporting data back to the runner rather than blocking page scripts. + ### param: Page.addInitScript.path * since: v1.8 * langs: python diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 0b3086a126811..2cc9a102d410f 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -18,7 +18,7 @@ import { ChildProcess } from 'child_process'; import { Readable } from 'stream'; import { ReadStream } from 'fs'; import { Protocol } from './protocol'; -import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from './structs'; +import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource, Unboxed } from './structs'; // Use the global URLPattern type if available (Node.js 22+, modern browsers), // otherwise fall back to `never` so it disappears from union types. @@ -313,15 +313,158 @@ export interface Page { * }, mock); * ``` * + * ```js + * // Pass a Node.js callback that the page can call to report data back on every navigation. + * const loaded = []; + * await page.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in the page. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) (only supported when + * passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a function it receives + * the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) or as its only argument when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is omitted. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a string or file, the + * callback is available as a `callback` variable. The in-page callback returns a [Promise] that resolves to the value + * returned by [`callback`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-callback), and it + * keeps working across navigations until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) + * is disposed. Since the init script's return value is not awaited, this is best suited for reporting data back to + * the runner rather than blocking page scripts. + */ + addInitScript(script: (arg: Unboxed, callback: (...args: any[]) => Promise) => any, arg: Arg, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever the page is navigated. + * - Whenever the child frame is attached or navigated. In this case, the script is evaluated in the context of the + * newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory + * await page.addInitScript({ path: './preload.js' }); + * ``` + * + * ```js + * await page.addInitScript(mock => { + * window.mock = mock; + * }, mock); + * ``` + * + * ```js + * // Pass a Node.js callback that the page can call to report data back on every navigation. + * const loaded = []; + * await page.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in the page. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) (only supported when + * passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a function it receives + * the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) or as its only argument when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is omitted. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a string or file, the + * callback is available as a `callback` variable. The in-page callback returns a [Promise] that resolves to the value + * returned by [`callback`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-callback), and it + * keeps working across navigations until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) + * is disposed. Since the init script's return value is not awaited, this is best suited for reporting data back to + * the runner rather than blocking page scripts. + */ + addInitScript(script: ((callback: (...args: any[]) => Promise) => any) | string | { path?: string, content?: string }, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever the page is navigated. + * - Whenever the child frame is attached or navigated. In this case, the script is evaluated in the context of the + * newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory + * await page.addInitScript({ path: './preload.js' }); + * ``` + * + * ```js + * await page.addInitScript(mock => { + * window.mock = mock; + * }, mock); + * ``` + * + * ```js + * // Pass a Node.js callback that the page can call to report data back on every navigation. + * const loaded = []; + * await page.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * * **NOTE** The order of evaluation of multiple scripts installed via - * [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) - * and [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) is not - * defined. + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. * * @param script Script to be evaluated in the page. * @param arg Optional argument to pass to * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) (only supported when * passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a function it receives + * the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) or as its only argument when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is omitted. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a string or file, the + * callback is available as a `callback` variable. The in-page callback returns a [Promise] that resolves to the value + * returned by [`callback`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-callback), and it + * keeps working across navigations until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) + * is disposed. Since the init script's return value is not awaited, this is best suited for reporting data back to + * the runner rather than blocking page scripts. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; @@ -9075,15 +9218,162 @@ export interface BrowserContext { * }); * ``` * + * ```js + * // Pass a Node.js callback that any page in the context can call to report data back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in all pages in the browser context. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) + * (only supported when passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * function it receives the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) or as its + * only argument when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is + * omitted. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * string or file, the callback is available as a `callback` variable. The in-page callback returns a [Promise] that + * resolves to the value returned by + * [`callback`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-callback). + * The same callback is shared by all current and future pages in the context and keeps working across navigations + * until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. Since the init + * script's return value is not awaited, this is best suited for reporting data back to the runner rather than + * blocking page scripts. + */ + addInitScript(script: (arg: Unboxed, callback: (...args: any[]) => Promise) => any, arg: Arg, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever a page is created in the browser context or is navigated. + * - Whenever a child frame is attached or navigated in any page in the browser context. In this case, the script is + * evaluated in the context of the newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory. + * await browserContext.addInitScript({ + * path: 'preload.js' + * }); + * ``` + * + * ```js + * // Pass a Node.js callback that any page in the context can call to report data back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in all pages in the browser context. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) + * (only supported when passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * function it receives the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) or as its + * only argument when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is + * omitted. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * string or file, the callback is available as a `callback` variable. The in-page callback returns a [Promise] that + * resolves to the value returned by + * [`callback`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-callback). + * The same callback is shared by all current and future pages in the context and keeps working across navigations + * until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. Since the init + * script's return value is not awaited, this is best suited for reporting data back to the runner rather than + * blocking page scripts. + */ + addInitScript(script: ((callback: (...args: any[]) => Promise) => any) | string | { path?: string, content?: string }, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever a page is created in the browser context or is navigated. + * - Whenever a child frame is attached or navigated in any page in the browser context. In this case, the script is + * evaluated in the context of the newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory. + * await browserContext.addInitScript({ + * path: 'preload.js' + * }); + * ``` + * + * ```js + * // Pass a Node.js callback that any page in the context can call to report data back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * * **NOTE** The order of evaluation of multiple scripts installed via - * [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) - * and [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) is not - * defined. + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. * * @param script Script to be evaluated in all pages in the browser context. * @param arg Optional argument to pass to * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) * (only supported when passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * function it receives the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) or as its + * only argument when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is + * omitted. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * string or file, the callback is available as a `callback` variable. The in-page callback returns a [Promise] that + * resolves to the value returned by + * [`callback`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-callback). + * The same callback is shared by all current and future pages in the context and keeps working across navigations + * until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. Since the init + * script's return value is not awaited, this is best suited for reporting data back to the runner rather than + * blocking page scripts. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; @@ -20781,14 +21071,15 @@ export interface Dialog { /** * [Disposable](https://playwright.dev/docs/api/class-disposable) is returned from various methods to allow undoing * the corresponding action. For example, - * [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) returns a - * [Disposable](https://playwright.dev/docs/api/class-disposable) that can be used to remove the init script. + * [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * returns a [Disposable](https://playwright.dev/docs/api/class-disposable) that can be used to remove the init + * script. */ export interface Disposable { /** * Removes the associated resource. For example, removes the init script installed via - * [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) or - * [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script). + * [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) or + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script). */ dispose(): Promise; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 82445b69f0941..6e9cd1f3aad0b 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -25,7 +25,7 @@ import { rewriteErrorMessage } from '@utils/stackTrace'; import { Browser } from './browser'; import { CDPSession } from './cdpSession'; import { ChannelOwner } from './channelOwner'; -import { evaluationScript } from './clientHelper'; +import { addInitScriptWithCallback, evaluationScript } from './clientHelper'; import { Clock } from './clock'; import { ConsoleMessage } from './consoleMessage'; import { Credentials } from './credentials'; @@ -358,7 +358,22 @@ export class BrowserContext extends ChannelOwner await this._channel.setHTTPCredentials({ httpCredentials: httpCredentials || undefined }, kNoTimeout); } - async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { + async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any, callback?: (...args: any[]) => any) { + // A function in place of `arg` is unambiguously the `callback`, since `arg` must be serializable. + const hasArg = callback !== undefined; + if (callback === undefined && typeof arg === 'function') { + callback = arg; + arg = undefined; + } + if (callback !== undefined) { + if (typeof callback !== 'function') + throw new Error(`callback: expected function, got ${typeof callback}`); + return await addInitScriptWithCallback( + this._bindings, + async params => (await this._channel.exposeBinding(params, kNoTimeout)).disposable, + async source => (await this._channel.addInitScript({ source }, kNoTimeout)).disposable, + script, arg, hasArg, callback); + } const source = await evaluationScript(script, arg); return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable); } diff --git a/packages/playwright-core/src/client/clientHelper.ts b/packages/playwright-core/src/client/clientHelper.ts index f8d055883f9a4..ef0a8bcd609ab 100644 --- a/packages/playwright-core/src/client/clientHelper.ts +++ b/packages/playwright-core/src/client/clientHelper.ts @@ -18,6 +18,12 @@ import fs from 'fs'; import { isString } from '@isomorphic/rtti'; +import { kBindingsControllerProperty, kFunctionBindingPrefix } from '@isomorphic/utilityScriptSerializers'; +import { createGuid } from '@utils/crypto'; +import { DisposableObject, DisposableStub } from './disposable'; + +import type * as channels from './channels'; +import type * as structs from '../../types/structs'; export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string, value: string }[] { const result: { name: string, value: string }[] = []; @@ -28,12 +34,13 @@ export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string, value: return result; } +function serializeArgument(arg: any): string { + return Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg); +} + export async function evaluationScript(fun: Function | string | { path?: string, content?: string }, arg?: any, addSourceUrl: boolean = true): Promise { - if (typeof fun === 'function') { - const source = fun.toString(); - const argString = Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg); - return `(${source})(${argString})`; - } + if (typeof fun === 'function') + return `(${fun.toString()})(${serializeArgument(arg)})`; if (arg !== undefined) throw new Error('Cannot evaluate a string with arguments'); if (isString(fun)) @@ -52,3 +59,51 @@ export async function evaluationScript(fun: Function | string | { path?: string, export function addSourceUrlToScript(source: string, path: string): string { return `${source}\n//# sourceURL=${path.replace(/\n/g, '')}`; } + +// Installs an init script that can invoke an in-page `callback` bridged to the Node-side `callback` +// via the existing binding machinery (see `serializeArgumentWithCallbacks`). A function script gets +// the callback as an argument, and a string or file script can call it as `callback`. The returned +// disposable removes the init script, the binding, and the client-side binding entry. +export async function addInitScriptWithCallback( + bindings: Map any>, + exposeBinding: (params: { name: string }) => Promise, + addInitScript: (source: string) => Promise, + script: Function | string | { path?: string, content?: string }, + arg: any, + hasArg: boolean, + callback: (...args: any[]) => any, +): Promise { + // Build the source before touching any state so a throw does not leak a registered binding. + const name = kFunctionBindingPrefix + createGuid(); + const callbackExpression = `(...args) => globalThis[${JSON.stringify(kBindingsControllerProperty)}].callBinding(${JSON.stringify(name)}, ...args)`; + let source: string; + if (typeof script === 'function') + source = hasArg ? `(${script.toString()})(${serializeArgument(arg)}, ${callbackExpression})` : `(${script.toString()})(${callbackExpression})`; + else + source = `(callback => {\n${await evaluationScript(script, arg)}\n})(${callbackExpression})`; + + bindings.set(name, (_source, ...args) => callback(...args)); + let bindingDisposable: DisposableObject | undefined; + let scriptDisposable: DisposableObject | undefined; + try { + bindingDisposable = DisposableObject.from(await exposeBinding({ name })); + scriptDisposable = DisposableObject.from(await addInitScript(source)); + } catch (error) { + await scriptDisposable?.dispose().catch(() => {}); + await bindingDisposable?.dispose().catch(() => {}); + bindings.delete(name); + throw error; + } + + return new DisposableStub(async () => { + try { + await scriptDisposable!.dispose(); + } finally { + try { + await bindingDisposable!.dispose(); + } finally { + bindings.delete(name); + } + } + }); +} diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 90a199a17f76d..117747598eecc 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -28,7 +28,7 @@ import { LongStandingScope } from '@isomorphic/manualPromise'; import { isObject, isRegExp, isString } from '@isomorphic/rtti'; import { Artifact } from './artifact'; import { ChannelOwner } from './channelOwner'; -import { evaluationScript } from './clientHelper'; +import { addInitScriptWithCallback, evaluationScript } from './clientHelper'; import { Coverage } from './coverage'; import { DisposableObject, DisposableStub } from './disposable'; import { Download } from './download'; @@ -549,7 +549,22 @@ export class Page extends ChannelOwner implements api.Page return await this._mainFrame.evaluate(pageFunction, arg, options); } - async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { + async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any, callback?: (...args: any[]) => any) { + // A function in place of `arg` is unambiguously the `callback`, since `arg` must be serializable. + const hasArg = callback !== undefined; + if (callback === undefined && typeof arg === 'function') { + callback = arg; + arg = undefined; + } + if (callback !== undefined) { + if (typeof callback !== 'function') + throw new Error(`callback: expected function, got ${typeof callback}`); + return await addInitScriptWithCallback( + this._bindings, + async params => (await this._channel.exposeBinding(params, kNoTimeout)).disposable, + async source => (await this._channel.addInitScript({ source }, kNoTimeout)).disposable, + script, arg, hasArg, callback); + } const source = await evaluationScript(script, arg); return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 0b3086a126811..2cc9a102d410f 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -18,7 +18,7 @@ import { ChildProcess } from 'child_process'; import { Readable } from 'stream'; import { ReadStream } from 'fs'; import { Protocol } from './protocol'; -import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from './structs'; +import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource, Unboxed } from './structs'; // Use the global URLPattern type if available (Node.js 22+, modern browsers), // otherwise fall back to `never` so it disappears from union types. @@ -313,15 +313,158 @@ export interface Page { * }, mock); * ``` * + * ```js + * // Pass a Node.js callback that the page can call to report data back on every navigation. + * const loaded = []; + * await page.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in the page. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) (only supported when + * passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a function it receives + * the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) or as its only argument when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is omitted. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a string or file, the + * callback is available as a `callback` variable. The in-page callback returns a [Promise] that resolves to the value + * returned by [`callback`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-callback), and it + * keeps working across navigations until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) + * is disposed. Since the init script's return value is not awaited, this is best suited for reporting data back to + * the runner rather than blocking page scripts. + */ + addInitScript(script: (arg: Unboxed, callback: (...args: any[]) => Promise) => any, arg: Arg, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever the page is navigated. + * - Whenever the child frame is attached or navigated. In this case, the script is evaluated in the context of the + * newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory + * await page.addInitScript({ path: './preload.js' }); + * ``` + * + * ```js + * await page.addInitScript(mock => { + * window.mock = mock; + * }, mock); + * ``` + * + * ```js + * // Pass a Node.js callback that the page can call to report data back on every navigation. + * const loaded = []; + * await page.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in the page. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) (only supported when + * passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a function it receives + * the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) or as its only argument when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is omitted. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a string or file, the + * callback is available as a `callback` variable. The in-page callback returns a [Promise] that resolves to the value + * returned by [`callback`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-callback), and it + * keeps working across navigations until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) + * is disposed. Since the init script's return value is not awaited, this is best suited for reporting data back to + * the runner rather than blocking page scripts. + */ + addInitScript(script: ((callback: (...args: any[]) => Promise) => any) | string | { path?: string, content?: string }, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever the page is navigated. + * - Whenever the child frame is attached or navigated. In this case, the script is evaluated in the context of the + * newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory + * await page.addInitScript({ path: './preload.js' }); + * ``` + * + * ```js + * await page.addInitScript(mock => { + * window.mock = mock; + * }, mock); + * ``` + * + * ```js + * // Pass a Node.js callback that the page can call to report data back on every navigation. + * const loaded = []; + * await page.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * * **NOTE** The order of evaluation of multiple scripts installed via - * [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) - * and [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) is not - * defined. + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. * * @param script Script to be evaluated in the page. * @param arg Optional argument to pass to * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) (only supported when * passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a function it receives + * the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) or as its only argument when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is omitted. When + * [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) is a string or file, the + * callback is available as a `callback` variable. The in-page callback returns a [Promise] that resolves to the value + * returned by [`callback`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-callback), and it + * keeps working across navigations until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) + * is disposed. Since the init script's return value is not awaited, this is best suited for reporting data back to + * the runner rather than blocking page scripts. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; @@ -9075,15 +9218,162 @@ export interface BrowserContext { * }); * ``` * + * ```js + * // Pass a Node.js callback that any page in the context can call to report data back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in all pages in the browser context. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) + * (only supported when passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * function it receives the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) or as its + * only argument when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is + * omitted. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * string or file, the callback is available as a `callback` variable. The in-page callback returns a [Promise] that + * resolves to the value returned by + * [`callback`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-callback). + * The same callback is shared by all current and future pages in the context and keeps working across navigations + * until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. Since the init + * script's return value is not awaited, this is best suited for reporting data back to the runner rather than + * blocking page scripts. + */ + addInitScript(script: (arg: Unboxed, callback: (...args: any[]) => Promise) => any, arg: Arg, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever a page is created in the browser context or is navigated. + * - Whenever a child frame is attached or navigated in any page in the browser context. In this case, the script is + * evaluated in the context of the newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory. + * await browserContext.addInitScript({ + * path: 'preload.js' + * }); + * ``` + * + * ```js + * // Pass a Node.js callback that any page in the context can call to report data back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * + * **NOTE** The order of evaluation of multiple scripts installed via + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. + * + * @param script Script to be evaluated in all pages in the browser context. + * @param arg Optional argument to pass to + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) + * (only supported when passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * function it receives the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) or as its + * only argument when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is + * omitted. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * string or file, the callback is available as a `callback` variable. The in-page callback returns a [Promise] that + * resolves to the value returned by + * [`callback`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-callback). + * The same callback is shared by all current and future pages in the context and keeps working across navigations + * until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. Since the init + * script's return value is not awaited, this is best suited for reporting data back to the runner rather than + * blocking page scripts. + */ + addInitScript(script: ((callback: (...args: any[]) => Promise) => any) | string | { path?: string, content?: string }, callback: (...args: any[]) => R | Promise): Promise; + /** + * Adds a script which would be evaluated in one of the following scenarios: + * - Whenever a page is created in the browser context or is navigated. + * - Whenever a child frame is attached or navigated in any page in the browser context. In this case, the script is + * evaluated in the context of the newly attached frame. + * + * The script is evaluated after the document was created but before any of its scripts were run. This is useful to + * amend the JavaScript environment, e.g. to seed `Math.random`. + * + * **Usage** + * + * An example of overriding `Math.random` before the page loads: + * + * ```js + * // preload.js + * Math.random = () => 42; + * ``` + * + * ```js + * // In your playwright script, assuming the preload.js file is in same directory. + * await browserContext.addInitScript({ + * path: 'preload.js' + * }); + * ``` + * + * ```js + * // Pass a Node.js callback that any page in the context can call to report data back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * report(location.pathname); + * }, message => { + * loaded.push(message); + * }); + * ``` + * * **NOTE** The order of evaluation of multiple scripts installed via - * [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) - * and [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) is not - * defined. + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * is not defined. * * @param script Script to be evaluated in all pages in the browser context. * @param arg Optional argument to pass to * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) * (only supported when passing a function). + * @param callback Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is called. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * function it receives the callback as an argument, after + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) or as its + * only argument when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is + * omitted. When + * [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script) is a + * string or file, the callback is available as a `callback` variable. The in-page callback returns a [Promise] that + * resolves to the value returned by + * [`callback`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-callback). + * The same callback is shared by all current and future pages in the context and keeps working across navigations + * until the returned [Disposable](https://playwright.dev/docs/api/class-disposable) is disposed. Since the init + * script's return value is not awaited, this is best suited for reporting data back to the runner rather than + * blocking page scripts. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; @@ -20781,14 +21071,15 @@ export interface Dialog { /** * [Disposable](https://playwright.dev/docs/api/class-disposable) is returned from various methods to allow undoing * the corresponding action. For example, - * [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) returns a - * [Disposable](https://playwright.dev/docs/api/class-disposable) that can be used to remove the init script. + * [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) + * returns a [Disposable](https://playwright.dev/docs/api/class-disposable) that can be used to remove the init + * script. */ export interface Disposable { /** * Removes the associated resource. For example, removes the init script installed via - * [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) or - * [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script). + * [page.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-page#page-add-init-script) or + * [browserContext.addInitScript(script[, arg, callback])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script). */ dispose(): Promise; diff --git a/tests/library/browsercontext-add-init-script.spec.ts b/tests/library/browsercontext-add-init-script.spec.ts index c2fd6cf83840f..a54e7bfcc848e 100644 --- a/tests/library/browsercontext-add-init-script.spec.ts +++ b/tests/library/browsercontext-add-init-script.spec.ts @@ -97,3 +97,60 @@ it('init script should run only once in popup', async ({ context }) => { ]); expect(await popup.evaluate('callCount')).toEqual(1); }); + +it('should report data from all pages to a single node callback', async ({ context, server }) => { + const reports = []; + const page1 = await context.newPage(); + await context.addInitScript((arg, report) => { + void report(location.pathname); + }, undefined, (pathname: string) => { + reports.push(pathname); + }); + // `page1` existed before the call and `page2` is created after it. + await page1.goto(server.EMPTY_PAGE); + const page2 = await context.newPage(); + await page2.goto(server.PREFIX + '/grid.html'); + // New pages also run the init script on their initial `about:blank` document, so ignore those. + await expect.poll(() => [...new Set(reports.filter(p => p.startsWith('/')))].sort()).toEqual(['/empty.html', '/grid.html']); +}); + +it('context in-page callback should resolve to the node callback return value', async ({ context, server }) => { + await context.addInitScript(async (arg, report) => { + (window as any)['__reported'] = await report(20); + }, undefined, (value: number) => value + 1); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any)['__reported'])).toBe(21); +}); + +it('should stop reporting after the context callback disposable is disposed', async ({ context, server }) => { + const reports = []; + const handle = await context.addInitScript((arg, report) => { + (window as any)['__report'] = report; + void report(location.pathname); + }, undefined, (pathname: string) => { + reports.push(pathname); + }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => reports).toContain('/empty.html'); + + await handle.dispose(); + + const page2 = await context.newPage(); + await page2.goto(server.PREFIX + '/grid.html'); + expect(await page2.evaluate(() => typeof (window as any)['__report'])).toBe('undefined'); + expect(reports).not.toContain('/grid.html'); +}); + +it('context should accept a callback without an arg', async ({ context, server }) => { + const reports = []; + await context.addInitScript(report => { + void report(location.pathname); + }, (message: string) => { + reports.push(message); + }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => reports).toContain('/empty.html'); +}); diff --git a/tests/page/page-add-init-script.spec.ts b/tests/page/page-add-init-script.spec.ts index 5e569f922f80f..3b6101606df8e 100644 --- a/tests/page/page-add-init-script.spec.ts +++ b/tests/page/page-add-init-script.spec.ts @@ -16,6 +16,7 @@ */ import { test as it, expect } from './pageTest'; +import { attachFrame } from '../config/utils'; it('should evaluate before anything else on the page', async ({ page, server }) => { await page.addInitScript(function() { @@ -127,3 +128,126 @@ it('init script should run only once in iframe', async ({ page, server, browserN 'init script: ' + (browserName === 'firefox' && !isBidi ? 'no url yet' : '/frames/frame.html'), ]); }); + +it('should report data back to a node callback on every navigation', async ({ page, server }) => { + const reports = []; + await page.addInitScript((arg, report) => { + void report(`${location.pathname}:${arg.tag}`); + }, { tag: 'x' }, (message: string) => { + reports.push(message); + }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => reports).toContain('/empty.html:x'); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await expect.poll(() => reports).toContain('/grid.html:x'); +}); + +it('should accept a callback without an arg', async ({ page, server }) => { + const reports = []; + await page.addInitScript(report => { + void report(location.pathname); + }, (message: string) => { + reports.push(message); + }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => reports).toContain('/empty.html'); +}); + +it('should support a callback with a string script', async ({ page, server }) => { + const reports = []; + await page.addInitScript(`callback(location.pathname)`, (pathname: string) => { + reports.push(pathname); + }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => reports).toContain('/empty.html'); +}); + +it('callback should work from a child frame', async ({ page, server }) => { + const reports = []; + await page.addInitScript((arg, report) => { + void report(window === window.top ? 'top' : 'child'); + }, undefined, (where: string) => { + reports.push(where); + }); + await page.goto(server.EMPTY_PAGE); + await attachFrame(page, 'frame1', server.EMPTY_PAGE); + await expect.poll(() => [...new Set(reports)].sort()).toEqual(['child', 'top']); +}); + +it('in-page callback should resolve to the node callback return value', async ({ page, server }) => { + await page.addInitScript(async (arg, report) => { + (window as any)['__reported'] = await report(21); + }, undefined, async (value: number) => value * 2); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any)['__reported'])).toBe(42); +}); + +it('should clean up the binding on dispose', async ({ page, server }) => { + const bindingsBefore = (page as any)._bindings.size; + const handle = await page.addInitScript((arg, report) => { + (window as any)['__report'] = report; + }, undefined, () => {}); + await page.goto(server.EMPTY_PAGE); + expect((page as any)._bindings.size).toBe(bindingsBefore + 1); + + await handle.dispose(); + expect((page as any)._bindings.size).toBe(bindingsBefore); +}); + +it('should stop reporting after the callback disposable is disposed', async ({ page, server }) => { + const reports = []; + const handle = await page.addInitScript((arg, report) => { + (window as any)['__report'] = report; + void report(location.pathname); + }, undefined, (pathname: string) => { + reports.push(pathname); + }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => reports).toContain('/empty.html'); + + await handle.dispose(); + + await page.goto(server.PREFIX + '/grid.html'); + expect(await page.evaluate(() => typeof (window as any)['__report'])).toBe('undefined'); + expect(reports).not.toContain('/grid.html'); +}); + +it('in-page callback should fail after dispose', async ({ page, server }) => { + const handle = await page.addInitScript((arg, report) => { + (window as any)['__report'] = report; + }, undefined, () => {}); + await page.goto(server.EMPTY_PAGE); + await handle.dispose(); + const error = await page.evaluate(async () => { + try { + await (window as any)['__report']('late'); + return 'no error'; + } catch (e) { + return String(e.message || e); + } + }); + expect(error).toContain('has been removed'); +}); + +it('should support multiple independent callbacks disposed independently', async ({ page, server }) => { + const a = []; + const b = []; + const handleA = await page.addInitScript((arg, report) => { + void report('a:' + location.pathname); + }, undefined, (message: string) => { + a.push(message); + }); + await page.addInitScript((arg, report) => { + void report('b:' + location.pathname); + }, undefined, (message: string) => { + b.push(message); + }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => a).toContain('a:/empty.html'); + await expect.poll(() => b).toContain('b:/empty.html'); + + await handleA.dispose(); + await page.goto(server.PREFIX + '/grid.html'); + await expect.poll(() => b).toContain('b:/grid.html'); + expect(a).not.toContain('a:/grid.html'); +}); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index 81ac22c943262..f01a908a8a33d 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -17,7 +17,7 @@ import { ChildProcess } from 'child_process'; import { Readable } from 'stream'; import { ReadStream } from 'fs'; import { Protocol } from './protocol'; -import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource } from './structs'; +import { Serializable, EvaluationArgument, PageFunction, PageFunctionOn, SmartHandle, ElementHandleForTag, BindingSource, Unboxed } from './structs'; // Use the global URLPattern type if available (Node.js 22+, modern browsers), // otherwise fall back to `never` so it disappears from union types. @@ -44,6 +44,8 @@ export interface Page { evaluateHandle(pageFunction: PageFunction, arg: Arg, options?: { exposeFunctions?: boolean }): Promise>; evaluateHandle(pageFunction: PageFunction, arg?: any, options?: { exposeFunctions?: boolean }): Promise>; + addInitScript(script: (arg: Unboxed, callback: (...args: any[]) => Promise) => any, arg: Arg, callback: (...args: any[]) => R | Promise): Promise; + addInitScript(script: ((callback: (...args: any[]) => Promise) => any) | string | { path?: string, content?: string }, callback: (...args: any[]) => R | Promise): Promise; addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; $(selector: K, options?: { strict: boolean }): Promise | null>; @@ -119,6 +121,8 @@ export interface Frame { export interface BrowserContext { exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any): Promise; + addInitScript(script: (arg: Unboxed, callback: (...args: any[]) => Promise) => any, arg: Arg, callback: (...args: any[]) => R | Promise): Promise; + addInitScript(script: ((callback: (...args: any[]) => Promise) => any) | string | { path?: string, content?: string }, callback: (...args: any[]) => R | Promise): Promise; addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; removeAllListeners(type?: string): this; diff --git a/utils/generate_types/test/test.ts b/utils/generate_types/test/test.ts index ff7b28a5200a2..5b8b69ecb2b67 100644 --- a/utils/generate_types/test/test.ts +++ b/utils/generate_types/test/test.ts @@ -672,6 +672,28 @@ playwright.chromium.launch().then(async browser => { }); } + { + await page.addInitScript(async (arg, report) => { + const argAssertion: AssertType<{ tag: string }, typeof arg> = true; + const result = await report(arg.tag, 1); + const resultAssertion: AssertType = true; + }, { tag: 'x' }, (name: string, count: number) => name.length + count); + } + + { + // `arg` can be omitted, passing the `callback` as the script's only argument. + await page.addInitScript(async report => { + const result = await report('hello'); + const resultAssertion: AssertType = true; + }, (name: string) => name.length); + } + + { + // A string or file script can use a callback too. + await page.addInitScript('globalThis.x = 1', () => {}); + await page.addInitScript({ content: 'globalThis.x = 1' }, () => {}); + } + await browser.close(); })();