Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/src/api/class-browsercontext.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,16 @@ browser_context.add_init_script(path="preload.js")
await Context.AddInitScriptAsync(scriptPath: "preload.js");
```

```js
// Pass a Node.js callback that any page in the context can call to report data back.
const loaded = [];
await browserContext.addInitScript(report => {
report(location.pathname);
}, message => {
loaded.push(message);
});
```

:::note
The order of evaluation of multiple scripts installed via [`method: BrowserContext.addInitScript`] and
[`method: Page.addInitScript`] is not defined.
Expand Down Expand Up @@ -462,6 +472,20 @@ Script to be evaluated in all pages in the browser context.

Optional argument to pass to [`param: script`] (only supported when passing a function).

### param: BrowserContext.addInitScript.callback

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be consistent with evaluate where we support function values in arguments?

* since: v1.62
* langs: js
- `callback` ?<[function]>

Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is
called. When [`param: script`] is a function it receives the callback as an argument, after
[`param: arg`] or as its only argument when [`param: arg`] is omitted. When [`param: script`] is a
string or file, the callback is available as a `callback` variable. The in-page callback returns a
[Promise] that resolves to the value returned by [`param: callback`]. The same callback is shared by
all current and future pages in the context and keeps working across navigations until the returned
[Disposable] is disposed. Since the init script's return value is not awaited, this is best suited for
reporting data back to the runner rather than blocking page scripts.

### param: BrowserContext.addInitScript.path
* since: v1.8
* langs: python
Expand Down
23 changes: 23 additions & 0 deletions docs/src/api/class-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,16 @@ await page.addInitScript(mock => {
}, mock);
```

```js
// Pass a Node.js callback that the page can call to report data back on every navigation.
const loaded = [];
await page.addInitScript(report => {
report(location.pathname);
}, message => {
loaded.push(message);
});
```

```java
// In your playwright script, assuming the preload.js file is in same directory
page.addInitScript(Paths.get("./preload.js"));
Expand Down Expand Up @@ -637,6 +647,19 @@ Script to be evaluated in all pages in the browser context.

Optional argument to pass to [`param: script`] (only supported when passing a function).

### param: Page.addInitScript.callback

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

* since: v1.62
* langs: js
- `callback` ?<[function]>

Optional callback invoked in the Playwright (Node.js) environment whenever the in-page callback is
called. When [`param: script`] is a function it receives the callback as an argument, after
[`param: arg`] or as its only argument when [`param: arg`] is omitted. When [`param: script`] is a
string or file, the callback is available as a `callback` variable. The in-page callback returns a
[Promise] that resolves to the value returned by [`param: callback`], and it keeps working across
navigations until the returned [Disposable] is disposed. Since the init script's return value is not
awaited, this is best suited for reporting data back to the runner rather than blocking page scripts.

### param: Page.addInitScript.path
* since: v1.8
* langs: python
Expand Down
313 changes: 302 additions & 11 deletions packages/playwright-client/types/types.d.ts

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions packages/playwright-core/src/client/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { rewriteErrorMessage } from '@utils/stackTrace';
import { Browser } from './browser';
import { CDPSession } from './cdpSession';
import { ChannelOwner } from './channelOwner';
import { evaluationScript } from './clientHelper';
import { addInitScriptWithCallback, evaluationScript } from './clientHelper';
import { Clock } from './clock';
import { ConsoleMessage } from './consoleMessage';
import { Credentials } from './credentials';
Expand Down Expand Up @@ -358,7 +358,22 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
await this._channel.setHTTPCredentials({ httpCredentials: httpCredentials || undefined }, kNoTimeout);
}

async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) {
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any, callback?: (...args: any[]) => any) {
// A function in place of `arg` is unambiguously the `callback`, since `arg` must be serializable.
const hasArg = callback !== undefined;
if (callback === undefined && typeof arg === 'function') {
callback = arg;
arg = undefined;
}
if (callback !== undefined) {
if (typeof callback !== 'function')
throw new Error(`callback: expected function, got ${typeof callback}`);
return await addInitScriptWithCallback(
this._bindings,
async params => (await this._channel.exposeBinding(params, kNoTimeout)).disposable,
async source => (await this._channel.addInitScript({ source }, kNoTimeout)).disposable,
script, arg, hasArg, callback);
}
const source = await evaluationScript(script, arg);
return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable);
}
Expand Down
65 changes: 60 additions & 5 deletions packages/playwright-core/src/client/clientHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
import fs from 'fs';

import { isString } from '@isomorphic/rtti';
import { kBindingsControllerProperty, kFunctionBindingPrefix } from '@isomorphic/utilityScriptSerializers';
import { createGuid } from '@utils/crypto';
import { DisposableObject, DisposableStub } from './disposable';

import type * as channels from './channels';
import type * as structs from '../../types/structs';

export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string, value: string }[] {
const result: { name: string, value: string }[] = [];
Expand All @@ -28,12 +34,13 @@ export function envObjectToArray(env: NodeJS.ProcessEnv): { name: string, value:
return result;
}

function serializeArgument(arg: any): string {
return Object.is(arg, undefined) ? 'undefined' : JSON.stringify(arg);
}

export async function evaluationScript(fun: Function | string | { path?: string, content?: string }, arg?: any, addSourceUrl: boolean = true): Promise<string> {
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))
Expand All @@ -52,3 +59,51 @@ export async function evaluationScript(fun: Function | string | { path?: string,
export function addSourceUrlToScript(source: string, path: string): string {
return `${source}\n//# sourceURL=${path.replace(/\n/g, '')}`;
}

