feat(exposeBinding): a more powerful exposeFunction with source attribution (#2263)

This commit is contained in:
Pavel Feldman 2020-05-18 14:28:06 -07:00 committed by GitHub
parent 40ea0dd23b
commit 2bd427ad1d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 157 additions and 41 deletions

View File

@ -297,6 +297,7 @@ await context.close();
- [browserContext.clearPermissions()](#browsercontextclearpermissions) - [browserContext.clearPermissions()](#browsercontextclearpermissions)
- [browserContext.close()](#browsercontextclose) - [browserContext.close()](#browsercontextclose)
- [browserContext.cookies([urls])](#browsercontextcookiesurls) - [browserContext.cookies([urls])](#browsercontextcookiesurls)
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction) - [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options) - [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
- [browserContext.newPage()](#browsercontextnewpage) - [browserContext.newPage()](#browsercontextnewpage)
@ -421,20 +422,54 @@ will be closed.
If no URLs are specified, this method returns all cookies. If no URLs are specified, this method returns all cookies.
If URLs are specified, only cookies that affect those URLs are returned. 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) #### browserContext.exposeFunction(name, playwrightFunction)
- `name` <[string]> Name of the function on the window object. - `name` <[string]> Name of the function on the window object.
- `playwrightFunction` <[function]> Callback function that will be called in the Playwright's context. - `playwrightFunction` <[function]> Callback function that will be called in the Playwright's context.
- returns: <[Promise]> - returns: <[Promise]>
The method adds a function called `name` on the `window` object of every frame in every page in the context. 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. If the `playwrightFunction` returns a [Promise], it will be awaited.
See [page.exposeFunction(name, playwrightFunction)](#pageexposefunctionname-playwrightfunction) for page-only version. 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: An example of adding an `md5` function to all pages in the context:
```js ```js
const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'. const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.
@ -678,6 +713,7 @@ page.removeListener('request', logRequest);
- [page.emulateMedia(options)](#pageemulatemediaoptions) - [page.emulateMedia(options)](#pageemulatemediaoptions)
- [page.evaluate(pageFunction[, arg])](#pageevaluatepagefunction-arg) - [page.evaluate(pageFunction[, arg])](#pageevaluatepagefunction-arg)
- [page.evaluateHandle(pageFunction[, arg])](#pageevaluatehandlepagefunction-arg) - [page.evaluateHandle(pageFunction[, arg])](#pageevaluatehandlepagefunction-arg)
- [page.exposeBinding(name, playwrightBinding)](#pageexposebindingname-playwrightbinding)
- [page.exposeFunction(name, playwrightFunction)](#pageexposefunctionname-playwrightfunction) - [page.exposeFunction(name, playwrightFunction)](#pageexposefunctionname-playwrightfunction)
- [page.fill(selector, value[, options])](#pagefillselector-value-options) - [page.fill(selector, value[, options])](#pagefillselector-value-options)
- [page.focus(selector[, options])](#pagefocusselector-options) - [page.focus(selector[, options])](#pagefocusselector-options)
@ -1165,13 +1201,51 @@ console.log(await resultHandle.jsonValue());
await resultHandle.dispose(); 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) #### page.exposeFunction(name, playwrightFunction)
- `name` <[string]> Name of the function on the window object - `name` <[string]> Name of the function on the window object
- `playwrightFunction` <[function]> Callback function which will be called in Playwright's context. - `playwrightFunction` <[function]> Callback function which will be called in Playwright's context.
- returns: <[Promise]> - returns: <[Promise]>
The method adds a function called `name` on the `window` object of every frame in the page. 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. If the `playwrightFunction` returns a [Promise], it will be awaited.
@ -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 ```js
const selector = '.foo'; const selector = '.foo';
@ -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 ```js
const selector = '.foo'; const selector = '.foo';
@ -4027,6 +4101,7 @@ const backgroundPage = await context.waitForEvent('backgroundpage');
- [browserContext.clearPermissions()](#browsercontextclearpermissions) - [browserContext.clearPermissions()](#browsercontextclearpermissions)
- [browserContext.close()](#browsercontextclose) - [browserContext.close()](#browsercontextclose)
- [browserContext.cookies([urls])](#browsercontextcookiesurls) - [browserContext.cookies([urls])](#browsercontextcookiesurls)
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction) - [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options) - [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
- [browserContext.newPage()](#browsercontextnewpage) - [browserContext.newPage()](#browsercontextnewpage)

View File

@ -25,6 +25,7 @@ import { ExtendedEventEmitter } from './extendedEventEmitter';
import { Download } from './download'; import { Download } from './download';
import { BrowserBase } from './browser'; import { BrowserBase } from './browser';
import { Log, InnerLogger, Logger, RootLogger } from './logger'; import { Log, InnerLogger, Logger, RootLogger } from './logger';
import { FunctionWithSource } from './frames';
export type BrowserContextOptions = { export type BrowserContextOptions = {
viewport?: types.Size | null, viewport?: types.Size | null,
@ -62,6 +63,7 @@ export interface BrowserContext extends InnerLogger {
setOffline(offline: boolean): Promise<void>; setOffline(offline: boolean): Promise<void>;
setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>; setHTTPCredentials(httpCredentials: types.Credentials | null): Promise<void>;
addInitScript(script: Function | string | { path?: string, content?: string }, arg?: any): 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>; exposeFunction(name: string, playwrightFunction: Function): Promise<void>;
route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>; route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>; unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>;
@ -126,11 +128,27 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements
abstract setExtraHTTPHeaders(headers: network.Headers): Promise<void>; abstract setExtraHTTPHeaders(headers: network.Headers): Promise<void>;
abstract setOffline(offline: boolean): Promise<void>; abstract setOffline(offline: boolean): Promise<void>;
abstract addInitScript(script: string | Function | { path?: string | undefined; content?: string | undefined; }, arg?: any): 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 route(url: types.URLMatch, handler: network.RouteHandler): Promise<void>;
abstract unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>; abstract unroute(url: types.URLMatch, handler?: network.RouteHandler): Promise<void>;
abstract close(): 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 }) { async grantPermissions(permissions: string[], options?: { origin?: string }) {
let origin = '*'; let origin = '*';
if (options && options.origin) { if (options && options.origin) {

View File

@ -405,15 +405,7 @@ export class CRBrowserContext extends BrowserContextBase {
await (page._delegate as CRPage).evaluateOnNewDocument(source); await (page._delegate as CRPage).evaluateOnNewDocument(source);
} }
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> { async _doExposeBinding(binding: PageBinding) {
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);
for (const page of this.pages()) for (const page of this.pages())
await (page._delegate as CRPage).exposeBinding(binding); await (page._delegate as CRPage).exposeBinding(binding);
} }

View File

@ -302,16 +302,8 @@ export class FFBrowserContext extends BrowserContextBase {
await this._browser._connection.send('Browser.addScriptToEvaluateOnNewDocument', { browserContextId: this._browserContextId || undefined, script: source }); await this._browser._connection.send('Browser.addScriptToEvaluateOnNewDocument', { browserContextId: this._browserContextId || undefined, script: source });
} }
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> { async _doExposeBinding(binding: PageBinding) {
for (const page of this.pages()) { await this._browser._connection.send('Browser.addBinding', { browserContextId: this._browserContextId || undefined, name: binding.name, script: binding.source });
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 route(url: types.URLMatch, handler: network.RouteHandler): Promise<void> { async route(url: types.URLMatch, handler: network.RouteHandler): Promise<void> {

View File

@ -28,6 +28,7 @@ import { Page } from './page';
import { selectors } from './selectors'; import { selectors } from './selectors';
import * as types from './types'; import * as types from './types';
import { waitForTimeoutWasUsed } from './hints'; import { waitForTimeoutWasUsed } from './hints';
import { BrowserContext } from './browserContext';
type ContextType = 'main' | 'utility'; type ContextType = 'main' | 'utility';
type ContextData = { type ContextData = {
@ -46,6 +47,8 @@ export type GotoResult = {
type ConsoleTagHandler = () => void; type ConsoleTagHandler = () => void;
export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any;
export class FrameManager { export class FrameManager {
private _page: Page; private _page: Page;
private _frames = new Map<string, Frame>(); private _frames = new Map<string, Frame>();

View File

@ -253,11 +253,15 @@ export class Page extends ExtendedEventEmitter implements InnerLogger {
} }
async exposeFunction(name: string, playwrightFunction: Function) { 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)) if (this._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered`); throw new Error(`Function "${name}" has been already registered`);
if (this._browserContext._pageBindings.has(name)) if (this._browserContext._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered in the browser context`); 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); this._pageBindings.set(name, binding);
await this._delegate.exposeBinding(binding); await this._delegate.exposeBinding(binding);
} }
@ -267,7 +271,7 @@ export class Page extends ExtendedEventEmitter implements InnerLogger {
return this._delegate.updateExtraHTTPHeaders(); return this._delegate.updateExtraHTTPHeaders();
} }
async _onBindingCalled(payload: string, context: js.ExecutionContext) { async _onBindingCalled(payload: string, context: dom.FrameExecutionContext) {
await PageBinding.dispatch(this, payload, context); await PageBinding.dispatch(this, payload, context);
} }
@ -580,23 +584,23 @@ export class Worker extends EventEmitter {
export class PageBinding { export class PageBinding {
readonly name: string; readonly name: string;
readonly playwrightFunction: Function; readonly playwrightFunction: frames.FunctionWithSource;
readonly source: string; readonly source: string;
constructor(name: string, playwrightFunction: Function) { constructor(name: string, playwrightFunction: frames.FunctionWithSource) {
this.name = name; this.name = name;
this.playwrightFunction = playwrightFunction; this.playwrightFunction = playwrightFunction;
this.source = helper.evaluationString(addPageBinding, name); 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); const {name, seq, args} = JSON.parse(payload);
let expression = null; let expression = null;
try { try {
let binding = page._pageBindings.get(name); let binding = page._pageBindings.get(name);
if (!binding) if (!binding)
binding = page._browserContext._pageBindings.get(name); 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); expression = helper.evaluationString(deliverResult, name, seq, result);
} catch (error) { } catch (error) {
if (error instanceof Error) if (error instanceof Error)

View File

@ -308,15 +308,7 @@ export class WKBrowserContext extends BrowserContextBase {
await (page._delegate as WKPage)._updateBootstrapScript(); await (page._delegate as WKPage)._updateBootstrapScript();
} }
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> { async _doExposeBinding(binding: PageBinding) {
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);
for (const page of this.pages()) for (const page of this.pages())
await (page._delegate as WKPage).exposeBinding(binding); await (page._delegate as WKPage).exposeBinding(binding);
} }

View File

@ -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', () => { describe('BrowserContext.exposeFunction', () => {
it('should work', async({browser, server}) => { it('should work', async({browser, server}) => {
const context = await browser.newContext(); const context = await browser.newContext();

View File

@ -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() { describe('Page.exposeFunction', function() {
it('should work', async({page, server}) => { it('should work', async({page, server}) => {
await page.exposeFunction('compute', function(a, b) { await page.exposeFunction('compute', function(a, b) {