Skip to content

Commit

Permalink
feat(exposeBinding): a more powerful exposeFunction with source attri…
Browse files Browse the repository at this point in the history
…bution (#2263)
  • Loading branch information
pavelfeldman committed May 18, 2020
1 parent 40ea0dd commit 2bd427a
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 41 deletions.
87 changes: 81 additions & 6 deletions docs/api.md
Expand Up @@ -297,6 +297,7 @@ await context.close();
- [browserContext.clearPermissions()](#browsercontextclearpermissions)
- [browserContext.close()](#browsercontextclose)
- [browserContext.cookies([urls])](#browsercontextcookiesurls)
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
- [browserContext.newPage()](#browsercontextnewpage)
Expand Down Expand Up @@ -421,20 +422,54 @@ will be closed.
If no URLs are specified, this method returns all cookies.
If URLs are specified, only cookies that affect those URLs are returned.

#### browserContext.exposeBinding(name, playwrightBinding)
- `name` <[string]> Name of the function on the window object.
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context.
- returns: <[Promise]>

The method adds a function called `name` on the `window` object of every frame in every page in the context.
When called, the function executes `playwrightBinding` in Node.js and returns a [Promise] which resolves to the return value of `playwrightBinding`.
If the `playwrightBinding` returns a [Promise], it will be awaited.

The first argument of the `playwrightBinding` function contains information about the caller:
`{ browserContext: BrowserContext, page: Page, frame: Frame }`.

See [page.exposeBinding(name, playwrightBinding)](#pageexposebindingname-playwrightbinding) for page-only version.

An example of exposing page URL to all frames in all pages in the context:
```js
const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.

(async () => {
const browser = await webkit.launch({ headless: false });
const context = await browser.newContext();
await context.exposeBinding('pageURL', ({ page }) => page.url());
const page = await context.newPage();
await page.setContent(`
<script>
async function onClick() {
document.querySelector('div').textContent = await window.pageURL();
}
</script>
<button onclick="onClick()">Click me</button>
<div></div>
`);
await page.click('button');
})();
```

#### browserContext.exposeFunction(name, playwrightFunction)
- `name` <[string]> Name of the function on the window object.
- `playwrightFunction` <[function]> Callback function that will be called in the Playwright's context.
- returns: <[Promise]>

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

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

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

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

#### page.exposeBinding(name, playwrightBinding)
- `name` <[string]> Name of the function on the window object.
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context.
- returns: <[Promise]>

The method adds a function called `name` on the `window` object of every frame in this page.
When called, the function executes `playwrightBinding` in Node.js and returns a [Promise] which resolves to the return value of `playwrightBinding`.
If the `playwrightBinding` returns a [Promise], it will be awaited.

The first argument of the `playwrightBinding` function contains information about the caller:
`{ browserContext: BrowserContext, page: Page, frame: Frame }`.

See [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding) for the context-wide version.

> **NOTE** Functions installed via `page.exposeBinding` survive navigations.
An example of exposing page URL to all frames in a page:
```js
const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.

(async () => {
const browser = await webkit.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
await page.exposeBinding('pageURL', ({ page }) => page.url());
await page.setContent(`
<script>
async function onClick() {
document.querySelector('div').textContent = await window.pageURL();
}
</script>
<button onclick="onClick()">Click me</button>
<div></div>
`);
await page.click('button');
})();
```

#### page.exposeFunction(name, playwrightFunction)
- `name` <[string]> Name of the function on the window object
- `playwrightFunction` <[function]> Callback function which will be called in Playwright's context.
- returns: <[Promise]>

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

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

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

To pass an argument from node.js to the predicate of `page.waitForFunction` function:
To pass an argument from Node.js to the predicate of `page.waitForFunction` function:

```js
const selector = '.foo';
Expand Down Expand Up @@ -2389,7 +2463,7 @@ const { firefox } = require('playwright'); // Or 'chromium' or 'webkit'.
})();
```

To pass an argument from node.js to the predicate of `frame.waitForFunction` function:
To pass an argument from Node.js to the predicate of `frame.waitForFunction` function:

```js
const selector = '.foo';
Expand Down Expand Up @@ -4027,6 +4101,7 @@ const backgroundPage = await context.waitForEvent('backgroundpage');
- [browserContext.clearPermissions()](#browsercontextclearpermissions)
- [browserContext.close()](#browsercontextclose)
- [browserContext.cookies([urls])](#browsercontextcookiesurls)
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
- [browserContext.newPage()](#browsercontextnewpage)
Expand Down
20 changes: 19 additions & 1 deletion src/browserContext.ts
Expand Up @@ -25,6 +25,7 @@ import { ExtendedEventEmitter } from './extendedEventEmitter';
import { Download } from './download';
import { BrowserBase } from './browser';
import { Log, InnerLogger, Logger, RootLogger } from './logger';
import { FunctionWithSource } from './frames';

export type BrowserContextOptions = {
viewport?: types.Size | null,
Expand Down Expand Up @@ -62,6 +63,7 @@ export interface BrowserContext extends InnerLogger {
setOffline(offline: boolean): Promise<void>;
setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>;
addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): Promise<void>;
exposeBinding(name: string, playwrightBinding: FunctionWithSource): Promise<void>;
exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>;
Expand Down Expand Up @@ -126,11 +128,27 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements
abstract setExtraHTTPHeaders(headers: network.Headers): Promise<void>;
abstract setOffline(offline: boolean): Promise<void>;
abstract addInitScript(script: string | Function | { path?: string | undefined; content?: string | undefined; }, arg?: any): Promise<void>;
abstract exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
abstract _doExposeBinding(binding: PageBinding): Promise<void>;
abstract route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
abstract unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>;
abstract close(): Promise<void>;

async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
await this.exposeBinding(name, (options, ...args: any) => playwrightFunction(...args));
}

async exposeBinding(name: string, playwrightBinding: FunctionWithSource): Promise<void> {
for (const page of this.pages()) {
if (page._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered in one of the pages`);
}
if (this._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered`);
const binding = new PageBinding(name, playwrightBinding);
this._pageBindings.set(name, binding);
this._doExposeBinding(binding);
}

async grantPermissions(permissions: string[], options?: { origin?: string }) {
let origin = '*';
if (options && options.origin) {
Expand Down
10 changes: 1 addition & 9 deletions src/chromium/crBrowser.ts
Expand Up @@ -405,15 +405,7 @@ export class CRBrowserContext extends BrowserContextBase {
await (page._delegate as CRPage).evaluateOnNewDocument(source);
}

async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
for (const page of this.pages()) {
if (page._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered in one of the pages`);
}
if (this._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered`);
const binding = new PageBinding(name, playwrightFunction);
this._pageBindings.set(name, binding);
async _doExposeBinding(binding: PageBinding) {
for (const page of this.pages())
await (page._delegate as CRPage).exposeBinding(binding);
}
Expand Down
12 changes: 2 additions & 10 deletions src/firefox/ffBrowser.ts
Expand Up @@ -302,16 +302,8 @@ export class FFBrowserContext extends BrowserContextBase {
await this._browser._connection.send('Browser.addScriptToEvaluateOnNewDocument', { browserContextId: this._browserContextId || undefined, script: source });
}

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

async route(url: types.URLMatch, handler: network.RouteHandler): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions src/frames.ts
Expand Up @@ -28,6 +28,7 @@ import { Page } from './page';
import { selectors } from './selectors';
import * as types from './types';
import { waitForTimeoutWasUsed } from './hints';
import { BrowserContext } from './browserContext';

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

type ConsoleTagHandler = () => void;

export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any;

export class FrameManager {
private _page: Page;
private _frames = new Map<string, Frame>();
Expand Down
16 changes: 10 additions & 6 deletions src/page.ts
Expand Up @@ -253,11 +253,15 @@ export class Page extends ExtendedEventEmitter implements InnerLogger {
}

async exposeFunction(name: string, playwrightFunction: Function) {
await this.exposeBinding(name, (options, ...args: any) => playwrightFunction(...args));
}

async exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource) {
if (this._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered`);
if (this._browserContext._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered in the browser context`);
const binding = new PageBinding(name, playwrightFunction);
const binding = new PageBinding(name, playwrightBinding);
this._pageBindings.set(name, binding);
await this._delegate.exposeBinding(binding);
}
Expand All @@ -267,7 +271,7 @@ export class Page extends ExtendedEventEmitter implements InnerLogger {
return this._delegate.updateExtraHTTPHeaders();
}

async _onBindingCalled(payload: string, context: js.ExecutionContext) {
async _onBindingCalled(payload: string, context: dom.FrameExecutionContext) {
await PageBinding.dispatch(this, payload, context);
}

Expand Down Expand Up @@ -580,23 +584,23 @@ export class Worker extends EventEmitter {

export class PageBinding {
readonly name: string;
readonly playwrightFunction: Function;
readonly playwrightFunction: frames.FunctionWithSource;
readonly source: string;

constructor(name: string, playwrightFunction: Function) {
constructor(name: string, playwrightFunction: frames.FunctionWithSource) {
this.name = name;
this.playwrightFunction = playwrightFunction;
this.source = helper.evaluationString(addPageBinding, name);
}

static async dispatch(page: Page, payload: string, context: js.ExecutionContext) {
static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) {
const {name, seq, args} = JSON.parse(payload);
let expression = null;
try {
let binding = page._pageBindings.get(name);
if (!binding)
binding = page._browserContext._pageBindings.get(name);
const result = await binding!.playwrightFunction(...args);
const result = await binding!.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args);
expression = helper.evaluationString(deliverResult, name, seq, result);
} catch (error) {
if (error instanceof Error)
Expand Down
10 changes: 1 addition & 9 deletions src/webkit/wkBrowser.ts
Expand Up @@ -308,15 +308,7 @@ export class WKBrowserContext extends BrowserContextBase {
await (page._delegate as WKPage)._updateBootstrapScript();
}

async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
for (const page of this.pages()) {
if (page._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered in one of the pages`);
}
if (this._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered`);
const binding = new PageBinding(name, playwrightFunction);
this._pageBindings.set(name, binding);
async _doExposeBinding(binding: PageBinding) {
for (const page of this.pages())
await (page._delegate as WKPage).exposeBinding(binding);
}
Expand Down
20 changes: 20 additions & 0 deletions test/browsercontext.spec.js
Expand Up @@ -326,6 +326,26 @@ describe('BrowserContext.pages()', function() {
});
});

describe('BrowserContext.exposeBinding', () => {
it('should work', async({browser}) => {
const context = await browser.newContext();
let bindingSource;
await context.exposeBinding('add', (source, a, b) => {
bindingSource = source;
return a + b;
});
const page = await context.newPage();
const result = await page.evaluate(async function() {
return add(5, 6);
});
expect(bindingSource.context).toBe(context);
expect(bindingSource.page).toBe(page);
expect(bindingSource.frame).toBe(page.mainFrame());
expect(result).toEqual(11);
await context.close();
});
});

describe('BrowserContext.exposeFunction', () => {
it('should work', async({browser, server}) => {
const context = await browser.newContext();
Expand Down
20 changes: 20 additions & 0 deletions test/page.spec.js
Expand Up @@ -405,6 +405,26 @@ describe('Page.waitForResponse', function() {
});
});

describe('Page.exposeBinding', () => {
it('should work', async({browser}) => {
const context = await browser.newContext();
const page = await context.newPage();
let bindingSource;
await page.exposeBinding('add', (source, a, b) => {
bindingSource = source;
return a + b;
});
const result = await page.evaluate(async function() {
return add(5, 6);
});
expect(bindingSource.context).toBe(context);
expect(bindingSource.page).toBe(page);
expect(bindingSource.frame).toBe(page.mainFrame());
expect(result).toEqual(11);
await context.close();
});
});

describe('Page.exposeFunction', function() {
it('should work', async({page, server}) => {
await page.exposeFunction('compute', function(a, b) {
Expand Down

0 comments on commit 2bd427a

Please sign in to comment.