diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 6d5558ae55..7b6c6aab1d 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -415,13 +415,42 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte [`method: Page.addInitScript`] is not defined. ::: +**Bundling** + +If you have a complex script split into several files, it needs to be bundled into a single file first. We recommend running [`esbuild`](https://esbuild.github.io/) or [`webpack`](https://webpack.js.org/) to produce a commonjs module and pass [`option: path`] and [`option: arg`]. + +```js browser title="mocks/mockRandom.ts" +// This script can import other files. +import { defaultValue } from './defaultValue'; + +export default function(value?: number) { + window.Math.random = () => value ?? defaultValue; +} +``` + +```sh +# bundle with esbuild +esbuild mocks/mockRandom.ts --bundle --format=cjs --outfile=mocks/mockRandom.js +``` + +```js title="tests/example.spec.ts" +const mockPath = { path: path.resolve(__dirname, '../mocks/mockRandom.js') }; + +// Passing 42 as an argument to the default export function. +await context.addInitScript({ path: mockPath }, 42); + +// Make sure to pass undefined even if you do not need to pass an argument. +// This instructs Playwright to treat the file as a commonjs module. +await context.addInitScript({ path: mockPath }, undefined); +``` + ### param: BrowserContext.addInitScript.script * since: v1.8 * langs: js - `script` <[function]|[string]|[Object]> - `path` ?<[path]> Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the - current working directory. Optional. - - `content` ?<[string]> Raw script content. Optional. + current working directory. + - `content` ?<[string]> Raw script content. Script to be evaluated in all pages in the browser context. @@ -437,7 +466,9 @@ 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 JSON-serializable argument to pass to [`param: script`]. +* When `script` is a function, the argument is passed to it directly. +* When `script` is a file path, the file is assumed to be a commonjs module. The default export, either `module.exports` or `module.exports.default`, should be a function that's going to be executed with this argument. ### param: BrowserContext.addInitScript.path * since: v1.8 diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index ee9623c867..11d7cbdd74 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -619,13 +619,42 @@ The order of evaluation of multiple scripts installed via [`method: BrowserConte [`method: Page.addInitScript`] is not defined. ::: +**Bundling** + +If you have a complex script split into several files, it needs to be bundled into a single file first. We recommend running [`esbuild`](https://esbuild.github.io/) or [`webpack`](https://webpack.js.org/) to produce a commonjs module and pass [`option: path`] and [`option: arg`]. + +```js browser title="mocks/mockRandom.ts" +// This script can import other files. +import { defaultValue } from './defaultValue'; + +export default function(value?: number) { + window.Math.random = () => value ?? defaultValue; +} +``` + +```sh +# bundle with esbuild +esbuild mocks/mockRandom.ts --bundle --format=cjs --outfile=mocks/mockRandom.js +``` + +```js title="tests/example.spec.ts" +const mockPath = { path: path.resolve(__dirname, '../mocks/mockRandom.js') }; + +// Passing 42 as an argument to the default export function. +await page.addInitScript({ path: mockPath }, 42); + +// Make sure to pass undefined even if you do not need to pass an argument. +// This instructs Playwright to treat the file as a commonjs module. +await page.addInitScript({ path: mockPath }, undefined); +``` + ### param: Page.addInitScript.script * since: v1.8 * langs: js - `script` <[function]|[string]|[Object]> - `path` ?<[path]> Path to the JavaScript file. If `path` is a relative path, then it is resolved relative to the - current working directory. Optional. - - `content` ?<[string]> Raw script content. Optional. + current working directory. + - `content` ?<[string]> Raw script content. Script to be evaluated in the page. @@ -641,7 +670,9 @@ 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 JSON-serializable argument to pass to [`param: script`]. +* When `script` is a function, the argument is passed to it directly. +* When `script` is a file path, the file is assumed to be a commonjs module. The default export, either `module.exports` or `module.exports.default`, should be a function that's going to be executed with this argument. ### param: Page.addInitScript.path * since: v1.8 diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index c4f7827840..b0b72917cf 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -308,7 +308,7 @@ export class BrowserContext extends ChannelOwner } async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise { - const source = await evaluationScript(script, arg); + const source = await evaluationScript(script, arg, arguments.length > 1); await this._channel.addInitScript({ source }); } diff --git a/packages/playwright-core/src/client/clientHelper.ts b/packages/playwright-core/src/client/clientHelper.ts index 540230a4fc..fcc785b71b 100644 --- a/packages/playwright-core/src/client/clientHelper.ts +++ b/packages/playwright-core/src/client/clientHelper.ts @@ -28,20 +28,37 @@ export function envObjectToArray(env: types.Env): { name: string, value: string return result; } -export async function evaluationScript(fun: Function | string | { path?: string, content?: string }, arg?: any, addSourceUrl: boolean = true): Promise { +export async function evaluationScript(fun: Function | string | { path?: string, content?: string }, arg: any, hasArg: boolean, 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 (arg !== undefined) - throw new Error('Cannot evaluate a string with arguments'); - if (isString(fun)) + if (isString(fun)) { + if (arg !== undefined) + throw new Error('Cannot evaluate a string with arguments'); return fun; - if (fun.content !== undefined) + } + if (fun.content !== undefined) { + if (arg !== undefined) + throw new Error('Cannot evaluate a string with arguments'); return fun.content; + } if (fun.path !== undefined) { let source = await fs.promises.readFile(fun.path, 'utf8'); + if (hasArg) { + // Assume a CJS module that has a function default export. + source = `(() => { + var exports = {}; var module = { exports }; + ${source} + let __pw_result__ = module.exports; + if (__pw_result__ && typeof __pw_result__ === 'object' && ('default' in __pw_result__)) + __pw_result__ = __pw_result__['default']; + if (typeof __pw_result__ !== 'function') + return __pw_result__; + return __pw_result__(${JSON.stringify(arg)}); + })()`; + } if (addSourceUrl) source = addSourceUrlToScript(source, fun.path); return source; diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index a10286fa9a..5ff6d6178d 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -492,7 +492,7 @@ 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); + const source = await evaluationScript(script, arg, arguments.length > 1); await this._channel.addInitScript({ source }); } diff --git a/packages/playwright-core/src/client/selectors.ts b/packages/playwright-core/src/client/selectors.ts index 2739be0e8d..c7a7967559 100644 --- a/packages/playwright-core/src/client/selectors.ts +++ b/packages/playwright-core/src/client/selectors.ts @@ -26,7 +26,7 @@ export class Selectors implements api.Selectors { private _registrations: channels.SelectorsRegisterParams[] = []; async register(name: string, script: string | (() => SelectorEngine) | { path?: string, content?: string }, options: { contentScript?: boolean } = {}): Promise { - const source = await evaluationScript(script, undefined, false); + const source = await evaluationScript(script, undefined, false, false); const params = { ...options, name, source }; for (const channel of this._channels) await channel._channel.register(params); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index a4bf9fa812..0d018dcfd3 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -288,8 +288,41 @@ export interface Page { * [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. + * + * **Bundling** + * + * If you have a complex script split into several files, it needs to be bundled into a single file first. We + * recommend running [`esbuild`](https://esbuild.github.io/) or [`webpack`](https://webpack.js.org/) to produce a + * commonjs module and pass `path` and `arg`. + * + * ```js + * // mocks/mockRandom.ts + * // This script can import other files. + * import { defaultValue } from './defaultValue'; + * + * export default function(value?: number) { + * window.Math.random = () => value ?? defaultValue; + * } + * ``` + * + * ```js + * // tests/example.spec.ts + * const mockPath = { path: path.resolve(__dirname, '../mocks/mockRandom.js') }; + * + * // Passing 42 as an argument to the default export function. + * await page.addInitScript({ path: mockPath }, 42); + * + * // Make sure to pass undefined even if you do not need to pass an argument. + * // This instructs Playwright to treat the file as a commonjs module. + * await page.addInitScript({ path: mockPath }, undefined); + * ``` + * * @param script Script to be evaluated in the page. - * @param arg Optional argument to pass to `script` (only supported when passing a function). + * @param arg Optional JSON-serializable argument to pass to `script`. + * - When `script` is a function, the argument is passed to it directly. + * - When `script` is a file path, the file is assumed to be a commonjs module. The default export, either + * `module.exports` or `module.exports.default`, should be a function that's going to be executed with this + * argument. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; @@ -7666,8 +7699,41 @@ export interface BrowserContext { * [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. + * + * **Bundling** + * + * If you have a complex script split into several files, it needs to be bundled into a single file first. We + * recommend running [`esbuild`](https://esbuild.github.io/) or [`webpack`](https://webpack.js.org/) to produce a + * commonjs module and pass `path` and `arg`. + * + * ```js + * // mocks/mockRandom.ts + * // This script can import other files. + * import { defaultValue } from './defaultValue'; + * + * export default function(value?: number) { + * window.Math.random = () => value ?? defaultValue; + * } + * ``` + * + * ```js + * // tests/example.spec.ts + * const mockPath = { path: path.resolve(__dirname, '../mocks/mockRandom.js') }; + * + * // Passing 42 as an argument to the default export function. + * await context.addInitScript({ path: mockPath }, 42); + * + * // Make sure to pass undefined even if you do not need to pass an argument. + * // This instructs Playwright to treat the file as a commonjs module. + * await context.addInitScript({ path: mockPath }, undefined); + * ``` + * * @param script Script to be evaluated in all pages in the browser context. - * @param arg Optional argument to pass to `script` (only supported when passing a function). + * @param arg Optional JSON-serializable argument to pass to `script`. + * - When `script` is a function, the argument is passed to it directly. + * - When `script` is a file path, the file is assumed to be a commonjs module. The default export, either + * `module.exports` or `module.exports.default`, should be a function that's going to be executed with this + * argument. */ addInitScript(script: PageFunction | { path?: string, content?: string }, arg?: Arg): Promise; diff --git a/tests/assets/injectedmodule.js b/tests/assets/injectedmodule.js new file mode 100644 index 0000000000..bc099f243f --- /dev/null +++ b/tests/assets/injectedmodule.js @@ -0,0 +1,33 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// one.ts +var one_exports = {}; +__export(one_exports, { + default: () => one_default +}); +module.exports = __toCommonJS(one_exports); + +// two.ts +var value = 42; + +// one.ts +function one_default(arg) { + window.injected = arg ?? value; +} diff --git a/tests/page/page-add-init-script.spec.ts b/tests/page/page-add-init-script.spec.ts index b2b7782eba..2b12f00251 100644 --- a/tests/page/page-add-init-script.spec.ts +++ b/tests/page/page-add-init-script.spec.ts @@ -31,6 +31,18 @@ it('should work with a path', async ({ page, server, asset }) => { expect(await page.evaluate(() => window['result'])).toBe(123); }); +it('should assume CJS module with a path and arg', async ({ page, server, asset }) => { + await page.addInitScript({ path: asset('injectedmodule.js') }, 17); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => window['injected'])).toBe(17); +}); + +it('should assume CJS module with a path and undefined arg', async ({ page, server, asset }) => { + await page.addInitScript({ path: asset('injectedmodule.js') }, undefined); + await page.goto(server.EMPTY_PAGE); + expect(await page.evaluate(() => window['injected'])).toBe(42); +}); + it('should work with content @smoke', async ({ page, server }) => { await page.addInitScript({ content: 'window["injected"] = 123' }); await page.goto(server.PREFIX + '/tamperable.html');