Skip to content

Commit

Permalink
api: allow exposeBinding to pass handles (#4030)
Browse files Browse the repository at this point in the history
This adds an option `{ handle: true }` to pass a single handle instead of arbitrary json values.
  • Loading branch information
dgozman committed Oct 2, 2020
1 parent c217121 commit 5e42029
Show file tree
Hide file tree
Showing 15 changed files with 203 additions and 51 deletions.
46 changes: 39 additions & 7 deletions docs/api.md
Expand Up @@ -314,7 +314,7 @@ await context.close();
- [browserContext.clearPermissions()](#browsercontextclearpermissions)
- [browserContext.close()](#browsercontextclose)
- [browserContext.cookies([urls])](#browsercontextcookiesurls)
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
- [browserContext.exposeBinding(name, playwrightBinding[, options])](#browsercontextexposebindingname-playwrightbinding-options)
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
- [browserContext.newPage()](#browsercontextnewpage)
Expand Down Expand Up @@ -443,9 +443,11 @@ 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)
#### browserContext.exposeBinding(name, playwrightBinding[, options])
- `name` <[string]> Name of the function on the window object.
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context.
- `options` <[Object]>
- `handle` <[boolean]> Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is supported. When passing by value, multiple arguments are supported.
- returns: <[Promise]>

The method adds a function called `name` on the `window` object of every frame in every page in the context.
Expand All @@ -455,7 +457,7 @@ 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.
See [page.exposeBinding(name, playwrightBinding)](#pageexposebindingname-playwrightbinding-options) for page-only version.

An example of exposing page URL to all frames in all pages in the context:
```js
Expand All @@ -479,6 +481,20 @@ const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.
})();
```

An example of passing an element handle:
```js
await context.exposeBinding('clicked', async (source, element) => {
console.log(await element.textContent());
}, { handle: true });
await page.setContent(`
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
`);
```

#### 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.
Expand Down Expand Up @@ -735,7 +751,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.exposeBinding(name, playwrightBinding[, options])](#pageexposebindingname-playwrightbinding-options)
- [page.exposeFunction(name, playwrightFunction)](#pageexposefunctionname-playwrightfunction)
- [page.fill(selector, value[, options])](#pagefillselector-value-options)
- [page.focus(selector[, options])](#pagefocusselector-options)
Expand Down Expand Up @@ -1264,9 +1280,11 @@ console.log(await resultHandle.jsonValue());
await resultHandle.dispose();
```

#### page.exposeBinding(name, playwrightBinding)
#### page.exposeBinding(name, playwrightBinding[, options])
- `name` <[string]> Name of the function on the window object.
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context.
- `options` <[Object]>
- `handle` <[boolean]> Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is supported. When passing by value, multiple arguments are supported.
- returns: <[Promise]>

The method adds a function called `name` on the `window` object of every frame in this page.
Expand All @@ -1276,7 +1294,7 @@ 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.
See [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding-options) for the context-wide version.

> **NOTE** Functions installed via `page.exposeBinding` survive navigations.
Expand All @@ -1302,6 +1320,20 @@ const { webkit } = require('playwright'); // Or 'chromium' or 'firefox'.
})();
```

An example of passing an element handle:
```js
await page.exposeBinding('clicked', async (source, element) => {
console.log(await element.textContent());
}, { handle: true });
await page.setContent(`
<script>
document.addEventListener('click', event => window.clicked(event.target));
</script>
<div>Click me</div>
<div>Or click me</div>
`);
```

#### 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.
Expand Down Expand Up @@ -4409,7 +4441,7 @@ const backgroundPage = await context.waitForEvent('backgroundpage');
- [browserContext.clearPermissions()](#browsercontextclearpermissions)
- [browserContext.close()](#browsercontextclose)
- [browserContext.cookies([urls])](#browsercontextcookiesurls)
- [browserContext.exposeBinding(name, playwrightBinding)](#browsercontextexposebindingname-playwrightbinding)
- [browserContext.exposeBinding(name, playwrightBinding[, options])](#browsercontextexposebindingname-playwrightbinding-options)
- [browserContext.exposeFunction(name, playwrightFunction)](#browsercontextexposefunctionname-playwrightfunction)
- [browserContext.grantPermissions(permissions[][, options])](#browsercontextgrantpermissionspermissions-options)
- [browserContext.newPage()](#browsercontextnewpage)
Expand Down
21 changes: 9 additions & 12 deletions src/client/browserContext.ts
Expand Up @@ -15,8 +15,7 @@
* limitations under the License.
*/

import * as frames from './frame';
import { Page, BindingCall } from './page';
import { Page, BindingCall, FunctionWithSource } from './page';
import * as network from './network';
import * as channels from '../protocol/channels';
import { ChannelOwner } from './channelOwner';
Expand All @@ -34,7 +33,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
private _routes: { url: URLMatch, handler: network.RouteHandler }[] = [];
readonly _browser: Browser | null = null;
readonly _browserName: string;
readonly _bindings = new Map<string, frames.FunctionWithSource>();
readonly _bindings = new Map<string, FunctionWithSource>();
_timeoutSettings = new TimeoutSettings();
_ownerPage: Page | undefined;
private _closedPromise: Promise<void>;
Expand Down Expand Up @@ -176,21 +175,19 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
});
}

async exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource): Promise<void> {
async exposeBinding(name: string, playwrightBinding: FunctionWithSource, options: { handle?: boolean } = {}): Promise<void> {
return this._wrapApiCall('browserContext.exposeBinding', async () => {
for (const page of this.pages()) {
if (page._bindings.has(name))
throw new Error(`Function "${name}" has been already registered in one of the pages`);
}
if (this._bindings.has(name))
throw new Error(`Function "${name}" has been already registered`);
await this._channel.exposeBinding({ name, needsHandle: options.handle });
this._bindings.set(name, playwrightBinding);
await this._channel.exposeBinding({ name });
});
}

async exposeFunction(name: string, playwrightFunction: Function): Promise<void> {
await this.exposeBinding(name, (source, ...args) => playwrightFunction(...args));
return this._wrapApiCall('browserContext.exposeFunction', async () => {
await this._channel.exposeBinding({ name });
const binding: FunctionWithSource = (source, ...args) => playwrightFunction(...args);
this._bindings.set(name, binding);
});
}

async route(url: URLMatch, handler: network.RouteHandler): Promise<void> {
Expand Down
2 changes: 0 additions & 2 deletions src/client/frame.ts
Expand Up @@ -17,7 +17,6 @@

import { assert } from '../utils/utils';
import * as channels from '../protocol/channels';
import { BrowserContext } from './browserContext';
import { ChannelOwner } from './channelOwner';
import { ElementHandle, convertSelectOptionValues, convertInputFiles } from './elementHandle';
import { assertMaxArguments, JSHandle, Func1, FuncOn, SmartHandle, serializeArgument, parseResult } from './jsHandle';
Expand All @@ -33,7 +32,6 @@ import { urlMatches } from './clientHelper';

const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));

export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame }, ...args: any) => any;
export type WaitForNavigationOptions = {
timeout?: number,
waitUntil?: LifecycleEvent,
Expand Down
25 changes: 15 additions & 10 deletions src/client/page.ts
Expand Up @@ -28,9 +28,9 @@ import { Dialog } from './dialog';
import { Download } from './download';
import { ElementHandle, determineScreenshotType } from './elementHandle';
import { Worker } from './worker';
import { Frame, FunctionWithSource, verifyLoadState, WaitForNavigationOptions } from './frame';
import { Frame, verifyLoadState, WaitForNavigationOptions } from './frame';
import { Keyboard, Mouse } from './input';
import { assertMaxArguments, Func1, FuncOn, SmartHandle, serializeArgument, parseResult } from './jsHandle';
import { assertMaxArguments, Func1, FuncOn, SmartHandle, serializeArgument, parseResult, JSHandle } from './jsHandle';
import { Request, Response, Route, RouteHandler, validateHeaders } from './network';
import { FileChooser } from './fileChooser';
import { Buffer } from 'buffer';
Expand Down Expand Up @@ -60,6 +60,7 @@ type PDFOptions = Omit<channels.PagePdfParams, 'width' | 'height' | 'margin'> &
path?: string,
};
type Listener = (...args: any[]) => void;
export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame }, ...args: any) => any;

export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitializer> {
private _browserContext: BrowserContext;
Expand Down Expand Up @@ -280,17 +281,17 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
}

async exposeFunction(name: string, playwrightFunction: Function) {
await this.exposeBinding(name, (options, ...args: any) => playwrightFunction(...args));
return this._wrapApiCall('page.exposeFunction', async () => {
await this._channel.exposeBinding({ name });
const binding: FunctionWithSource = (source, ...args) => playwrightFunction(...args);
this._bindings.set(name, binding);
});
}

async exposeBinding(name: string, playwrightBinding: FunctionWithSource) {
async exposeBinding(name: string, playwrightBinding: FunctionWithSource, options: { handle?: boolean } = {}) {
return this._wrapApiCall('page.exposeBinding', async () => {
if (this._bindings.has(name))
throw new Error(`Function "${name}" has been already registered`);
if (this._browserContext._bindings.has(name))
throw new Error(`Function "${name}" has been already registered in the browser context`);
await this._channel.exposeBinding({ name, needsHandle: options.handle });
this._bindings.set(name, playwrightBinding);
await this._channel.exposeBinding({ name });
});
}

Expand Down Expand Up @@ -615,7 +616,11 @@ export class BindingCall extends ChannelOwner<channels.BindingCallChannel, chann
page: frame._page!,
frame
};
const result = await func(source, ...this._initializer.args.map(parseResult));
let result: any;
if (this._initializer.handle)
result = await func(source, JSHandle.from(this._initializer.handle));
else
result = await func(source, ...this._initializer.args!.map(parseResult));
this._channel.resolve({ result: serializeArgument(result) });
} catch (e) {
this._channel.reject({ error: serializeError(e) });
Expand Down
4 changes: 2 additions & 2 deletions src/dispatchers/browserContextDispatcher.ts
Expand Up @@ -56,8 +56,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
}

async exposeBinding(params: channels.BrowserContextExposeBindingParams): Promise<void> {
await this._context.exposeBinding(params.name, (source, ...args) => {
const binding = new BindingCallDispatcher(this._scope, params.name, source, args);
await this._context.exposeBinding(params.name, !!params.needsHandle, (source, ...args) => {
const binding = new BindingCallDispatcher(this._scope, params.name, !!params.needsHandle, source, args);
this._dispatchEvent('bindingCall', { binding });
return binding.promise();
});
Expand Down
12 changes: 7 additions & 5 deletions src/dispatchers/pageDispatcher.ts
Expand Up @@ -26,10 +26,11 @@ import { DialogDispatcher } from './dialogDispatcher';
import { DownloadDispatcher } from './downloadDispatcher';
import { FrameDispatcher } from './frameDispatcher';
import { RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import { serializeResult, parseArgument } from './jsHandleDispatcher';
import { serializeResult, parseArgument, JSHandleDispatcher } from './jsHandleDispatcher';
import { ElementHandleDispatcher, createHandle } from './elementHandlerDispatcher';
import { FileChooser } from '../server/fileChooser';
import { CRCoverage } from '../server/chromium/crCoverage';
import { JSHandle } from '../server/javascript';

export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> implements channels.PageChannel {
private _page: Page;
Expand Down Expand Up @@ -81,8 +82,8 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> i
}

async exposeBinding(params: channels.PageExposeBindingParams): Promise<void> {
await this._page.exposeBinding(params.name, (source, ...args) => {
const binding = new BindingCallDispatcher(this._scope, params.name, source, args);
await this._page.exposeBinding(params.name, !!params.needsHandle, (source, ...args) => {
const binding = new BindingCallDispatcher(this._scope, params.name, !!params.needsHandle, source, args);
this._dispatchEvent('bindingCall', { binding });
return binding.promise();
});
Expand Down Expand Up @@ -254,11 +255,12 @@ export class BindingCallDispatcher extends Dispatcher<{}, channels.BindingCallIn
private _reject: ((error: any) => void) | undefined;
private _promise: Promise<any>;

constructor(scope: DispatcherScope, name: string, source: { context: BrowserContext, page: Page, frame: Frame }, args: any[]) {
constructor(scope: DispatcherScope, name: string, needsHandle: boolean, source: { context: BrowserContext, page: Page, frame: Frame }, args: any[]) {
super(scope, {}, 'BindingCall', {
frame: lookupDispatcher<FrameDispatcher>(source.frame),
name,
args: args.map(serializeResult),
args: needsHandle ? undefined : args.map(serializeResult),
handle: needsHandle ? new JSHandleDispatcher(scope, args[0] as JSHandle) : undefined,
});
this._promise = new Promise((resolve, reject) => {
this._resolve = resolve;
Expand Down
9 changes: 6 additions & 3 deletions src/protocol/channels.ts
Expand Up @@ -555,9 +555,10 @@ export type BrowserContextCookiesResult = {
};
export type BrowserContextExposeBindingParams = {
name: string,
needsHandle?: boolean,
};
export type BrowserContextExposeBindingOptions = {

needsHandle?: boolean,
};
export type BrowserContextExposeBindingResult = void;
export type BrowserContextGrantPermissionsParams = {
Expand Down Expand Up @@ -808,9 +809,10 @@ export type PageEmulateMediaOptions = {
export type PageEmulateMediaResult = void;
export type PageExposeBindingParams = {
name: string,
needsHandle?: boolean,
};
export type PageExposeBindingOptions = {

needsHandle?: boolean,
};
export type PageExposeBindingResult = void;
export type PageGoBackParams = {
Expand Down Expand Up @@ -2110,7 +2112,8 @@ export interface ConsoleMessageChannel extends Channel {
export type BindingCallInitializer = {
frame: FrameChannel,
name: string,
args: SerializedValue[],
args?: SerializedValue[],
handle?: JSHandleChannel,
};
export interface BindingCallChannel extends Channel {
reject(params: BindingCallRejectParams, metadata?: Metadata): Promise<BindingCallRejectResult>;
Expand Down
5 changes: 4 additions & 1 deletion src/protocol/protocol.yml
Expand Up @@ -475,6 +475,7 @@ BrowserContext:
exposeBinding:
parameters:
name: string
needsHandle: boolean?

grantPermissions:
parameters:
Expand Down Expand Up @@ -618,6 +619,7 @@ Page:
exposeBinding:
parameters:
name: string
needsHandle: boolean?

goBack:
parameters:
Expand Down Expand Up @@ -1780,8 +1782,9 @@ BindingCall:
frame: Frame
name: string
args:
type: array
type: array?
items: SerializedValue
handle: JSHandle?

commands:

Expand Down
2 changes: 2 additions & 0 deletions src/protocol/validator.ts
Expand Up @@ -262,6 +262,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
});
scheme.BrowserContextExposeBindingParams = tObject({
name: tString,
needsHandle: tOptional(tBoolean),
});
scheme.BrowserContextGrantPermissionsParams = tObject({
permissions: tArray(tString),
Expand Down Expand Up @@ -323,6 +324,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
});
scheme.PageExposeBindingParams = tObject({
name: tString,
needsHandle: tOptional(tBoolean),
});
scheme.PageGoBackParams = tObject({
timeout: tOptional(tNumber),
Expand Down
4 changes: 2 additions & 2 deletions src/server/browserContext.ts
Expand Up @@ -167,14 +167,14 @@ export abstract class BrowserContext extends EventEmitter {
return this._doSetHTTPCredentials(httpCredentials);
}

async exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource): Promise<void> {
async exposeBinding(name: string, needsHandle: boolean, playwrightBinding: frames.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);
const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding);
this._doExposeBinding(binding);
}
Expand Down

0 comments on commit 5e42029

Please sign in to comment.