diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index a5679cf5a96a3..4fab903349cd9 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -433,6 +433,14 @@ browser_context.add_init_script(path="preload.js") await Context.AddInitScriptAsync(scriptPath: "preload.js"); ``` +```js +// Pass a Node.js callback in `arg` that any page in the context can call to report back. +const loaded = []; +await browserContext.addInitScript(report => { + void 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. @@ -460,7 +468,14 @@ Script to be evaluated in all pages in the browser context. * langs: js - `arg` ?<[Serializable]> -Optional argument to pass to [`param: script`] (only supported when passing a function). +Optional argument to pass to [`param: script`] (only supported when passing a function). Any function +found anywhere in [`param: arg`] is exposed as a callback invoked in the Playwright (Node.js) +environment whenever a page calls it. The in-page function returns a [Promise] that resolves to the +value returned by the Node.js 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. Structured values such as `Date` are preserved when [`param: arg`] +contains a callback, and are otherwise passed as plain JSON. ### param: BrowserContext.addInitScript.path * since: v1.8 diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 04d0932668163..276e39857bc6a 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -589,6 +589,14 @@ await page.addInitScript(mock => { }, mock); ``` +```js +// Pass a Node.js callback in `arg` that the page can call to report data back on navigation. +const loaded = []; +await page.addInitScript(report => { + void 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")); @@ -635,7 +643,13 @@ Script to be evaluated in all pages in the browser context. * langs: js - `arg` ?<[Serializable]> -Optional argument to pass to [`param: script`] (only supported when passing a function). +Optional argument to pass to [`param: script`] (only supported when passing a function). Any function +found anywhere in [`param: arg`] is exposed as a callback invoked in the Playwright (Node.js) +environment whenever the page calls it. The in-page function returns a [Promise] that resolves to the +value returned by the Node.js 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. Structured values such as `Date` +are preserved when [`param: arg`] contains a callback, and are otherwise passed as plain JSON. ### param: Page.addInitScript.path * since: v1.8 diff --git a/packages/injected/src/bindingsController.ts b/packages/injected/src/bindingsController.ts index 5066e75ee4bcb..13642ff622d09 100644 --- a/packages/injected/src/bindingsController.ts +++ b/packages/injected/src/bindingsController.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers'; +import { parseEvaluationResultValue, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers'; import type { SerializedValue } from '@isomorphic/utilityScriptSerializers'; @@ -51,6 +51,10 @@ export class BindingsController { (this._global as any)[bindingName] = (...args: any[]) => this.callBinding(bindingName, ...args); } + parseArgument(value: SerializedValue): any { + return parseEvaluationResultValue(value); + } + callBinding(bindingName: string, ...args: any[]): Promise { const data = this._bindings.get(bindingName); if (!data || data.removed) diff --git a/packages/isomorphic/utilityScriptSerializers.ts b/packages/isomorphic/utilityScriptSerializers.ts index 012fa00e14e61..a6556e2dc4460 100644 --- a/packages/isomorphic/utilityScriptSerializers.ts +++ b/packages/isomorphic/utilityScriptSerializers.ts @@ -39,7 +39,7 @@ export type SerializedValue = { ta: { b: string, k: TypedArrayKind } } | { ab: { b: string } }; -type HandleOrValue = { h: number } | { fallThrough: any }; +type HandleOrValue = { h: number } | { fn: string } | { fallThrough: any }; type VisitorInfo = { visited: Map; diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 0b3086a126811..a78d74a549250 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -313,6 +313,14 @@ export interface Page { * }, mock); * ``` * + * ```js + * // Pass a Node.js callback in `arg` that the page can call to report data back on navigation. + * const loaded = []; + * await page.addInitScript(report => { + * void 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 @@ -321,7 +329,15 @@ export interface Page { * @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). + * passing a function). Any function found anywhere in + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is exposed as a callback + * invoked in the Playwright (Node.js) environment whenever the page calls it. The in-page function returns a + * [Promise] that resolves to the value returned by the Node.js 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. Structured values such as `Date` are preserved when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) contains a callback, and are + * otherwise passed as plain JSON. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; @@ -9075,6 +9091,14 @@ export interface BrowserContext { * }); * ``` * + * ```js + * // Pass a Node.js callback in `arg` that any page in the context can call to report back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * void 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 @@ -9083,7 +9107,16 @@ export interface BrowserContext { * @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). + * (only supported when passing a function). Any function found anywhere in + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is exposed + * as a callback invoked in the Playwright (Node.js) environment whenever a page calls it. The in-page function + * returns a [Promise] that resolves to the value returned by the Node.js 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. + * Structured values such as `Date` are preserved when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) contains a + * callback, and are otherwise passed as plain JSON. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 82445b69f0941..6cfe99494fe9b 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 { addInitScript, exposeCallbackBinding } from './clientHelper'; import { Clock } from './clock'; import { ConsoleMessage } from './consoleMessage'; import { Credentials } from './credentials'; @@ -46,6 +46,7 @@ import { Worker } from './worker'; import { TimeoutSettings, kNoTimeout } from './timeoutSettings'; import { mkdirIfNeeded } from './fileUtils'; +import type { Disposable } from './disposable'; import type { BrowserContextOptions, Headers, SetStorageState, StorageState, WaitForEventOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; @@ -359,8 +360,9 @@ export class BrowserContext extends ChannelOwner } async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { - const source = await evaluationScript(script, arg); - return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable); + return addInitScript(script, arg, + (name, callback) => this._exposeCallbackBinding(name, callback), + async source => (await this._channel.addInitScript({ source }, kNoTimeout)).disposable); } async exposeBinding(name: string, callback: (source: structs.BindingSource, ...args: any[]) => any): Promise { @@ -376,6 +378,10 @@ export class BrowserContext extends ChannelOwner return DisposableObject.from(result.disposable); } + _exposeCallbackBinding(name: string, callback: Function): Promise { + return exposeCallbackBinding(this._bindings, async params => (await this._channel.exposeBinding(params, kNoTimeout)).disposable, name, callback); + } + async route(url: URLMatch, handler: network.RouteHandlerCallback, options: { times?: number } = {}): Promise { this._routes.unshift(new network.RouteHandler(this._options.baseURL, url, handler, options.times)); await this._updateInterceptionPatterns({ title: 'Route requests' }); diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index ee239a9c51efc..f6031c35553c5 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -1425,9 +1425,10 @@ export type BrowserContextCookiesResult = { }; export type BrowserContextExposeBindingParams = { name: string, + noGlobal?: boolean, }; export type BrowserContextExposeBindingOptions = { - + noGlobal?: boolean, }; export type BrowserContextExposeBindingResult = { disposable: DisposableChannel, diff --git a/packages/playwright-core/src/client/clientHelper.ts b/packages/playwright-core/src/client/clientHelper.ts index f8d055883f9a4..cf2f965e18905 100644 --- a/packages/playwright-core/src/client/clientHelper.ts +++ b/packages/playwright-core/src/client/clientHelper.ts @@ -18,6 +18,13 @@ import fs from 'fs'; import { isString } from '@isomorphic/rtti'; +import { kBindingsControllerProperty, kFunctionBindingPrefix, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers'; +import { createGuid } from '@utils/crypto'; +import { DisposableObject, DisposableStub, disposeAll } from './disposable'; + +import type { Disposable } 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 +35,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 +60,73 @@ 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, '')}`; } + +export async function exposeCallbackBinding( + bindings: Map any>, + exposeBinding: (params: { name: string, noGlobal: boolean }) => Promise, + name: string, + callback: Function, +): Promise { + bindings.set(name, (source, ...args) => callback(...args)); + let channel: channels.DisposableChannel; + try { + channel = await exposeBinding({ name, noGlobal: true }); + } catch (error) { + bindings.delete(name); + throw error; + } + const binding = DisposableObject.from(channel); + return new DisposableStub(async () => { + try { + await binding.dispose(); + } finally { + bindings.delete(name); + } + }); +} + +export async function addInitScript( + script: Function | string | { path?: string, content?: string }, + arg: any, + exposeCallback: (name: string, callback: Function) => Promise, + installInitScript: (source: string) => Promise, +) { + // String or file scripts take no `arg`, and functions without an `arg` cannot carry callbacks. + if (typeof script !== 'function' || arg === undefined) + return DisposableObject.from(await installInitScript(await evaluationScript(script, arg))); + + const callbacksToExpose: { name: string, callback: Function }[] = []; + const serialized = serializeAsCallArgument(arg, value => { + if (typeof value === 'function') { + const name = kFunctionBindingPrefix + createGuid(); + callbacksToExpose.push({ name, callback: value }); + return { fn: name }; + } + return { fallThrough: value }; + }); + + if (!callbacksToExpose.length) + return DisposableObject.from(await installInitScript(await evaluationScript(script, arg))); + + const source = `(${script.toString()})(globalThis['${kBindingsControllerProperty}'].parseArgument(${JSON.stringify(serialized)}))`; + + const disposables: Disposable[] = []; + let scriptChannel: channels.DisposableChannel; + try { + for (const { name, callback } of callbacksToExpose) + disposables.push(await exposeCallback(name, callback)); + scriptChannel = await installInitScript(source); + } catch (error) { + await disposeAll(disposables).catch(() => {}); + throw error; + } + + const scriptDisposable = DisposableObject.from(scriptChannel); + return new DisposableStub(async () => { + try { + await scriptDisposable.dispose(); + } finally { + await disposeAll(disposables); + } + }); +} diff --git a/packages/playwright-core/src/client/jsHandle.ts b/packages/playwright-core/src/client/jsHandle.ts index c0f20cc97965e..9dc6e02a593a2 100644 --- a/packages/playwright-core/src/client/jsHandle.ts +++ b/packages/playwright-core/src/client/jsHandle.ts @@ -120,7 +120,9 @@ export async function serializeArgumentWithCallbacks(owner: ChannelOwner, p if (!page) throw new Error('Passing a function is not supported as an argument here'); const name = kFunctionBindingPrefix + createGuid(); - exposePromises.push(page._exposeEvaluateCallback(name, callback)); + exposePromises.push(page._exposeCallbackBinding(name, callback).then(disposable => { + page._registerCallbackDisposable(disposable); + })); return name; }); await Promise.all(exposePromises); diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 90a199a17f76d..063a40059538c 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -28,9 +28,9 @@ 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 { addInitScript, exposeCallbackBinding } from './clientHelper'; import { Coverage } from './coverage'; -import { DisposableObject, DisposableStub } from './disposable'; +import { DisposableObject, DisposableStub, disposeAll } from './disposable'; import { Download } from './download'; import { ElementHandle, determineScreenshotType } from './elementHandle'; import { AbortError, PlaywrightError, TargetClosedError, isTargetClosedError, parseError, serializeError } from './errors'; @@ -50,6 +50,7 @@ import { TimeoutSettings, kNoTimeout } from './timeoutSettings'; import { mkdirIfNeeded } from './fileUtils'; import { ConsoleMessage } from './consoleMessage'; import type { BrowserContext } from './browserContext'; +import type { Disposable } from './disposable'; import type { EvaluateOptions } from './jsHandle'; import type { Clock } from './clock'; import type { APIRequestContext } from './fetch'; @@ -118,7 +119,7 @@ export class Page extends ChannelOwner implements api.Page private _harRouters: HarRouter[] = []; private _locatorHandlers = new Map any, times: number | undefined }>(); - private _evaluateCallbacks: { name: string, disposable: DisposableObject }[] = []; + private _evaluateCallbackDisposables: Disposable[] = []; static from(page: channels.PageChannel): Page { return (page as any)._object; @@ -376,18 +377,16 @@ export class Page extends ChannelOwner implements api.Page return DisposableObject.from(result.disposable); } - async _exposeEvaluateCallback(name: string, callback: Function) { - this._bindings.set(name, (source, ...args) => callback(...args)); - const result = await this._channel.exposeBinding({ name, noGlobal: true }, kNoTimeout); - this._evaluateCallbacks.push({ name, disposable: DisposableObject.from(result.disposable) }); + _exposeCallbackBinding(name: string, callback: Function): Promise { + return exposeCallbackBinding(this._bindings, async params => (await this._channel.exposeBinding(params, kNoTimeout)).disposable, name, callback); + } + + _registerCallbackDisposable(disposable: Disposable) { + this._evaluateCallbackDisposables.push(disposable); } _eraseEvaluateCallbacks() { - for (const { name, disposable } of this._evaluateCallbacks) { - this._bindings.delete(name); - disposable.dispose().catch(() => {}); - } - this._evaluateCallbacks = []; + void disposeAll(this._evaluateCallbackDisposables).catch(() => {}); } async setExtraHTTPHeaders(headers: Headers) { @@ -550,8 +549,9 @@ export class Page extends ChannelOwner implements api.Page } async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) { - const source = await evaluationScript(script, arg); - return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable); + return addInitScript(script, arg, + (name, callback) => this._exposeCallbackBinding(name, callback), + async source => (await this._channel.addInitScript({ source }, kNoTimeout)).disposable); } async route(url: URLMatch, handler: RouteHandlerCallback, options: { times?: number } = {}): Promise { diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 5353a4fd0f7ac..3656eab0d8a6a 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -377,7 +377,7 @@ export abstract class BrowserContext extends Sdk return this._playwrightBindingExposed !== undefined; } - async exposeBinding(progress: Progress, name: string, playwrightBinding: frames.FunctionWithSource, forClient?: unknown): Promise { + async exposeBinding(progress: Progress, name: string, playwrightBinding: frames.FunctionWithSource, forClient?: unknown, noGlobal?: boolean): Promise { if (this._pageBindings.has(name)) throw new Error(`Function "${name}" has been already registered`); for (const page of this.pages()) { @@ -385,7 +385,7 @@ export abstract class BrowserContext extends Sdk throw new Error(`Function "${name}" has been already registered in one of the pages`); } await progress.race(this.exposePlaywrightBindingIfNeeded()); - const binding = new PageBinding(this, name, playwrightBinding); + const binding = new PageBinding(this, name, playwrightBinding, noGlobal); binding.forClient = forClient; this._pageBindings.set(name, binding); try { diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index b66eb1753d7ef..ea7cb8ad86f37 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -1426,9 +1426,10 @@ export type BrowserContextCookiesResult = { }; export type BrowserContextExposeBindingParams = { name: string, + noGlobal?: boolean, }; export type BrowserContextExposeBindingOptions = { - + noGlobal?: boolean, }; export type BrowserContextExposeBindingResult = { disposable: DisposableChannel, diff --git a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts index e8da143bfbf18..4d24b22c8329c 100644 --- a/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserContextDispatcher.ts @@ -253,7 +253,7 @@ export class BrowserContextDispatcher extends Dispatcher { + * void 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 @@ -321,7 +329,15 @@ export interface Page { * @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). + * passing a function). Any function found anywhere in + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) is exposed as a callback + * invoked in the Playwright (Node.js) environment whenever the page calls it. The in-page function returns a + * [Promise] that resolves to the value returned by the Node.js 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. Structured values such as `Date` are preserved when + * [`arg`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-arg) contains a callback, and are + * otherwise passed as plain JSON. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; @@ -9075,6 +9091,14 @@ export interface BrowserContext { * }); * ``` * + * ```js + * // Pass a Node.js callback in `arg` that any page in the context can call to report back. + * const loaded = []; + * await browserContext.addInitScript(report => { + * void 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 @@ -9083,7 +9107,16 @@ export interface BrowserContext { * @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). + * (only supported when passing a function). Any function found anywhere in + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) is exposed + * as a callback invoked in the Playwright (Node.js) environment whenever a page calls it. The in-page function + * returns a [Promise] that resolves to the value returned by the Node.js 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. + * Structured values such as `Date` are preserved when + * [`arg`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-arg) contains a + * callback, and are otherwise passed as plain JSON. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; diff --git a/packages/protocol/spec/browserContext.yml b/packages/protocol/spec/browserContext.yml index 753e9d6a2c225..50c3b7a99ac76 100644 --- a/packages/protocol/spec/browserContext.yml +++ b/packages/protocol/spec/browserContext.yml @@ -84,6 +84,7 @@ BrowserContext: group: configuration parameters: name: string + noGlobal: boolean? returns: disposable: Disposable diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index 5c080081f7e09..6228d295499ef 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -773,6 +773,7 @@ scheme.BrowserContextCookiesResult = tObject({ }); scheme.BrowserContextExposeBindingParams = tObject({ name: tString, + noGlobal: tOptional(tBoolean), }); scheme.BrowserContextExposeBindingResult = tObject({ disposable: tChannel(['Disposable']), diff --git a/tests/library/browsercontext-add-init-script.spec.ts b/tests/library/browsercontext-add-init-script.spec.ts index c2fd6cf83840f..b008856e7e961 100644 --- a/tests/library/browsercontext-add-init-script.spec.ts +++ b/tests/library/browsercontext-add-init-script.spec.ts @@ -97,3 +97,49 @@ 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(report => { + void report(location.pathname); + }, (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('should stop reporting after the context callback disposable is disposed', async ({ context, server }) => { + const reports = []; + const handle = await context.addInitScript(report => { + (window as any)['__report'] = report; + void report(location.pathname); + }, (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 not register the callback on the global object', async ({ context, server }) => { + await context.addInitScript(report => { + void report(); + }, () => {}); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const globals = await page.evaluate(() => Object.getOwnPropertyNames(globalThis).filter(name => name.startsWith('__pw_fn_'))); + expect(globals).toEqual([]); +}); diff --git a/tests/page/page-add-init-script.spec.ts b/tests/page/page-add-init-script.spec.ts index 5e569f922f80f..6face879741be 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,121 @@ 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 => { + void arg.report(`${location.pathname}:${arg.tag}`); + }, { tag: 'x', report: (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 expose multiple callbacks anywhere in the arg', async ({ page, server }) => { + const calls = []; + await page.addInitScript(arg => { + void arg.report('report:' + location.pathname); + void arg.handlers.onLoad('nested:' + location.pathname); + }, { + report: (message: string) => calls.push(message), + handlers: { onLoad: (message: string) => calls.push(message) }, + }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => calls).toContain('report:/empty.html'); + await expect.poll(() => calls).toContain('nested:/empty.html'); +}); + +it('should preserve non-callback values in the arg', async ({ page, server }) => { + await page.addInitScript(arg => { + (window as any)['__reported'] = { tag: arg.tag, when: arg.when instanceof Date }; + void arg.report(); + }, { tag: 'x', when: new Date(), report: () => {} }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any)['__reported'])).toEqual({ tag: 'x', when: true }); +}); + +it('callback should work from a child frame', async ({ page, server }) => { + const reports = []; + await page.addInitScript(report => { + void report(window === window.top ? 'top' : 'child'); + }, (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 report => { + (window as any)['__reported'] = await report(21); + }, async (value: number) => value * 2); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any)['__reported'])).toBe(42); +}); + +it('should tear down the binding on dispose', async ({ page, server }) => { + const reports = []; + const bindingsBefore = (page as any)._bindings.size; + const handle = await page.addInitScript(report => { + (window as any)['__report'] = report; + void report(location.pathname); + }, (pathname: string) => { + reports.push(pathname); + }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => reports).toContain('/empty.html'); + expect((page as any)._bindings.size).toBe(bindingsBefore + 1); + + await handle.dispose(); + expect((page as any)._bindings.size).toBe(bindingsBefore); + 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'); + + 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('should support multiple independent callbacks disposed independently', async ({ page, server }) => { + const a = []; + const b = []; + const handleA = await page.addInitScript(report => { + void report('a:' + location.pathname); + }, (message: string) => { + a.push(message); + }); + await page.addInitScript(report => { + void report('b:' + location.pathname); + }, (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'); +}); + +it('should not register the callback on the global object', async ({ page, server }) => { + await page.addInitScript(report => { + void report(); + }, () => {}); + await page.goto(server.EMPTY_PAGE); + const globals = await page.evaluate(() => Object.getOwnPropertyNames(globalThis).filter(name => name.startsWith('__pw_fn_'))); + expect(globals).toEqual([]); +}); diff --git a/utils/generate_types/test/test.ts b/utils/generate_types/test/test.ts index ff7b28a5200a2..6f5923b267758 100644 --- a/utils/generate_types/test/test.ts +++ b/utils/generate_types/test/test.ts @@ -672,6 +672,20 @@ playwright.chromium.launch().then(async browser => { }); } + { + // Functions anywhere in `arg` are exposed as Node callbacks. + await page.addInitScript(arg => { + void arg; + }, { tag: 'x', report: (name: string, count: number) => name.length + count }); + } + + { + // `arg` can itself be a function. + await page.addInitScript(report => { + void report; + }, (name: string) => name.length); + } + await browser.close(); })();