Skip to content

Commit f39623b

Browse files
authored
feat(addInitScript): allow passing functions as init script arguments (#41921)
1 parent 6d487d6 commit f39623b

19 files changed

Lines changed: 306 additions & 40 deletions

File tree

docs/src/api/class-browsercontext.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,9 @@ Path to the JavaScript file. If `path` is a relative path, then it is resolved r
476476

477477
Script to be evaluated in all pages in the browser context. Optional.
478478

479+
### option: BrowserContext.addInitScript.exposeFunctions = %%-js-init-script-expose-functions-%%
480+
* since: v1.62
481+
479482
## method: BrowserContext.backgroundPages
480483
* since: v1.11
481484
* deprecated: Background pages have been removed from Chromium together with Manifest V2 extensions.

docs/src/api/class-page.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,9 @@ Path to the JavaScript file. If `path` is a relative path, then it is resolved r
651651

652652
Script to be evaluated in all pages in the browser context. Optional.
653653

654+
### option: Page.addInitScript.exposeFunctions = %%-js-init-script-expose-functions-%%
655+
* since: v1.62
656+
654657
## async method: Page.addScriptTag
655658
* since: v1.8
656659
- returns: <[ElementHandle]>

docs/src/api/params.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,12 @@ Function to be evaluated in the page context.
590590

591591
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.
592592

593+
## js-init-script-expose-functions
594+
* langs: js
595+
- `exposeFunctions` <[boolean]>
596+
597+
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.
598+
593599
## js-evalonselector-pagefunction
594600
* langs: js
595601
- `pageFunction` <[function]\([Element]\)|[string]>

packages/injected/src/bindingsController.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
17+
import { parseEvaluationResultValue, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
1818

1919
import type { SerializedValue } from '@isomorphic/utilityScriptSerializers';
2020

@@ -68,6 +68,12 @@ export class BindingsController {
6868
return promise;
6969
}
7070

71+
parseInitScriptArg(value: SerializedValue): any {
72+
// Functions serialized as { fn } deserialize into wrappers
73+
// that route the call through this controller.
74+
return parseEvaluationResultValue(value);
75+
}
76+
7177
removeBinding(bindingName: string) {
7278
const data = this._bindings.get(bindingName);
7379
if (data)

packages/isomorphic/utilityScriptSerializers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type SerializedValue =
3939
{ ta: { b: string, k: TypedArrayKind } } |
4040
{ ab: { b: string } };
4141

42-
type HandleOrValue = { h: number } | { fallThrough: any };
42+
type HandleOrValue = { h: number } | { fn: string } | { fallThrough: any };
4343

4444
type VisitorInfo = {
4545
visited: Map<object, number>;

packages/playwright-client/types/types.d.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -314,16 +314,17 @@ export interface Page {
314314
* ```
315315
*
316316
* **NOTE** The order of evaluation of multiple scripts installed via
317-
* [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script)
318-
* and [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) is not
319-
* defined.
317+
* [browserContext.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script)
318+
* and [page.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-page#page-add-init-script)
319+
* is not defined.
320320
*
321321
* @param script Script to be evaluated in the page.
322322
* @param arg Optional argument to pass to
323323
* [`script`](https://playwright.dev/docs/api/class-page#page-add-init-script-option-script) (only supported when
324324
* passing a function).
325+
* @param options
325326
*/
326-
addInitScript<Arg>(script: PageFunction<Arg, any> | { path?: string, content?: string }, arg?: Arg): Promise<Disposable>;
327+
addInitScript<Arg>(script: PageFunction<Arg, any> | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise<Disposable>;
327328

328329
/**
329330
* **NOTE** Use locator-based [page.locator(selector[, options])](https://playwright.dev/docs/api/class-page#page-locator)
@@ -9090,16 +9091,17 @@ export interface BrowserContext {
90909091
* ```
90919092
*
90929093
* **NOTE** The order of evaluation of multiple scripts installed via
9093-
* [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script)
9094-
* and [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) is not
9095-
* defined.
9094+
* [browserContext.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script)
9095+
* and [page.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-page#page-add-init-script)
9096+
* is not defined.
90969097
*
90979098
* @param script Script to be evaluated in all pages in the browser context.
90989099
* @param arg Optional argument to pass to
90999100
* [`script`](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script-option-script)
91009101
* (only supported when passing a function).
9102+
* @param options
91019103
*/
9102-
addInitScript<Arg>(script: PageFunction<Arg, any> | { path?: string, content?: string }, arg?: Arg): Promise<Disposable>;
9104+
addInitScript<Arg>(script: PageFunction<Arg, any> | { path?: string, content?: string }, arg?: Arg, options?: { exposeFunctions?: boolean }): Promise<Disposable>;
91039105

91049106
/**
91059107
* Removes all the listeners of the given type (or all registered listeners if no type given). Allows to wait for
@@ -20795,14 +20797,15 @@ export interface Dialog {
2079520797
/**
2079620798
* [Disposable](https://playwright.dev/docs/api/class-disposable) is returned from various methods to allow undoing
2079720799
* the corresponding action. For example,
20798-
* [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) returns a
20799-
* [Disposable](https://playwright.dev/docs/api/class-disposable) that can be used to remove the init script.
20800+
* [page.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-page#page-add-init-script)
20801+
* returns a [Disposable](https://playwright.dev/docs/api/class-disposable) that can be used to remove the init
20802+
* script.
2080020803
*/
2080120804
export interface Disposable {
2080220805
/**
2080320806
* Removes the associated resource. For example, removes the init script installed via
20804-
* [page.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-page#page-add-init-script) or
20805-
* [browserContext.addInitScript(script[, arg])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script).
20807+
* [page.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-page#page-add-init-script) or
20808+
* [browserContext.addInitScript(script[, arg, options])](https://playwright.dev/docs/api/class-browsercontext#browser-context-add-init-script).
2080620809
*/
2080720810
dispose(): Promise<void>;
2080820811

packages/playwright-core/src/client/browserContext.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,17 @@ import { Events } from './events';
3737
import { APIRequestContext } from './fetch';
3838
import { Frame } from './frame';
3939
import { HarRouter } from './harRouter';
40+
import { assertEvaluateOptions } from './jsHandle';
4041
import * as network from './network';
41-
import { BindingCall, Page } from './page';
42+
import { BindingCall, Page, addInitScriptWithExposedFunctions } from './page';
4243
import { Tracing } from './tracing';
4344
import { Waiter } from './waiter';
4445
import { WebError } from './webError';
4546
import { Worker } from './worker';
4647
import { TimeoutSettings, kNoTimeout } from './timeoutSettings';
4748
import { mkdirIfNeeded } from './fileUtils';
4849

50+
import type { EvaluateOptions } from './jsHandle';
4951
import type { BrowserContextOptions, Headers, SetStorageState, StorageState, WaitForEventOptions } from './types';
5052
import type * as structs from '../../types/structs';
5153
import type * as api from '../../types/types';
@@ -358,7 +360,10 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
358360
await this._channel.setHTTPCredentials({ httpCredentials: httpCredentials || undefined }, kNoTimeout);
359361
}
360362

361-
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) {
363+
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any, options?: EvaluateOptions) {
364+
assertEvaluateOptions(options);
365+
if (options?.exposeFunctions)
366+
return await addInitScriptWithExposedFunctions(this, script, arg);
362367
const source = await evaluationScript(script, arg);
363368
return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable);
364369
}
@@ -369,6 +374,12 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
369374
return DisposableObject.from(result.disposable);
370375
}
371376

377+
async _exposeCallbackBinding(name: string, callback: Function): Promise<DisposableObject> {
378+
this._bindings.set(name, (source, ...args) => callback(...args));
379+
const result = await this._channel.exposeBinding({ name, noGlobal: true }, kNoTimeout);
380+
return DisposableObject.from(result.disposable);
381+
}
382+
372383
async exposeFunction(name: string, callback: Function): Promise<DisposableObject> {
373384
const result = await this._channel.exposeBinding({ name }, kNoTimeout);
374385
const binding = (source: structs.BindingSource, ...args: any[]) => callback(...args);

packages/playwright-core/src/client/channels.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1425,9 +1425,10 @@ export type BrowserContextCookiesResult = {
14251425
};
14261426
export type BrowserContextExposeBindingParams = {
14271427
name: string,
1428+
noGlobal?: boolean,
14281429
};
14291430
export type BrowserContextExposeBindingOptions = {
1430-
1431+
noGlobal?: boolean,
14311432
};
14321433
export type BrowserContextExposeBindingResult = {
14331434
disposable: DisposableChannel,

packages/playwright-core/src/client/clientHelper.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import fs from 'fs';
1919

2020
import { isString } from '@isomorphic/rtti';
21+
import { kBindingsControllerProperty, kFunctionBindingPrefix, serializeAsCallArgument } from '@isomorphic/utilityScriptSerializers';
22+
import { createGuid } from '@utils/crypto';
2123

2224
export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string, value: string }[] {
2325
const result: { name: string, value: string }[] = [];
@@ -49,6 +51,22 @@ export async function evaluationScript(fun: Function | string | { path?: string,
4951
throw new Error('Either path or content property must be present');
5052
}
5153

54+
export async function initScriptSourceWithExposedFunctions(fun: Function, arg: any, expose: (name: string, callback: Function) => Promise<void>): Promise<string> {
55+
const exposePromises: Promise<void>[] = [];
56+
const serialized = serializeAsCallArgument(arg, value => {
57+
if (typeof value === 'function') {
58+
const name = kFunctionBindingPrefix + createGuid();
59+
exposePromises.push(expose(name, value));
60+
return { fn: name };
61+
}
62+
return { fallThrough: value };
63+
});
64+
await Promise.all(exposePromises);
65+
// Bindings backing the functions are registered through their own init scripts
66+
// that are guaranteed to run first, so the controller is available here.
67+
return `(${fun.toString()})(globalThis['${kBindingsControllerProperty}'].parseInitScriptArg(${JSON.stringify(serialized)}))`;
68+
}
69+
5270
export function addSourceUrlToScript(source: string, path: string): string {
5371
return `${source}\n//# sourceURL=${path.replace(/\n/g, '')}`;
5472
}

packages/playwright-core/src/client/page.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { LongStandingScope } from '@isomorphic/manualPromise';
2828
import { isObject, isRegExp, isString } from '@isomorphic/rtti';
2929
import { Artifact } from './artifact';
3030
import { ChannelOwner } from './channelOwner';
31-
import { evaluationScript } from './clientHelper';
31+
import { evaluationScript, initScriptSourceWithExposedFunctions } from './clientHelper';
3232
import { Coverage } from './coverage';
3333
import { DisposableObject, DisposableStub } from './disposable';
3434
import { Download } from './download';
@@ -40,7 +40,7 @@ import { Frame, verifyLoadState } from './frame';
4040
import { HarRouter } from './harRouter';
4141
import { Keyboard, Mouse, Touchscreen } from './input';
4242
import { WebStorage } from './webStorage';
43-
import { assertMaxArguments, parseResult, serializeArgument } from './jsHandle';
43+
import { assertEvaluateOptions, assertMaxArguments, parseResult, serializeArgument } from './jsHandle';
4444
import { Request, Response, Route, RouteHandler, WebSocket, WebSocketRoute, WebSocketRouteHandler, validateHeaders } from './network';
4545
import { Video } from './video';
4646
import { Screencast } from './screencast';
@@ -376,10 +376,15 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
376376
return DisposableObject.from(result.disposable);
377377
}
378378

379-
async _exposeEvaluateCallback(name: string, callback: Function) {
379+
async _exposeCallbackBinding(name: string, callback: Function): Promise<DisposableObject> {
380380
this._bindings.set(name, (source, ...args) => callback(...args));
381381
const result = await this._channel.exposeBinding({ name, noGlobal: true }, kNoTimeout);
382-
this._evaluateCallbacks.push({ name, disposable: DisposableObject.from(result.disposable) });
382+
return DisposableObject.from(result.disposable);
383+
}
384+
385+
async _exposeEvaluateCallback(name: string, callback: Function) {
386+
const disposable = await this._exposeCallbackBinding(name, callback);
387+
this._evaluateCallbacks.push({ name, disposable });
383388
}
384389

385390
_eraseEvaluateCallbacks() {
@@ -549,7 +554,10 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
549554
return await this._mainFrame.evaluate(pageFunction, arg, options);
550555
}
551556

552-
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) {
557+
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any, options?: EvaluateOptions) {
558+
assertEvaluateOptions(options);
559+
if (options?.exposeFunctions)
560+
return await addInitScriptWithExposedFunctions(this, script, arg);
553561
const source = await evaluationScript(script, arg);
554562
return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable);
555563
}
@@ -947,3 +955,23 @@ function trimUrl(param: any): string | undefined {
947955
if (isString(param))
948956
return `"${trimStringWithEllipsis(param, 50)}"`;
949957
}
958+
959+
export async function addInitScriptWithExposedFunctions(owner: Page | BrowserContext, script: Function | string | { path?: string, content?: string }, arg: any): Promise<DisposableStub> {
960+
if (typeof script !== 'function')
961+
throw new Error('Passing functions requires the init script to be a function');
962+
const callbacks: { name: string, disposable: DisposableObject }[] = [];
963+
const source = await owner._wrapApiCall(async () => {
964+
return await initScriptSourceWithExposedFunctions(script, arg, async (name, callback) => {
965+
const disposable = await owner._exposeCallbackBinding(name, callback);
966+
callbacks.push({ name, disposable });
967+
});
968+
}, { internal: true });
969+
const initScriptDisposable = DisposableObject.from((await owner._channel.addInitScript({ source }, kNoTimeout)).disposable);
970+
return new DisposableStub(async () => {
971+
for (const { name, disposable } of callbacks) {
972+
owner._bindings.delete(name);
973+
disposable.dispose().catch(() => {});
974+
}
975+
await initScriptDisposable.dispose();
976+
});
977+
}

0 commit comments

Comments
 (0)