api: allow exposeBinding to pass handles (#4030)

This adds an option `{ handle: true }` to pass a single handle instead of arbitrary json values.
This commit is contained in:
Dmitry Gozman 2020-10-01 22:47:31 -07:00 committed by GitHub
parent c2171218fa
commit 5e42029fce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 203 additions and 51 deletions

View file

@ -314,7 +314,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.exposeBinding(name, playwrightBinding[, options])](#browsercontextexposebindingname-playwrightbinding-options)
- [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)
@ -443,9 +443,11 @@ 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) #### browserContext.exposeBinding(name, playwrightBinding[, options])
- `name` <[string]> Name of the function on the window object. - `name` <[string]> Name of the function on the window object.
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context. - `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]> - 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.
@ -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: The first argument of the `playwrightBinding` function contains information about the caller:
`{ browserContext: BrowserContext, page: Page, frame: Frame }`. `{ 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: An example of exposing page URL to all frames in all pages in the context:
```js ```js
@ -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) #### 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.
@ -735,7 +751,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.exposeBinding(name, playwrightBinding[, options])](#pageexposebindingname-playwrightbinding-options)
- [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)
@ -1264,9 +1280,11 @@ console.log(await resultHandle.jsonValue());
await resultHandle.dispose(); await resultHandle.dispose();
``` ```
#### page.exposeBinding(name, playwrightBinding) #### page.exposeBinding(name, playwrightBinding[, options])
- `name` <[string]> Name of the function on the window object. - `name` <[string]> Name of the function on the window object.
- `playwrightBinding` <[function]> Callback function that will be called in the Playwright's context. - `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]> - returns: <[Promise]>
The method adds a function called `name` on the `window` object of every frame in this page. The method adds a function called `name` on the `window` object of every frame in this page.
@ -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: The first argument of the `playwrightBinding` function contains information about the caller:
`{ browserContext: BrowserContext, page: Page, frame: Frame }`. `{ 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. > **NOTE** Functions installed via `page.exposeBinding` survive navigations.
@ -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) #### 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.
@ -4409,7 +4441,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.exposeBinding(name, playwrightBinding[, options])](#browsercontextexposebindingname-playwrightbinding-options)
- [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

@ -15,8 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import * as frames from './frame'; import { Page, BindingCall, FunctionWithSource } from './page';
import { Page, BindingCall } from './page';
import * as network from './network'; import * as network from './network';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
@ -34,7 +33,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
private _routes: { url: URLMatch, handler: network.RouteHandler }[] = []; private _routes: { url: URLMatch, handler: network.RouteHandler }[] = [];
readonly _browser: Browser | null = null; readonly _browser: Browser | null = null;
readonly _browserName: string; readonly _browserName: string;
readonly _bindings = new Map<string, frames.FunctionWithSource>(); readonly _bindings = new Map<string, FunctionWithSource>();
_timeoutSettings = new TimeoutSettings(); _timeoutSettings = new TimeoutSettings();
_ownerPage: Page | undefined; _ownerPage: Page | undefined;
private _closedPromise: Promise<void>; private _closedPromise: Promise<void>;
@ -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 () => { return this._wrapApiCall('browserContext.exposeBinding', async () => {
for (const page of this.pages()) { await this._channel.exposeBinding({ name, needsHandle: options.handle });
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`);
this._bindings.set(name, playwrightBinding); this._bindings.set(name, playwrightBinding);
await this._channel.exposeBinding({ name });
}); });
} }
async exposeFunction(name: string, playwrightFunction: Function): Promise<void> { 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> { async route(url: URLMatch, handler: network.RouteHandler): Promise<void> {

View file

@ -17,7 +17,6 @@
import { assert } from '../utils/utils'; import { assert } from '../utils/utils';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { BrowserContext } from './browserContext';
import { ChannelOwner } from './channelOwner'; import { ChannelOwner } from './channelOwner';
import { ElementHandle, convertSelectOptionValues, convertInputFiles } from './elementHandle'; import { ElementHandle, convertSelectOptionValues, convertInputFiles } from './elementHandle';
import { assertMaxArguments, JSHandle, Func1, FuncOn, SmartHandle, serializeArgument, parseResult } from './jsHandle'; import { assertMaxArguments, JSHandle, Func1, FuncOn, SmartHandle, serializeArgument, parseResult } from './jsHandle';
@ -33,7 +32,6 @@ import { urlMatches } from './clientHelper';
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame }, ...args: any) => any;
export type WaitForNavigationOptions = { export type WaitForNavigationOptions = {
timeout?: number, timeout?: number,
waitUntil?: LifecycleEvent, waitUntil?: LifecycleEvent,

View file

@ -28,9 +28,9 @@ import { Dialog } from './dialog';
import { Download } from './download'; import { Download } from './download';
import { ElementHandle, determineScreenshotType } from './elementHandle'; import { ElementHandle, determineScreenshotType } from './elementHandle';
import { Worker } from './worker'; import { Worker } from './worker';
import { Frame, FunctionWithSource, verifyLoadState, WaitForNavigationOptions } from './frame'; import { Frame, verifyLoadState, WaitForNavigationOptions } from './frame';
import { Keyboard, Mouse } from './input'; 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 { Request, Response, Route, RouteHandler, validateHeaders } from './network';
import { FileChooser } from './fileChooser'; import { FileChooser } from './fileChooser';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
@ -60,6 +60,7 @@ type PDFOptions = Omit<channels.PagePdfParams, 'width' | 'height' | 'margin'> &
path?: string, path?: string,
}; };
type Listener = (...args: any[]) => void; 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> { export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitializer> {
private _browserContext: BrowserContext; private _browserContext: BrowserContext;
@ -280,17 +281,17 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
} }
async exposeFunction(name: string, playwrightFunction: Function) { 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 () => { return this._wrapApiCall('page.exposeBinding', async () => {
if (this._bindings.has(name)) await this._channel.exposeBinding({ name, needsHandle: options.handle });
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`);
this._bindings.set(name, playwrightBinding); this._bindings.set(name, playwrightBinding);
await this._channel.exposeBinding({ name });
}); });
} }
@ -615,7 +616,11 @@ export class BindingCall extends ChannelOwner<channels.BindingCallChannel, chann
page: frame._page!, page: frame._page!,
frame 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) }); this._channel.resolve({ result: serializeArgument(result) });
} catch (e) { } catch (e) {
this._channel.reject({ error: serializeError(e) }); this._channel.reject({ error: serializeError(e) });

View file

@ -56,8 +56,8 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
} }
async exposeBinding(params: channels.BrowserContextExposeBindingParams): Promise<void> { async exposeBinding(params: channels.BrowserContextExposeBindingParams): Promise<void> {
await this._context.exposeBinding(params.name, (source, ...args) => { await this._context.exposeBinding(params.name, !!params.needsHandle, (source, ...args) => {
const binding = new BindingCallDispatcher(this._scope, params.name, source, args); const binding = new BindingCallDispatcher(this._scope, params.name, !!params.needsHandle, source, args);
this._dispatchEvent('bindingCall', { binding }); this._dispatchEvent('bindingCall', { binding });
return binding.promise(); return binding.promise();
}); });

View file

@ -26,10 +26,11 @@ import { DialogDispatcher } from './dialogDispatcher';
import { DownloadDispatcher } from './downloadDispatcher'; import { DownloadDispatcher } from './downloadDispatcher';
import { FrameDispatcher } from './frameDispatcher'; import { FrameDispatcher } from './frameDispatcher';
import { RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers'; import { RequestDispatcher, ResponseDispatcher, RouteDispatcher } from './networkDispatchers';
import { serializeResult, parseArgument } from './jsHandleDispatcher'; import { serializeResult, parseArgument, JSHandleDispatcher } from './jsHandleDispatcher';
import { ElementHandleDispatcher, createHandle } from './elementHandlerDispatcher'; import { ElementHandleDispatcher, createHandle } from './elementHandlerDispatcher';
import { FileChooser } from '../server/fileChooser'; import { FileChooser } from '../server/fileChooser';
import { CRCoverage } from '../server/chromium/crCoverage'; import { CRCoverage } from '../server/chromium/crCoverage';
import { JSHandle } from '../server/javascript';
export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> implements channels.PageChannel { export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> implements channels.PageChannel {
private _page: Page; private _page: Page;
@ -81,8 +82,8 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageInitializer> i
} }
async exposeBinding(params: channels.PageExposeBindingParams): Promise<void> { async exposeBinding(params: channels.PageExposeBindingParams): Promise<void> {
await this._page.exposeBinding(params.name, (source, ...args) => { await this._page.exposeBinding(params.name, !!params.needsHandle, (source, ...args) => {
const binding = new BindingCallDispatcher(this._scope, params.name, source, args); const binding = new BindingCallDispatcher(this._scope, params.name, !!params.needsHandle, source, args);
this._dispatchEvent('bindingCall', { binding }); this._dispatchEvent('bindingCall', { binding });
return binding.promise(); return binding.promise();
}); });
@ -254,11 +255,12 @@ export class BindingCallDispatcher extends Dispatcher<{}, channels.BindingCallIn
private _reject: ((error: any) => void) | undefined; private _reject: ((error: any) => void) | undefined;
private _promise: Promise<any>; 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', { super(scope, {}, 'BindingCall', {
frame: lookupDispatcher<FrameDispatcher>(source.frame), frame: lookupDispatcher<FrameDispatcher>(source.frame),
name, 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._promise = new Promise((resolve, reject) => {
this._resolve = resolve; this._resolve = resolve;

View file

@ -555,9 +555,10 @@ export type BrowserContextCookiesResult = {
}; };
export type BrowserContextExposeBindingParams = { export type BrowserContextExposeBindingParams = {
name: string, name: string,
needsHandle?: boolean,
}; };
export type BrowserContextExposeBindingOptions = { export type BrowserContextExposeBindingOptions = {
needsHandle?: boolean,
}; };
export type BrowserContextExposeBindingResult = void; export type BrowserContextExposeBindingResult = void;
export type BrowserContextGrantPermissionsParams = { export type BrowserContextGrantPermissionsParams = {
@ -808,9 +809,10 @@ export type PageEmulateMediaOptions = {
export type PageEmulateMediaResult = void; export type PageEmulateMediaResult = void;
export type PageExposeBindingParams = { export type PageExposeBindingParams = {
name: string, name: string,
needsHandle?: boolean,
}; };
export type PageExposeBindingOptions = { export type PageExposeBindingOptions = {
needsHandle?: boolean,
}; };
export type PageExposeBindingResult = void; export type PageExposeBindingResult = void;
export type PageGoBackParams = { export type PageGoBackParams = {
@ -2110,7 +2112,8 @@ export interface ConsoleMessageChannel extends Channel {
export type BindingCallInitializer = { export type BindingCallInitializer = {
frame: FrameChannel, frame: FrameChannel,
name: string, name: string,
args: SerializedValue[], args?: SerializedValue[],
handle?: JSHandleChannel,
}; };
export interface BindingCallChannel extends Channel { export interface BindingCallChannel extends Channel {
reject(params: BindingCallRejectParams, metadata?: Metadata): Promise<BindingCallRejectResult>; reject(params: BindingCallRejectParams, metadata?: Metadata): Promise<BindingCallRejectResult>;

View file

@ -475,6 +475,7 @@ BrowserContext:
exposeBinding: exposeBinding:
parameters: parameters:
name: string name: string
needsHandle: boolean?
grantPermissions: grantPermissions:
parameters: parameters:
@ -618,6 +619,7 @@ Page:
exposeBinding: exposeBinding:
parameters: parameters:
name: string name: string
needsHandle: boolean?
goBack: goBack:
parameters: parameters:
@ -1780,8 +1782,9 @@ BindingCall:
frame: Frame frame: Frame
name: string name: string
args: args:
type: array type: array?
items: SerializedValue items: SerializedValue
handle: JSHandle?
commands: commands:

View file

@ -262,6 +262,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
}); });
scheme.BrowserContextExposeBindingParams = tObject({ scheme.BrowserContextExposeBindingParams = tObject({
name: tString, name: tString,
needsHandle: tOptional(tBoolean),
}); });
scheme.BrowserContextGrantPermissionsParams = tObject({ scheme.BrowserContextGrantPermissionsParams = tObject({
permissions: tArray(tString), permissions: tArray(tString),
@ -323,6 +324,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
}); });
scheme.PageExposeBindingParams = tObject({ scheme.PageExposeBindingParams = tObject({
name: tString, name: tString,
needsHandle: tOptional(tBoolean),
}); });
scheme.PageGoBackParams = tObject({ scheme.PageGoBackParams = tObject({
timeout: tOptional(tNumber), timeout: tOptional(tNumber),

View file

@ -167,14 +167,14 @@ export abstract class BrowserContext extends EventEmitter {
return this._doSetHTTPCredentials(httpCredentials); 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()) { for (const page of this.pages()) {
if (page._pageBindings.has(name)) if (page._pageBindings.has(name))
throw new Error(`Function "${name}" has been already registered in one of the pages`); throw new Error(`Function "${name}" has been already registered in one of the pages`);
} }
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`);
const binding = new PageBinding(name, playwrightBinding); const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding); this._pageBindings.set(name, binding);
this._doExposeBinding(binding); this._doExposeBinding(binding);
} }

View file

@ -232,12 +232,12 @@ export class Page extends EventEmitter {
this._timeoutSettings.setDefaultTimeout(timeout); this._timeoutSettings.setDefaultTimeout(timeout);
} }
async exposeBinding(name: string, playwrightBinding: frames.FunctionWithSource) { async exposeBinding(name: string, needsHandle: boolean, 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, playwrightBinding); const binding = new PageBinding(name, playwrightBinding, needsHandle);
this._pageBindings.set(name, binding); this._pageBindings.set(name, binding);
await this._delegate.exposeBinding(binding); await this._delegate.exposeBinding(binding);
} }
@ -454,11 +454,13 @@ export class PageBinding {
readonly name: string; readonly name: string;
readonly playwrightFunction: frames.FunctionWithSource; readonly playwrightFunction: frames.FunctionWithSource;
readonly source: string; readonly source: string;
readonly needsHandle: boolean;
constructor(name: string, playwrightFunction: frames.FunctionWithSource) { constructor(name: string, playwrightFunction: frames.FunctionWithSource, needsHandle: boolean) {
this.name = name; this.name = name;
this.playwrightFunction = playwrightFunction; this.playwrightFunction = playwrightFunction;
this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)})`; this.source = `(${addPageBinding.toString()})(${JSON.stringify(name)}, ${needsHandle})`;
this.needsHandle = needsHandle;
} }
static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) {
@ -467,7 +469,13 @@ export class PageBinding {
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({ frame: context.frame, page, context: page._browserContext }, ...args); let result: any;
if (binding!.needsHandle) {
const handle = await context.evaluateHandleInternal(takeHandle, { name, seq }).catch(e => null);
result = await binding!.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, handle);
} else {
result = await binding!.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args);
}
context.evaluateInternal(deliverResult, { name, seq, result }).catch(e => debugLogger.log('error', e)); context.evaluateInternal(deliverResult, { name, seq, result }).catch(e => debugLogger.log('error', e));
} catch (error) { } catch (error) {
if (isError(error)) if (isError(error))
@ -476,6 +484,12 @@ export class PageBinding {
context.evaluateInternal(deliverErrorValue, { name, seq, error }).catch(e => debugLogger.log('error', e)); context.evaluateInternal(deliverErrorValue, { name, seq, error }).catch(e => debugLogger.log('error', e));
} }
function takeHandle(arg: { name: string, seq: number }) {
const handle = (window as any)[arg.name]['handles'].get(arg.seq);
(window as any)[arg.name]['handles'].delete(arg.seq);
return handle;
}
function deliverResult(arg: { name: string, seq: number, result: any }) { function deliverResult(arg: { name: string, seq: number, result: any }) {
(window as any)[arg.name]['callbacks'].get(arg.seq).resolve(arg.result); (window as any)[arg.name]['callbacks'].get(arg.seq).resolve(arg.result);
(window as any)[arg.name]['callbacks'].delete(arg.seq); (window as any)[arg.name]['callbacks'].delete(arg.seq);
@ -495,12 +509,14 @@ export class PageBinding {
} }
} }
function addPageBinding(bindingName: string) { function addPageBinding(bindingName: string, needsHandle: boolean) {
const binding = (window as any)[bindingName]; const binding = (window as any)[bindingName];
if (binding.__installed) if (binding.__installed)
return; return;
(window as any)[bindingName] = (...args: any[]) => { (window as any)[bindingName] = (...args: any[]) => {
const me = (window as any)[bindingName]; const me = (window as any)[bindingName];
if (needsHandle && args.slice(1).some(arg => arg !== undefined))
throw new Error(`exposeBindingHandle supports a single argument, ${args.length} received`);
let callbacks = me['callbacks']; let callbacks = me['callbacks'];
if (!callbacks) { if (!callbacks) {
callbacks = new Map(); callbacks = new Map();
@ -508,8 +524,18 @@ function addPageBinding(bindingName: string) {
} }
const seq = (me['lastSeq'] || 0) + 1; const seq = (me['lastSeq'] || 0) + 1;
me['lastSeq'] = seq; me['lastSeq'] = seq;
let handles = me['handles'];
if (!handles) {
handles = new Map();
me['handles'] = handles;
}
const promise = new Promise((resolve, reject) => callbacks.set(seq, {resolve, reject})); const promise = new Promise((resolve, reject) => callbacks.set(seq, {resolve, reject}));
binding(JSON.stringify({name: bindingName, seq, args})); if (needsHandle) {
handles.set(seq, args[0]);
binding(JSON.stringify({name: bindingName, seq}));
} else {
binding(JSON.stringify({name: bindingName, seq, args}));
}
return promise; return promise;
}; };
(window as any)[bindingName].__installed = true; (window as any)[bindingName].__installed = true;

View file

@ -76,3 +76,19 @@ it('should be callable from-inside addInitScript', async ({browser, server}) =>
expect(args).toEqual(['context', 'page']); expect(args).toEqual(['context', 'page']);
await context.close(); await context.close();
}); });
it('exposeBindingHandle should work', async ({browser}) => {
const context = await browser.newContext();
let target;
await context.exposeBinding('logme', (source, t) => {
target = t;
return 17;
}, { handle: true });
const page = await context.newPage();
const result = await page.evaluate(async function() {
return window['logme']({ foo: 42 });
});
expect(await target.evaluate(x => x.foo)).toBe(42);
expect(result).toEqual(17);
await context.close();
});

View file

@ -169,3 +169,56 @@ it('should work with complex objects', async ({page, server}) => {
const result = await page.evaluate(async () => window['complexObject']({x: 5}, {x: 2})); const result = await page.evaluate(async () => window['complexObject']({x: 5}, {x: 2}));
expect(result.x).toBe(7); expect(result.x).toBe(7);
}); });
it('exposeBindingHandle should work', async ({page}) => {
let target;
await page.exposeBinding('logme', (source, t) => {
target = t;
return 17;
}, { handle: true });
const result = await page.evaluate(async function() {
return window['logme']({ foo: 42 });
});
expect(await target.evaluate(x => x.foo)).toBe(42);
expect(result).toEqual(17);
});
it('exposeBindingHandle should not throw during navigation', async ({page, server}) => {
await page.exposeBinding('logme', (source, t) => {
return 17;
}, { handle: true });
await page.goto(server.EMPTY_PAGE);
await Promise.all([
page.evaluate(async url => {
window['logme']({ foo: 42 });
window.location.href = url;
}, server.PREFIX + '/one-style.html'),
page.waitForNavigation({ waitUntil: 'load' }),
]);
});
it('should throw for duplicate registrations', async ({page}) => {
await page.exposeFunction('foo', () => {});
const error = await page.exposeFunction('foo', () => {}).catch(e => e);
expect(error.message).toContain('page.exposeFunction: Function "foo" has been already registered');
});
it('exposeBindingHandle should throw for multiple arguments', async ({page}) => {
await page.exposeBinding('logme', (source, t) => {
return 17;
}, { handle: true });
expect(await page.evaluate(async function() {
return window['logme']({ foo: 42 });
})).toBe(17);
expect(await page.evaluate(async function() {
return window['logme']({ foo: 42 }, undefined, undefined);
})).toBe(17);
expect(await page.evaluate(async function() {
return window['logme'](undefined, undefined, undefined);
})).toBe(17);
const error = await page.evaluate(async function() {
return window['logme'](1, 2);
}).catch(e => e);
expect(error.message).toContain('exposeBindingHandle supports a single argument, 2 received');
});

View file

@ -51,6 +51,8 @@ type ElementHandleWaitForSelectorOptionsNotHidden = ElementHandleWaitForSelector
state: 'visible'|'attached'; state: 'visible'|'attached';
}; };
type BindingSource = { context: BrowserContext, page: Page, frame: Frame };
export interface Page { export interface Page {
evaluate<R, Arg>(pageFunction: PageFunction<Arg, R>, arg: Arg): Promise<R>; evaluate<R, Arg>(pageFunction: PageFunction<Arg, R>, arg: Arg): Promise<R>;
evaluate<R>(pageFunction: PageFunction<void, R>, arg?: any): Promise<R>; evaluate<R>(pageFunction: PageFunction<void, R>, arg?: any): Promise<R>;
@ -81,6 +83,9 @@ export interface Page {
waitForSelector(selector: string, options?: PageWaitForSelectorOptionsNotHidden): Promise<ElementHandle<SVGElement | HTMLElement>>; waitForSelector(selector: string, options?: PageWaitForSelectorOptionsNotHidden): Promise<ElementHandle<SVGElement | HTMLElement>>;
waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options: PageWaitForSelectorOptions): Promise<ElementHandleForTag<K> | null>; waitForSelector<K extends keyof HTMLElementTagNameMap>(selector: K, options: PageWaitForSelectorOptions): Promise<ElementHandleForTag<K> | null>;
waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise<null|ElementHandle<SVGElement | HTMLElement>>; waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise<null|ElementHandle<SVGElement | HTMLElement>>;
exposeBinding(name: string, playwrightBinding: (source: BindingSource, arg: JSHandle) => any, options: { handle: true }): Promise<void>;
exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise<void>;
} }
export interface Frame { export interface Frame {
@ -115,6 +120,11 @@ export interface Frame {
waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise<null|ElementHandle<SVGElement | HTMLElement>>; waitForSelector(selector: string, options: PageWaitForSelectorOptions): Promise<null|ElementHandle<SVGElement | HTMLElement>>;
} }
export interface BrowserContext {
exposeBinding(name: string, playwrightBinding: (source: BindingSource, arg: JSHandle) => any, options: { handle: true }): Promise<void>;
exposeBinding(name: string, playwrightBinding: (source: BindingSource, ...args: any[]) => any, options?: { handle?: boolean }): Promise<void>;
}
export interface Worker { export interface Worker {
evaluate<R, Arg>(pageFunction: PageFunction<Arg, R>, arg: Arg): Promise<R>; evaluate<R, Arg>(pageFunction: PageFunction<Arg, R>, arg: Arg): Promise<R>;
evaluate<R>(pageFunction: PageFunction<void, R>, arg?: any): Promise<R>; evaluate<R>(pageFunction: PageFunction<void, R>, arg?: any): Promise<R>;

View file

@ -122,6 +122,11 @@ playwright.chromium.launch().then(async browser => {
console.log(content); console.log(content);
}); });
await page.exposeBinding('clicked', async (source, handle) => {
await handle.asElement()!.textContent();
await source.page.goto('http://example.com');
}, { handle: true });
await page.emulateMedia({media: 'screen'}); await page.emulateMedia({media: 'screen'});
await page.pdf({ path: 'page.pdf' }); await page.pdf({ path: 'page.pdf' });