chore: implement exposeFunction (#10860)

This commit is contained in:
jrandolf 2023-09-11 12:46:17 +02:00 committed by GitHub
parent acdd7d3cd5
commit 253d469515
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 364 additions and 18 deletions

View File

@ -18,7 +18,7 @@ Functions installed via `page.exposeFunction` survive navigations.
```typescript
class Page {
exposeFunction(
abstract exposeFunction(
name: string,
pptrFunction:
| Function

View File

@ -15,7 +15,7 @@ One Browser instance might have multiple Page instances.
#### Signature:
```typescript
export declare class Page extends EventEmitter implements AsyncDisposable, Disposable
export declare abstract class Page extends EventEmitter implements AsyncDisposable, Disposable
```
**Extends:** [EventEmitter](./puppeteer.eventemitter.md)

16
package-lock.json generated
View File

@ -3434,9 +3434,9 @@
}
},
"node_modules/chromium-bidi": {
"version": "0.4.25",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.25.tgz",
"integrity": "sha512-wQOIgYulshTLpZtuDO/eKFfKqVtpS2UwFVVqi/9q5rX/VXVkYNb/0mZ5l479W24A5ogYKBKEIb6BxMlhMcpXFw==",
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.26.tgz",
"integrity": "sha512-lukBGfogAI4T0y3acc86RaacqgKQve47/8pV2c+Hr1PjcICj2K4OkL3qfX3qrqxxnd4ddurFC0WBA3VCQqYeUQ==",
"dependencies": {
"mitt": "3.0.1"
},
@ -11115,7 +11115,7 @@
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "1.7.0",
"chromium-bidi": "0.4.25",
"chromium-bidi": "0.4.26",
"cross-fetch": "4.0.0",
"debug": "4.3.4",
"devtools-protocol": "0.0.1159816",
@ -13466,9 +13466,9 @@
"dev": true
},
"chromium-bidi": {
"version": "0.4.25",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.25.tgz",
"integrity": "sha512-wQOIgYulshTLpZtuDO/eKFfKqVtpS2UwFVVqi/9q5rX/VXVkYNb/0mZ5l479W24A5ogYKBKEIb6BxMlhMcpXFw==",
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.26.tgz",
"integrity": "sha512-lukBGfogAI4T0y3acc86RaacqgKQve47/8pV2c+Hr1PjcICj2K4OkL3qfX3qrqxxnd4ddurFC0WBA3VCQqYeUQ==",
"requires": {
"mitt": "3.0.1"
}
@ -17257,7 +17257,7 @@
"version": "file:packages/puppeteer-core",
"requires": {
"@puppeteer/browsers": "1.7.0",
"chromium-bidi": "0.4.25",
"chromium-bidi": "0.4.26",
"cross-fetch": "4.0.0",
"debug": "4.3.4",
"devtools-protocol": "0.0.1159816",

View File

@ -141,7 +141,7 @@
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "1.7.0",
"chromium-bidi": "0.4.25",
"chromium-bidi": "0.4.26",
"cross-fetch": "4.0.0",
"debug": "4.3.4",
"devtools-protocol": "0.0.1159816",

View File

@ -1107,4 +1107,15 @@ export abstract class Frame extends EventEmitter {
waitForDevicePrompt(): Promise<DeviceRequestPrompt> {
throw new Error('Not implemented');
}
/**
* @internal
*/
exposeFunction<Args extends unknown[], Ret>(
name: string,
fn: (...args: Args) => Awaitable<Ret>
): Promise<void>;
exposeFunction(): Promise<void> {
throw new Error('Not implemented');
}
}

View File

@ -478,7 +478,10 @@ export interface NewDocumentScriptEvaluation {
*
* @public
*/
export class Page extends EventEmitter implements AsyncDisposable, Disposable {
export abstract class Page
extends EventEmitter
implements AsyncDisposable, Disposable
{
#handlerMap = new WeakMap<Handler<any>, Handler<any>>();
/**
@ -1327,13 +1330,10 @@ export class Page extends EventEmitter implements AsyncDisposable, Disposable {
* @param pptrFunction - Callback function which will be called in Puppeteer's
* context.
*/
async exposeFunction(
abstract exposeFunction(
name: string,
pptrFunction: Function | {default: Function}
): Promise<void>;
async exposeFunction(): Promise<void> {
throw new Error('Not implemented');
}
/**
* The method removes a previously added function via ${@link Page.exposeFunction}

View File

@ -0,0 +1,276 @@
/**
* Copyright 2023 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {assert} from '../../util/assert.js';
import {Deferred} from '../../util/Deferred.js';
import {interpolateFunction, stringifyFunction} from '../../util/Function.js';
import {Awaitable, FlattenHandle} from '../types.js';
import {Connection} from './Connection.js';
import {BidiFrame} from './Frame.js';
import {BidiSerializer} from './Serializer.js';
import {debugError} from './utils.js';
type SendArgsChannel<Args> = (value: [id: number, args: Args]) => void;
type SendResolveChannel<Ret> = (
value: [id: number, resolve: (ret: FlattenHandle<Awaited<Ret>>) => void]
) => void;
type SendRejectChannel = (
value: [id: number, reject: (error: unknown) => void]
) => void;
interface RemotePromiseCallbacks {
resolve: Deferred<Bidi.Script.RemoteValue>;
reject: Deferred<Bidi.Script.RemoteValue>;
}
export class ExposeableFunction<Args extends unknown[], Ret> {
readonly #frame;
readonly name;
readonly #apply;
readonly #channels;
readonly #callerInfos = new Map<
string,
Map<number, RemotePromiseCallbacks>
>();
constructor(
frame: BidiFrame,
name: string,
apply: (...args: Args) => Awaitable<Ret>
) {
this.#frame = frame;
this.name = name;
this.#apply = apply;
this.#channels = {
args: `__puppeteer__${this.#frame._id}_page_exposeFunction_${
this.name
}_args`,
resolve: `__puppeteer__${this.#frame._id}_page_exposeFunction_${
this.name
}_resolve`,
reject: `__puppeteer__${this.#frame._id}_page_exposeFunction_${
this.name
}_reject`,
};
}
async expose(): Promise<void> {
const connection = this.#connection;
const channelArguments = this.#channelArguments;
const {name} = this;
// TODO(jrandolf): Implement cleanup with removePreloadScript.
connection.on(
Bidi.ChromiumBidi.Script.EventNames.Message,
this.#handleArgumentsMessage
);
connection.on(
Bidi.ChromiumBidi.Script.EventNames.Message,
this.#handleResolveMessage
);
connection.on(
Bidi.ChromiumBidi.Script.EventNames.Message,
this.#handleRejectMessage
);
const functionDeclaration = stringifyFunction(
interpolateFunction(
(
sendArgs: SendArgsChannel<Args>,
sendResolve: SendResolveChannel<Ret>,
sendReject: SendRejectChannel
) => {
let id = 0;
Object.assign(globalThis, {
[PLACEHOLDER('name') as string]: function (...args: Args) {
return new Promise<FlattenHandle<Awaited<Ret>>>(
(resolve, reject) => {
sendArgs([id, args]);
sendResolve([id, resolve]);
sendReject([id, reject]);
++id;
}
);
},
});
},
{name: JSON.stringify(name)}
)
);
await connection.send('script.addPreloadScript', {
functionDeclaration,
arguments: channelArguments,
});
await connection.send('script.callFunction', {
functionDeclaration,
arguments: channelArguments,
awaitPromise: false,
target: this.#frame.mainRealm().realm.target,
});
}
#handleArgumentsMessage = async (params: Bidi.Script.MessageParameters) => {
if (params.channel !== this.#channels.args) {
return;
}
const connection = this.#connection;
const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
const args = remoteValue.value?.[1];
assert(args);
try {
const result = await this.#apply(...BidiSerializer.deserialize(args));
await connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(([_, resolve]: any, result) => {
resolve(result);
}),
arguments: [
(await callbacks.resolve.valueOrThrow()) as Bidi.Script.LocalValue,
BidiSerializer.serializeRemoteValue(result),
],
awaitPromise: false,
target: params.source,
});
} catch (error) {
try {
if (error instanceof Error) {
await connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(
(
[_, reject]: any,
name: string,
message: string,
stack?: string
) => {
const error = new Error(message);
error.name = name;
if (stack) {
error.stack = stack;
}
reject(error);
}
),
arguments: [
(await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue,
BidiSerializer.serializeRemoteValue(error.name),
BidiSerializer.serializeRemoteValue(error.message),
BidiSerializer.serializeRemoteValue(error.stack),
],
awaitPromise: false,
target: params.source,
});
} else {
await connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(
([_, reject]: any, error: unknown) => {
reject(error);
}
),
arguments: [
(await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue,
BidiSerializer.serializeRemoteValue(error),
],
awaitPromise: false,
target: params.source,
});
}
} catch (error) {
debugError(error);
}
}
};
get #connection(): Connection {
return this.#frame.context().connection;
}
get #channelArguments() {
return [
{
type: 'channel' as const,
value: {
channel: this.#channels.args,
ownership: Bidi.Script.ResultOwnership.Root,
},
},
{
type: 'channel' as const,
value: {
channel: this.#channels.resolve,
ownership: Bidi.Script.ResultOwnership.Root,
},
},
{
type: 'channel' as const,
value: {
channel: this.#channels.reject,
ownership: Bidi.Script.ResultOwnership.Root,
},
},
];
}
#handleResolveMessage = (params: Bidi.Script.MessageParameters) => {
if (params.channel !== this.#channels.resolve) {
return;
}
const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
callbacks.resolve.resolve(remoteValue);
};
#handleRejectMessage = (params: Bidi.Script.MessageParameters) => {
if (params.channel !== this.#channels.reject) {
return;
}
const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
callbacks.reject.resolve(remoteValue);
};
#getCallbacksAndRemoteValue(params: Bidi.Script.MessageParameters) {
const {data, source} = params;
assert(data.type === 'array');
assert(data.value);
const callerIdRemote = data.value[0];
assert(callerIdRemote);
assert(callerIdRemote.type === 'number');
assert(typeof callerIdRemote.value === 'number');
let bindingMap = this.#callerInfos.get(source.realm);
if (!bindingMap) {
bindingMap = new Map();
this.#callerInfos.set(source.realm, bindingMap);
}
const callerId = callerIdRemote.value;
let callbacks = bindingMap.get(callerId);
if (!callbacks) {
callbacks = {
resolve: new Deferred(),
reject: new Deferred(),
};
bindingMap.set(callerId, callbacks);
}
return {callbacks, remoteValue: data};
}
}

View File

@ -22,6 +22,7 @@ import {CDPSession} from '../Connection.js';
import {UTILITY_WORLD_NAME} from '../FrameManager.js';
import {PuppeteerLifeCycleEvent} from '../LifecycleWatcher.js';
import {TimeoutSettings} from '../TimeoutSettings.js';
import {Awaitable} from '../types.js';
import {waitForEvent} from '../util.js';
import {
@ -29,6 +30,7 @@ import {
getWaitUntilSingle,
lifeCycleToSubscribedEvent,
} from './BrowsingContext.js';
import {ExposeableFunction} from './ExposedFunction.js';
import {HTTPResponse} from './HTTPResponse.js';
import {BidiPage} from './Page.js';
import {
@ -207,4 +209,24 @@ export class BidiFrame extends Frame {
this.sandboxes[MAIN_SANDBOX][Symbol.dispose]();
this.sandboxes[PUPPETEER_SANDBOX][Symbol.dispose]();
}
#exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>();
override async exposeFunction<Args extends unknown[], Ret>(
name: string,
apply: (...args: Args) => Awaitable<Ret>
): Promise<void> {
if (this.#exposedFunctions.has(name)) {
throw new Error(
`Failed to add page binding with name ${name}: globalThis['${name}'] already exists!`
);
}
const exposeable = new ExposeableFunction(this, name, apply);
this.#exposedFunctions.set(name, exposeable);
try {
await exposeable.expose();
} catch (error) {
this.#exposedFunctions.delete(name);
throw error;
}
}
}

View File

@ -43,6 +43,7 @@ import {PDFOptions} from '../PDFOptions.js';
import {Viewport} from '../PuppeteerViewport.js';
import {TimeoutSettings} from '../TimeoutSettings.js';
import {Tracing} from '../Tracing.js';
import {Awaitable} from '../types.js';
import {
debugError,
evaluationString,
@ -674,6 +675,18 @@ export class BidiPage extends Page {
script: id,
});
}
override async exposeFunction<Args extends unknown[], Ret>(
name: string,
pptrFunction:
| ((...args: Args) => Awaitable<Ret>)
| {default: (...args: Args) => Awaitable<Ret>}
): Promise<void> {
return await this.mainFrame().exposeFunction(
name,
'default' in pptrFunction ? pptrFunction.default : pptrFunction
);
}
}
function isConsoleLogEntry(

View File

@ -765,7 +765,7 @@
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work from-inside an exposed function",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL"]
"expectations": ["PASS"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluateOnNewDocument *",
@ -1367,6 +1367,12 @@
"parameters": ["webDriverBiDi"],
"expectations": ["PASS"]
},
{
"testIdPattern": "[page.spec] Page Page.exposeFunction *",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["chrome", "webDriverBiDi"],
"expectations": ["PASS"]
},
{
"testIdPattern": "[page.spec] Page Page.pdf should respect timeout",
"platforms": ["darwin", "linux", "win32"],
@ -2099,6 +2105,12 @@
"parameters": ["firefox", "webDriverBiDi"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.click should return Point data",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["chrome", "webDriverBiDi"],
"expectations": ["PASS"]
},
{
"testIdPattern": "[elementhandle.spec] ElementHandle specs ElementHandle.click should return Point data",
"platforms": ["darwin", "linux", "win32"],
@ -2285,6 +2297,12 @@
"parameters": ["cdp", "firefox"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work from-inside an exposed function",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox", "webDriverBiDi"],
"expectations": ["FAIL", "TIMEOUT"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work from-inside an exposed function",
"platforms": ["darwin", "linux", "win32"],
@ -2979,7 +2997,7 @@
"testIdPattern": "[navigation.spec] navigation Page.goto should return response when page changes its URL after load",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox", "webDriverBiDi"],
"expectations": ["PASS"]
"expectations": ["FAIL", "PASS"]
},
{
"testIdPattern": "[navigation.spec] navigation Page.goto should send referer",
@ -3701,6 +3719,12 @@
"parameters": ["cdp", "firefox"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[page.spec] Page Page.exposeFunction should be callable from-inside evaluateOnNewDocument",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["chrome", "webDriverBiDi"],
"expectations": ["FAIL", "TIMEOUT"]
},
{
"testIdPattern": "[page.spec] Page Page.exposeFunction should be callable from-inside evaluateOnNewDocument",
"platforms": ["darwin", "linux", "win32"],