diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index a5679cf5a96a3..37ebb8f05f0db 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -476,6 +476,9 @@ Path to the JavaScript file. If `path` is a relative path, then it is resolved r Script to be evaluated in all pages in the browser context. Optional. +### option: BrowserContext.addInitScript.exposeFunctions = %%-js-init-script-expose-functions-%% +* since: v1.62 + ## method: BrowserContext.backgroundPages * since: v1.11 * deprecated: Background pages have been removed from Chromium together with Manifest V2 extensions. diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 04d0932668163..6174ccc392d49 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -651,6 +651,9 @@ Path to the JavaScript file. If `path` is a relative path, then it is resolved r Script to be evaluated in all pages in the browser context. Optional. +### option: Page.addInitScript.exposeFunctions = %%-js-init-script-expose-functions-%% +* since: v1.62 + ## async method: Page.addScriptTag * since: v1.8 - returns: <[ElementHandle]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index bdce5366586ce..47fe73082ada9 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -590,6 +590,12 @@ Function to be evaluated in the page context. When set to `true`, functions passed inside [`param: arg`] are exposed in the page and can be called from the page function. Calling one returns a [Promise] of its result. Under the hood, each function is exposed via [`method: Page.exposeFunction`], so it is technically accessible from all frames and worlds of the page. Exposed functions are cleared upon the top-level navigation. Defaults to `false`, in which case functions are not serializable and passing one throws an error. +## js-init-script-expose-functions +* langs: js +- `exposeFunctions` <[boolean]> + +When set to `true`, functions passed inside [`param: arg`] are exposed in the page and can be called from the init script. Calling one returns a [Promise] of its result. Under the hood, each function is exposed via [`method: Page.exposeFunction`], so it is technically accessible from all frames and worlds of the page. Unlike functions passed to [`method: Page.evaluate`], functions passed to an init script are exposed in every new document, so they survive navigations. Defaults to `false`, in which case functions are not serializable and are silently dropped. + ## js-evalonselector-pagefunction * langs: js - `pageFunction` <[function]\([Element]\)|[string]> diff --git a/packages/injected/src/bindingsController.ts b/packages/injected/src/bindingsController.ts index 5066e75ee4bcb..6e998e5c210c8 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'; @@ -68,6 +68,12 @@ export class BindingsController { return promise; } + parseInitScriptArg(value: SerializedValue): any { + // Functions serialized as { fn } deserialize into wrappers + // that route the call through this controller. + return parseEvaluationResultValue(value); + } + removeBinding(bindingName: string) { const data = this._bindings.get(bindingName); if (data) 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 0b3c58906b5b1..4576f7bdc8f7b 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -314,16 +314,17 @@ export interface Page { * ``` * * **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, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, options])](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 options */ - addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise; /** * **NOTE** Use locator-based [page.locator(selector[, options])](https://playwright.dev/docs/api/class-page#page-locator) @@ -9076,16 +9077,17 @@ export interface BrowserContext { * ``` * * **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, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, options])](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 options */ - addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise; /** * Removes all the listeners of the given type (or all registered listeners if no type given). Allows to wait for @@ -20781,14 +20783,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, options])](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, options])](https://playwright.dev/docs/api/class-page#page-add-init-script) or + * [browserContext.addInitScript(script[, arg, options])](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..7186b1fc584b9 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -37,8 +37,9 @@ import { Events } from './events'; import { APIRequestContext } from './fetch'; import { Frame } from './frame'; import { HarRouter } from './harRouter'; +import { assertEvaluateOptions } from './jsHandle'; import * as network from './network'; -import { BindingCall, Page } from './page'; +import { BindingCall, Page, addInitScriptWithExposedFunctions } from './page'; import { Tracing } from './tracing'; import { Waiter } from './waiter'; import { WebError } from './webError'; @@ -46,6 +47,7 @@ import { Worker } from './worker'; import { TimeoutSettings, kNoTimeout } from './timeoutSettings'; import { mkdirIfNeeded } from './fileUtils'; +import type { EvaluateOptions } from './jsHandle'; import type { BrowserContextOptions, Headers, SetStorageState, StorageState, WaitForEventOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; @@ -358,7 +360,10 @@ 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, options?: EvaluateOptions) { + assertEvaluateOptions(options); + if (options?.exposeFunctions) + return await addInitScriptWithExposedFunctions(this, script, arg); const source = await evaluationScript(script, arg); return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable); } @@ -369,6 +374,12 @@ export class BrowserContext extends ChannelOwner return DisposableObject.from(result.disposable); } + async _exposeCallbackBinding(name: string, callback: Function): Promise { + this._bindings.set(name, (source, ...args) => callback(...args)); + const result = await this._channel.exposeBinding({ name, noGlobal: true }, kNoTimeout); + return DisposableObject.from(result.disposable); + } + async exposeFunction(name: string, callback: Function): Promise { const result = await this._channel.exposeBinding({ name }, kNoTimeout); const binding = (source: structs.BindingSource, ...args: any[]) => callback(...args); 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..89037e34295f4 100644 --- a/packages/playwright-core/src/client/clientHelper.ts +++ b/packages/playwright-core/src/client/clientHelper.ts @@ -18,6 +18,8 @@ import fs from 'fs'; import { isString } from '@isomorphic/rtti'; +import { kBindingsControllerProperty, kFunctionBindingPrefix, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers'; +import { createGuid } from '@utils/crypto'; export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string, value: string }[] { const result: { name: string, value: string }[] = []; @@ -49,6 +51,22 @@ export async function evaluationScript(fun: Function | string | { path?: string, throw new Error('Either path or content property must be present'); } +export async function initScriptSourceWithExposedFunctions(fun: Function, arg: any, expose: (name: string, callback: Function) => Promise): Promise { + const exposePromises: Promise[] = []; + const serialized = serializeAsCallArgument(arg, value => { + if (typeof value === 'function') { + const name = kFunctionBindingPrefix + createGuid(); + exposePromises.push(expose(name, value)); + return { fn: name }; + } + return { fallThrough: value }; + }); + await Promise.all(exposePromises); + // Bindings backing the functions are registered through their own init scripts + // that are guaranteed to run first, so the controller is available here. + return `(${fun.toString()})(globalThis['${kBindingsControllerProperty}'].parseInitScriptArg(${JSON.stringify(serialized)}))`; +} + export function addSourceUrlToScript(source: string, path: string): string { return `${source}\n//# sourceURL=${path.replace(/\n/g, '')}`; } diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 90a199a17f76d..1227ce1c5307b 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 { evaluationScript, initScriptSourceWithExposedFunctions } from './clientHelper'; import { Coverage } from './coverage'; import { DisposableObject, DisposableStub } from './disposable'; import { Download } from './download'; @@ -40,7 +40,7 @@ import { Frame, verifyLoadState } from './frame'; import { HarRouter } from './harRouter'; import { Keyboard, Mouse, Touchscreen } from './input'; import { WebStorage } from './webStorage'; -import { assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; +import { assertEvaluateOptions, assertMaxArguments, parseResult, serializeArgument } from './jsHandle'; import { Request, Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network'; import { Video } from './video'; import { Screencast } from './screencast'; @@ -376,10 +376,15 @@ export class Page extends ChannelOwner implements api.Page return DisposableObject.from(result.disposable); } - async _exposeEvaluateCallback(name: string, callback: Function) { + async _exposeCallbackBinding(name: string, callback: Function): Promise { 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) }); + return DisposableObject.from(result.disposable); + } + + async _exposeEvaluateCallback(name: string, callback: Function) { + const disposable = await this._exposeCallbackBinding(name, callback); + this._evaluateCallbacks.push({ name, disposable }); } _eraseEvaluateCallbacks() { @@ -549,7 +554,10 @@ 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, options?: EvaluateOptions) { + assertEvaluateOptions(options); + if (options?.exposeFunctions) + return await addInitScriptWithExposedFunctions(this, script, arg); const source = await evaluationScript(script, arg); return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable); } @@ -947,3 +955,23 @@ function trimUrl(param: any): string | undefined { if (isString(param)) return `"${trimStringWithEllipsis(param, 50)}"`; } + +export async function addInitScriptWithExposedFunctions(owner: Page | BrowserContext, script: Function | string | { path?: string, content?: string }, arg: any): Promise { + if (typeof script !== 'function') + throw new Error('Passing functions requires the init script to be a function'); + const callbacks: { name: string, disposable: DisposableObject }[] = []; + const source = await owner._wrapApiCall(async () => { + return await initScriptSourceWithExposedFunctions(script, arg, async (name, callback) => { + const disposable = await owner._exposeCallbackBinding(name, callback); + callbacks.push({ name, disposable }); + }); + }, { internal: true }); + const initScriptDisposable = DisposableObject.from((await owner._channel.addInitScript({ source }, kNoTimeout)).disposable); + return new DisposableStub(async () => { + for (const { name, disposable } of callbacks) { + owner._bindings.delete(name); + disposable.dispose().catch(() => {}); + } + await initScriptDisposable.dispose(); + }); +} 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(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise; /** * **NOTE** Use locator-based [page.locator(selector[, options])](https://playwright.dev/docs/api/class-page#page-locator) @@ -9076,16 +9077,17 @@ export interface BrowserContext { * ``` * * **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, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script) + * and [page.addInitScript(script[, arg, options])](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 options */ - addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise; /** * Removes all the listeners of the given type (or all registered listeners if no type given). Allows to wait for @@ -20781,14 +20783,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, options])](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, options])](https://playwright.dev/docs/api/class-page#page-add-init-script) or + * [browserContext.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script). */ dispose(): 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..7704c9de5b2ee 100644 --- a/tests/library/browsercontext-add-init-script.spec.ts +++ b/tests/library/browsercontext-add-init-script.spec.ts @@ -86,6 +86,54 @@ it('should remove context init script and keep working in new pages', async ({ c expect(await page.evaluate(() => (window as any)['temp'])).toBe(undefined); }); +it('should expose functions passed as arguments', async ({ context, server }) => { + const received: string[] = []; + await context.addInitScript(async ({ cb }) => { + await cb(location.href); + }, { cb: async (href: string) => { received.push(href); } }, { exposeFunctions: true }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => received).toContain(server.EMPTY_PAGE); +}); + +it('should expose functions that survive navigation', async ({ context, server }) => { + const received: number[] = []; + await context.addInitScript(({ cb }) => { + (window as any).cb = cb; + }, { cb: (n: number) => { received.push(n); return n * 2; } }, { exposeFunctions: true }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => (window as any).cb(1))).toBe(2); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(await page.evaluate(() => (window as any).cb(2))).toBe(4); + expect(received).toEqual([1, 2]); +}); + +it('should expose functions in popups', async ({ context, server }) => { + await context.addInitScript(({ mul }) => { + (window as any).mul = mul; + }, { mul: (a: number, b: number) => a * b }, { exposeFunctions: true }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.evaluate(() => window.open('about:blank')), + ]); + expect(await popup.evaluate(() => (window as any).mul(6, 7))).toBe(42); +}); + +it('should remove exposed functions after dispose', async ({ context, server }) => { + const disposable = await context.addInitScript(({ cb }) => { + (window as any).cb = cb; + }, { cb: (n: number) => n * 2 }, { exposeFunctions: true }); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => (window as any).cb(21))).toBe(42); + await disposable.dispose(); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => typeof (window as any).cb)).toBe('undefined'); +}); + it('init script should run only once in popup', async ({ context }) => { await context.addInitScript(() => { window['callCount'] = (window['callCount'] || 0) + 1; diff --git a/tests/page/page-add-init-script-callback.spec.ts b/tests/page/page-add-init-script-callback.spec.ts new file mode 100644 index 0000000000000..a44e9f84f95cc --- /dev/null +++ b/tests/page/page-add-init-script-callback.spec.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test as it, expect } from './pageTest'; + +it('should drop functions without the exposeFunctions option', async ({ page, server }) => { + await page.addInitScript(({ cb }) => { + (window as any).cbType = typeof cb; + }, { cb: () => {} }); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => (window as any).cbType)).toBe('undefined'); +}); + +it('should throw when the script is not a function', async ({ page }) => { + await expect(page.addInitScript({ content: 'window.foo = 1;' }, undefined, { exposeFunctions: true })) + .rejects.toThrow('Passing functions requires the init script to be a function'); +}); + +it('should call a function passed as an argument', async ({ page, server }) => { + const received: number[] = []; + await page.addInitScript(async ({ cb }) => { + await cb(1); + await cb(2); + }, { cb: async (n: number) => { received.push(n); } }, { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => received).toEqual([1, 2]); +}); + +it('should accept a function as the whole argument', async ({ page, server }) => { + await page.addInitScript(async cb => { + (window as any).result = await cb('a'); + }, async (s: string) => s + 'b', { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any).result)).toBe('ab'); +}); + +it('should pass arguments to the callback', async ({ page, server }) => { + const argsPromise = new Promise(resolve => { + void page.addInitScript(({ cb }) => cb(1, 'two', { three: 3 }, [4]), { + cb: (...a: any[]) => resolve(a), + }, { exposeFunctions: true }).then(() => page.goto(server.EMPTY_PAGE)); + }); + expect(await argsPromise).toEqual([1, 'two', { three: 3 }, [4]]); +}); + +it('should return the callback result to the page', async ({ page, server }) => { + await page.addInitScript(async ({ double }) => { + (window as any).result = await double(21); + }, { double: async (n: number) => n * 2 }, { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any).result)).toBe(42); +}); + +it('should propagate callback errors to the page', async ({ page, server }) => { + await page.addInitScript(async ({ cb }) => { + try { + await cb(); + (window as any).result = 'no error'; + } catch (e) { + (window as any).result = (e as Error).message; + } + }, { cb: async () => { throw new Error('boom'); } }, { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any).result)).toContain('boom'); +}); + +it('should support multiple callbacks', async ({ page, server }) => { + await page.addInitScript(async ({ add, mul }) => { + (window as any).result = (await add(2, 3)) + (await mul(2, 3)); + }, { + add: async (a: number, b: number) => a + b, + mul: async (a: number, b: number) => a * b, + }, { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any).result)).toBe(11); +}); + +it('should survive a navigation and keep working', async ({ page, server }) => { + const received: number[] = []; + await page.addInitScript(({ cb }) => { + (window as any).cb = cb; + }, { cb: (n: number) => { received.push(n); return n * 2; } }, { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => (window as any).cb(1))).toBe(2); + await page.goto(server.CROSS_PROCESS_PREFIX + '/empty.html'); + expect(await page.evaluate(() => (window as any).cb(2))).toBe(4); + expect(received).toEqual([1, 2]); +}); + +it('should work in a child frame', async ({ page, server }) => { + const received: number[] = []; + await page.addInitScript(async ({ cb }) => { + await cb(42); + }, { cb: async (n: number) => { received.push(n); } }, { exposeFunctions: true }); + await page.goto(server.PREFIX + '/frames/one-frame.html'); + // Once for the main frame document, once for the child frame document. + await expect.poll(() => received).toEqual([42, 42]); +}); + +it('should not register the callback on the global object', async ({ page, server }) => { + await page.addInitScript(async ({ cb }) => { + await cb(); + (window as any).result = Object.getOwnPropertyNames(globalThis).filter(name => name.startsWith('__pw_fn_')); + }, { cb: async () => {} }, { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + await expect.poll(() => page.evaluate(() => (window as any).result)).toEqual([]); +}); + +it('should remove exposed functions after dispose', async ({ page, server }) => { + const received: number[] = []; + const disposable = await page.addInitScript(({ cb }) => { + (window as any).cb = cb; + }, { cb: (n: number) => { received.push(n); } }, { exposeFunctions: true }); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => (window as any).cb(1)); + await disposable.dispose(); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => typeof (window as any).cb)).toBe('undefined'); + expect(received).toEqual([1]); +}); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index 81ac22c943262..a018af158bbb5 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -44,7 +44,7 @@ export interface Page { evaluateHandle(pageFunction: PageFunction, arg: Arg, options?: { exposeFunctions?: boolean }): Promise>; evaluateHandle(pageFunction: PageFunction, arg?: any, options?: { exposeFunctions?: boolean }): Promise>; - addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise; $(selector: K, options?: { strict: boolean }): Promise | null>; $(selector: string, options?: { strict: boolean }): Promise | null>; @@ -119,7 +119,7 @@ export interface Frame { export interface BrowserContext { exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any): Promise; - addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; + addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise; removeAllListeners(type?: string): this; removeAllListeners(type: string | undefined, options: {