Skip to content

Commit 2bd427a

Browse files
authored
feat(exposeBinding): a more powerful exposeFunction with source attribution (#2263)
1 parent 40ea0dd commit 2bd427a

File tree

9 files changed

+157
-41
lines changed

9 files changed

+157
-41
lines changed

docs/api.md

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ await context.close();
297297
- [browserContext.clearPermissions()](#browsercontextclearpermissions)
298298
- [browserContext.close()](#browsercontextclose)
299299
- [browserContext.cookies([urls])](#browsercontextcookiesurls)
300+
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
300301
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
301302
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
302303
- [browserContext.newPage()](#browsercontextnewpage)
@@ -421,20 +422,54 @@ will be closed.
421422
If no URLs are specified, this method returns all cookies.
422423
If URLs are specified, only cookies that affect those URLs are returned.
423424

425+
#### browserContext.exposeBinding(name, playwrightBinding)
426+
- `name` <[string]> Name of the function on the window object.
427+
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context.
428+
- returns: <[Promise]>
429+
430+
The method adds a function called `name` on the `window` object of every frame in every page in the context.
431+
When called, the function executes `playwrightBinding` in Node.js and returns a [Promise] which resolves to the return value of `playwrightBinding`.
432+
If the `playwrightBinding` returns a [Promise], it will be awaited.
433+
434+
The first argument of the `playwrightBinding` function contains information about the caller:
435+
`{ browserContext: BrowserContext, page: Page, frame: Frame }`.
436+
437+
See [page.exposeBinding(name, playwrightBinding)](#pageexposebindingname-playwrightbinding) for page-only version.
438+
439+
An example of exposing page URL to all frames in all pages in the context:
440+
```js
441+
const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.
442+
443+
(async () => {
444+
const browser = await webkit.launch({ headless: false });
445+
const context = await browser.newContext();
446+
await context.exposeBinding('pageURL', ({ page }) => page.url());
447+
const page = await context.newPage();
448+
await page.setContent(`
449+
<script>
450+
async function onClick() {
451+
document.querySelector('div').textContent = await window.pageURL();
452+
}
453+
</script>
454+
<button onclick="onClick()">Click me</button>
455+
<div></div>
456+
`);
457+
await page.click('button');
458+
})();
459+
```
460+
424461
#### browserContext.exposeFunction(name, playwrightFunction)
425462
- `name` <[string]> Name of the function on the window object.
426463
- `playwrightFunction` <[function]> Callback function that will be called in the Playwright's context.
427464
- returns: <[Promise]>
428465

429466
The method adds a function called `name` on the `window` object of every frame in every page in the context.
430-
When called, the function executes `playwrightFunction` in node.js and returns a [Promise] which resolves to the return value of `playwrightFunction`.
467+
When called, the function executes `playwrightFunction` in Node.js and returns a [Promise] which resolves to the return value of `playwrightFunction`.
431468

432469
If the `playwrightFunction` returns a [Promise], it will be awaited.
433470

434471
See [page.exposeFunction(name, playwrightFunction)](#pageexposefunctionname-playwrightfunction) for page-only version.
435472

436-
> **NOTE** Functions installed via `page.exposeFunction` survive navigations.
437-
438473
An example of adding an `md5` function to all pages in the context:
439474
```js
440475
const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.
@@ -678,6 +713,7 @@ page.removeListener('request', logRequest);
678713
- [page.emulateMedia(options)](#pageemulatemediaoptions)
679714
- [page.evaluate(pageFunction[, arg])](#pageevaluatepagefunction-arg)
680715
- [page.evaluateHandle(pageFunction[, arg])](#pageevaluatehandlepagefunction-arg)
716+
- [page.exposeBinding(name, playwrightBinding)](#pageexposebindingname-playwrightbinding)
681717
- [page.exposeFunction(name, playwrightFunction)](#pageexposefunctionname-playwrightfunction)
682718
- [page.fill(selector, value[, options])](#pagefillselector-value-options)
683719
- [page.focus(selector[, options])](#pagefocusselector-options)
@@ -1165,13 +1201,51 @@ console.log(await resultHandle.jsonValue());
11651201
await resultHandle.dispose();
11661202
```
11671203

1204+
#### page.exposeBinding(name, playwrightBinding)
1205+
- `name` <[string]> Name of the function on the window object.
1206+
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context.
1207+
- returns: <[Promise]>
1208+
1209+
The method adds a function called `name` on the `window` object of every frame in this page.
1210+
When called, the function executes `playwrightBinding` in Node.js and returns a [Promise] which resolves to the return value of `playwrightBinding`.
1211+
If the `playwrightBinding` returns a [Promise], it will be awaited.
1212+
1213+
The first argument of the `playwrightBinding` function contains information about the caller:
1214+
`{ browserContext: BrowserContext, page: Page, frame: Frame }`.
1215+
1216+
See [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding) for the context-wide version.
1217+
1218+
> **NOTE** Functions installed via `page.exposeBinding` survive navigations.
1219+
1220+
An example of exposing page URL to all frames in a page:
1221+
```js
1222+
const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.
1223+
1224+
(async () => {
1225+
const browser = await webkit.launch({ headless: false });
1226+
const context = await browser.newContext();
1227+
const page = await context.newPage();
1228+
await page.exposeBinding('pageURL', ({ page }) => page.url());
1229+
await page.setContent(`
1230+
<script>
1231+
async function onClick() {
1232+
document.querySelector('div').textContent = await window.pageURL();
1233+
}
1234+
</script>
1235+
<button onclick="onClick()">Click me</button>
1236+
<div></div>
1237+
`);
1238+
await page.click('button');
1239+
})();
1240+
```
1241+
11681242
#### page.exposeFunction(name, playwrightFunction)
11691243
- `name` <[string]> Name of the function on the window object
11701244
- `playwrightFunction` <[function]> Callback function which will be called in Playwright's context.
11711245
- returns: <[Promise]>
11721246

11731247
The method adds a function called `name` on the `window` object of every frame in the page.
1174-
When called, the function executes `playwrightFunction` in node.js and returns a [Promise] which resolves to the return value of `playwrightFunction`.
1248+
When called, the function executes `playwrightFunction` in Node.js and returns a [Promise] which resolves to the return value of `playwrightFunction`.
11751249

11761250
If the `playwrightFunction` returns a [Promise], it will be awaited.
11771251

@@ -1720,7 +1794,7 @@ const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.
17201794
})();
17211795
```
17221796

1723-
To pass an argument from node.js to the predicate of `page.waitForFunction` function:
1797+
To pass an argument from Node.js to the predicate of `page.waitForFunction` function:
17241798

17251799
```js
17261800
const selector = '.foo';
@@ -2389,7 +2463,7 @@ const { firefox } = require('playwright'); // Or 'chromium' or 'webkit'.
23892463
})();
23902464
```
23912465

2392-
To pass an argument from node.js to the predicate of `frame.waitForFunction` function:
2466+
To pass an argument from Node.js to the predicate of `frame.waitForFunction` function:
23932467

23942468
```js
23952469
const selector = '.foo';
@@ -4027,6 +4101,7 @@ const backgroundPage = await context.waitForEvent('backgroundpage');
40274101
- [browserContext.clearPermissions()](#browsercontextclearpermissions)
40284102
- [browserContext.close()](#browsercontextclose)
40294103
- [browserContext.cookies([urls])](#browsercontextcookiesurls)
4104+
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
40304105
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
40314106
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
40324107
- [browserContext.newPage()](#browsercontextnewpage)

src/browserContext.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { ExtendedEventEmitter } from './extendedEventEmitter';
2525
import { Download } from './download';
2626
import { BrowserBase } from './browser';
2727
import { Log, InnerLogger, Logger, RootLogger } from './logger';
28+
import { FunctionWithSource } from './frames';
2829

2930
export type BrowserContextOptions = {
3031
viewport?: types.Size | null,
@@ -62,6 +63,7 @@ export interface BrowserContext extends InnerLogger {
6263
setOffline(offline: boolean): Promise<void>;
6364
setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>;
6465
addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise<void>;
66+
exposeBinding(name: string, playwrightBinding: FunctionWithSource): Promise<void>;
6567
exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
6668
route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
6769
unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>;
@@ -126,11 +128,27 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements
126128
abstract setExtraHTTPHeaders(headers: network.Headers): Promise<void>;
127129
abstract setOffline(offline: boolean): Promise<void>;
128130
abstract addInitScript(script: string | Function | { path?: string | undefined; content?: string | undefined; }, arg?: any): Promise<void>;
129-
abstract exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
131+
abstract _doExposeBinding(binding: PageBinding): Promise<void>;
130132
abstract route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
131133
abstract unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>;
132134
abstract close(): Promise<void>;
133135

136+
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
137+
await this.exposeBinding(name, (options, ...args: any) => playwrightFunction(...args));
138+
}
139+
140+
async exposeBinding(name: string, playwrightBinding: FunctionWithSource): Promise<void> {
141+
for (const page of this.pages()) {
142+
if (page._pageBindings.has(name))
143+
throw new Error(`Function "${name}" has been already registered in one of the pages`);
144+
}
145+
if (this._pageBindings.has(name))
146+
throw new Error(`Function "${name}" has been already registered`);
147+
const binding = new PageBinding(name, playwrightBinding);
148+
this._pageBindings.set(name, binding);
149+
this._doExposeBinding(binding);
150+
}
151+
134152
async grantPermissions(permissions: string[], options?: { origin?: string }) {
135153
let origin = '*';
136154
if (options && options.origin) {

src/chromium/crBrowser.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -405,15 +405,7 @@ export class CRBrowserContext extends BrowserContextBase {
405405
await (page._delegate as CRPage).evaluateOnNewDocument(source);
406406
}
407407

408-
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
409-
for (const page of this.pages()) {
410-
if (page._pageBindings.has(name))
411-
throw new Error(`Function "${name}" has been already registered in one of the pages`);
412-
}
413-
if (this._pageBindings.has(name))
414-
throw new Error(`Function "${name}" has been already registered`);
415-
const binding = new PageBinding(name, playwrightFunction);
416-
this._pageBindings.set(name, binding);
408+
async _doExposeBinding(binding: PageBinding) {
417409
for (const page of this.pages())
418410
await (page._delegate as CRPage).exposeBinding(binding);
419411
}

src/firefox/ffBrowser.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -302,16 +302,8 @@ export class FFBrowserContext extends BrowserContextBase {
302302
await this._browser._connection.send('Browser.addScriptToEvaluateOnNewDocument', { browserContextId: this._browserContextId || undefined, script: source });
303303
}
304304

305-
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
306-
for (const page of this.pages()) {
307-
if (page._pageBindings.has(name))
308-
throw new Error(`Function "${name}" has been already registered in one of the pages`);
309-
}
310-
if (this._pageBindings.has(name))
311-
throw new Error(`Function "${name}" has been already registered`);
312-
const binding = new PageBinding(name, playwrightFunction);
313-
this._pageBindings.set(name, binding);
314-
await this._browser._connection.send('Browser.addBinding', { browserContextId: this._browserContextId || undefined, name, script: binding.source });
305+
async _doExposeBinding(binding: PageBinding) {
306+
await this._browser._connection.send('Browser.addBinding', { browserContextId: this._browserContextId || undefined, name: binding.name, script: binding.source });
315307
}
316308

317309
async route(url: types.URLMatch, handler: network.RouteHandler): Promise<void> {

src/frames.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Page } from './page';
2828
import { selectors } from './selectors';
2929
import * as types from './types';
3030
import { waitForTimeoutWasUsed } from './hints';
31+
import { BrowserContext } from './browserContext';
3132

3233
type ContextType = 'main' | 'utility';
3334
type ContextData = {
@@ -46,6 +47,8 @@ export type GotoResult = {
4647

4748
type ConsoleTagHandler = () => void;
4849

50+
export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any;
51+
4952
export class FrameManager {
5053
private _page: Page;
5154
private _frames = new Map<string, Frame>();

src/page.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,15 @@ export class Page extends ExtendedEventEmitter implements InnerLogger {
253253
}
254254

255255
async exposeFunction(name: string, playwrightFunction: Function) {
256+
await this.exposeBinding(name, (options, ...args: any) => playwrightFunction(...args));
257+
}
258+
259+
async exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource) {
256260
if (this._pageBindings.has(name))
257261
throw new Error(`Function "${name}" has been already registered`);
258262
if (this._browserContext._pageBindings.has(name))
259263
throw new Error(`Function "${name}" has been already registered in the browser context`);
260-
const binding = new PageBinding(name, playwrightFunction);
264+
const binding = new PageBinding(name, playwrightBinding);
261265
this._pageBindings.set(name, binding);
262266
await this._delegate.exposeBinding(binding);
263267
}
@@ -267,7 +271,7 @@ export class Page extends ExtendedEventEmitter implements InnerLogger {
267271
return this._delegate.updateExtraHTTPHeaders();
268272
}
269273

270-
async _onBindingCalled(payload: string, context: js.ExecutionContext) {
274+
async _onBindingCalled(payload: string, context: dom.FrameExecutionContext) {
271275
await PageBinding.dispatch(this, payload, context);
272276
}
273277

@@ -580,23 +584,23 @@ export class Worker extends EventEmitter {
580584

581585
export class PageBinding {
582586
readonly name: string;
583-
readonly playwrightFunction: Function;
587+
readonly playwrightFunction: frames.FunctionWithSource;
584588
readonly source: string;
585589

586-
constructor(name: string, playwrightFunction: Function) {
590+
constructor(name: string, playwrightFunction: frames.FunctionWithSource) {
587591
this.name = name;
588592
this.playwrightFunction = playwrightFunction;
589593
this.source = helper.evaluationString(addPageBinding, name);
590594
}
591595

592-
static async dispatch(page: Page, payload: string, context: js.ExecutionContext) {
596+
static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) {
593597
const {name, seq, args} = JSON.parse(payload);
594598
let expression = null;
595599
try {
596600
let binding = page._pageBindings.get(name);
597601
if (!binding)
598602
binding = page._browserContext._pageBindings.get(name);
599-
const result = await binding!.playwrightFunction(...args);
603+
const result = await binding!.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args);
600604
expression = helper.evaluationString(deliverResult, name, seq, result);
601605
} catch (error) {
602606
if (error instanceof Error)

src/webkit/wkBrowser.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -308,15 +308,7 @@ export class WKBrowserContext extends BrowserContextBase {
308308
await (page._delegate as WKPage)._updateBootstrapScript();
309309
}
310310

311-
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
312-
for (const page of this.pages()) {
313-
if (page._pageBindings.has(name))
314-
throw new Error(`Function "${name}" has been already registered in one of the pages`);
315-
}
316-
if (this._pageBindings.has(name))
317-
throw new Error(`Function "${name}" has been already registered`);
318-
const binding = new PageBinding(name, playwrightFunction);
319-
this._pageBindings.set(name, binding);
311+
async _doExposeBinding(binding: PageBinding) {
320312
for (const page of this.pages())
321313
await (page._delegate as WKPage).exposeBinding(binding);
322314
}

test/browsercontext.spec.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,26 @@ describe('BrowserContext.pages()', function() {
326326
});
327327
});
328328

329+
describe('BrowserContext.exposeBinding', () => {
330+
it('should work', async({browser}) => {
331+
const context = await browser.newContext();
332+
let bindingSource;
333+
await context.exposeBinding('add', (source, a, b) => {
334+
bindingSource = source;
335+
return a + b;
336+
});
337+
const page = await context.newPage();
338+
const result = await page.evaluate(async function() {
339+
return add(5, 6);
340+
});
341+
expect(bindingSource.context).toBe(context);
342+
expect(bindingSource.page).toBe(page);
343+
expect(bindingSource.frame).toBe(page.mainFrame());
344+
expect(result).toEqual(11);
345+
await context.close();
346+
});
347+
});
348+
329349
describe('BrowserContext.exposeFunction', () => {
330350
it('should work', async({browser, server}) => {
331351
const context = await browser.newContext();

test/page.spec.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,26 @@ describe('Page.waitForResponse', function() {
405405
});
406406
});
407407

408+
describe('Page.exposeBinding', () => {
409+
it('should work', async({browser}) => {
410+
const context = await browser.newContext();
411+
const page = await context.newPage();
412+
let bindingSource;
413+
await page.exposeBinding('add', (source, a, b) => {
414+
bindingSource = source;
415+
return a + b;
416+
});
417+
const result = await page.evaluate(async function() {
418+
return add(5, 6);
419+
});
420+
expect(bindingSource.context).toBe(context);
421+
expect(bindingSource.page).toBe(page);
422+
expect(bindingSource.frame).toBe(page.mainFrame());
423+
expect(result).toEqual(11);
424+
await context.close();
425+
});
426+
});
427+
408428
describe('Page.exposeFunction', function() {
409429
it('should work', async({page, server}) => {
410430
await page.exposeFunction('compute', function(a, b) {

0 commit comments

Comments
 (0)