// Installs an init script that can invoke an in-page `callback` bridged to the Node-side `callback`
// via the existing binding machinery (see `serializeArgumentWithCallbacks`). A function script gets
// the callback as an argument, and a string or file script can call it as `callback`. The returned
// disposable removes the init script, the binding, and the client-side binding entry.
export async function addInitScriptWithCallback(
bindings: Map<string, (source: structs.BindingSource, ...args: any[]) => any>,
exposeBinding: (params: { name: string }) => Promise<channels.DisposableChannel>,
addInitScript: (source: string) => Promise<channels.DisposableChannel>,
script: Function | string | { path?: string, content?: string },
arg: any,
hasArg: boolean,
callback: (...args: any[]) => any,
): Promise<DisposableStub> {
// Build the source before touching any state so a throw does not leak a registered binding.
const name = kFunctionBindingPrefix + createGuid();
const callbackExpression = `(...args) => globalThis[${JSON.stringify(kBindingsControllerProperty)}].callBinding(${JSON.stringify(name)}, ...args)`;
let source: string;
if (typeof script === 'function')
source = hasArg ? `(${script.toString()})(${serializeArgument(arg)}, ${callbackExpression})` : `(${script.toString()})(${callbackExpression})`;
else
source = `(callback => {\n${await evaluationScript(script, arg)}\n})(${callbackExpression})`;

bindings.set(name, (_source, ...args) => callback(...args));
let bindingDisposable: DisposableObject | undefined;
let scriptDisposable: DisposableObject | undefined;
try {
bindingDisposable = DisposableObject.from(await exposeBinding({ name }));
scriptDisposable = DisposableObject.from(await addInitScript(source));
} catch (error) {
await scriptDisposable?.dispose().catch(() => {});
await bindingDisposable?.dispose().catch(() => {});
bindings.delete(name);
throw error;
}

return new DisposableStub(async () => {
try {
await scriptDisposable!.dispose();
} finally {
try {
await bindingDisposable!.dispose();
} finally {
bindings.delete(name);
}
}
});
}
19 changes: 17 additions & 2 deletions packages/playwright-core/src/client/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { LongStandingScope } from '@isomorphic/manualPromise';
import { isObject, isRegExp, isString } from '@isomorphic/rtti';
import { Artifact } from './artifact';
import { ChannelOwner } from './channelOwner';
import { evaluationScript } from './clientHelper';
import { addInitScriptWithCallback, evaluationScript } from './clientHelper';
import { Coverage } from './coverage';
import { DisposableObject, DisposableStub } from './disposable';
import { Download } from './download';
Expand Down Expand Up @@ -549,7 +549,22 @@ export class Page extends ChannelOwner<channels.PageChannel> implements api.Page
return await this._mainFrame.evaluate(pageFunction, arg, options);
}

async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any) {
async addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any, callback?: (...args: any[]) => any) {
// A function in place of `arg` is unambiguously the `callback`, since `arg` must be serializable.
const hasArg = callback !== undefined;
if (callback === undefined && typeof arg === 'function') {
callback = arg;
arg = undefined;
}
if (callback !== undefined) {
if (typeof callback !== 'function')
throw new Error(`callback: expected function, got ${typeof callback}`);
return await addInitScriptWithCallback(
this._bindings,
async params => (await this._channel.exposeBinding(params, kNoTimeout)).disposable,
async source => (await this._channel.addInitScript({ source }, kNoTimeout)).disposable,
script, arg, hasArg, callback);
}
const source = await evaluationScript(script, arg);
return DisposableObject.from((await this._channel.addInitScript({ source }, kNoTimeout)).disposable);
}
Expand Down
Loading
Loading