From 253d469515a0be5f931892239d9892caeebcd5c2 Mon Sep 17 00:00:00 2001 From: jrandolf <101637635+jrandolf@users.noreply.github.com> Date: Mon, 11 Sep 2023 12:46:17 +0200 Subject: [PATCH] chore: implement `exposeFunction` (#10860) --- docs/api/puppeteer.page.exposefunction.md | 2 +- docs/api/puppeteer.page.md | 2 +- package-lock.json | 16 +- packages/puppeteer-core/package.json | 2 +- packages/puppeteer-core/src/api/Frame.ts | 11 + packages/puppeteer-core/src/api/Page.ts | 10 +- .../src/common/bidi/ExposedFunction.ts | 276 ++++++++++++++++++ .../puppeteer-core/src/common/bidi/Frame.ts | 22 ++ .../puppeteer-core/src/common/bidi/Page.ts | 13 + test/TestExpectations.json | 28 +- 10 files changed, 364 insertions(+), 18 deletions(-) create mode 100644 packages/puppeteer-core/src/common/bidi/ExposedFunction.ts diff --git a/docs/api/puppeteer.page.exposefunction.md b/docs/api/puppeteer.page.exposefunction.md index de6584e4..07699607 100644 --- a/docs/api/puppeteer.page.exposefunction.md +++ b/docs/api/puppeteer.page.exposefunction.md @@ -18,7 +18,7 @@ Functions installed via `page.exposeFunction` survive navigations. ```typescript class Page { - exposeFunction( + abstract exposeFunction( name: string, pptrFunction: | Function diff --git a/docs/api/puppeteer.page.md b/docs/api/puppeteer.page.md index bc0f718f..58904519 100644 --- a/docs/api/puppeteer.page.md +++ b/docs/api/puppeteer.page.md @@ -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) diff --git a/package-lock.json b/package-lock.json index 690c4e59..d9386fbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/puppeteer-core/package.json b/packages/puppeteer-core/package.json index a419b869..b57ade9d 100644 --- a/packages/puppeteer-core/package.json +++ b/packages/puppeteer-core/package.json @@ -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", diff --git a/packages/puppeteer-core/src/api/Frame.ts b/packages/puppeteer-core/src/api/Frame.ts index ea7b1525..b8f185b9 100644 --- a/packages/puppeteer-core/src/api/Frame.ts +++ b/packages/puppeteer-core/src/api/Frame.ts @@ -1107,4 +1107,15 @@ export abstract class Frame extends EventEmitter { waitForDevicePrompt(): Promise { throw new Error('Not implemented'); } + + /** + * @internal + */ + exposeFunction( + name: string, + fn: (...args: Args) => Awaitable + ): Promise; + exposeFunction(): Promise { + throw new Error('Not implemented'); + } } diff --git a/packages/puppeteer-core/src/api/Page.ts b/packages/puppeteer-core/src/api/Page.ts index ae830939..18e88cd9 100644 --- a/packages/puppeteer-core/src/api/Page.ts +++ b/packages/puppeteer-core/src/api/Page.ts @@ -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>(); /** @@ -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; - async exposeFunction(): Promise { - throw new Error('Not implemented'); - } /** * The method removes a previously added function via ${@link Page.exposeFunction} diff --git a/packages/puppeteer-core/src/common/bidi/ExposedFunction.ts b/packages/puppeteer-core/src/common/bidi/ExposedFunction.ts new file mode 100644 index 00000000..8031554d --- /dev/null +++ b/packages/puppeteer-core/src/common/bidi/ExposedFunction.ts @@ -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 = (value: [id: number, args: Args]) => void; +type SendResolveChannel = ( + value: [id: number, resolve: (ret: FlattenHandle>) => void] +) => void; +type SendRejectChannel = ( + value: [id: number, reject: (error: unknown) => void] +) => void; + +interface RemotePromiseCallbacks { + resolve: Deferred; + reject: Deferred; +} + +export class ExposeableFunction { + readonly #frame; + + readonly name; + readonly #apply; + + readonly #channels; + readonly #callerInfos = new Map< + string, + Map + >(); + + constructor( + frame: BidiFrame, + name: string, + apply: (...args: Args) => Awaitable + ) { + 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 { + 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, + sendResolve: SendResolveChannel, + sendReject: SendRejectChannel + ) => { + let id = 0; + Object.assign(globalThis, { + [PLACEHOLDER('name') as string]: function (...args: Args) { + return new Promise>>( + (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}; + } +} diff --git a/packages/puppeteer-core/src/common/bidi/Frame.ts b/packages/puppeteer-core/src/common/bidi/Frame.ts index bfc7b8ab..962fa4f4 100644 --- a/packages/puppeteer-core/src/common/bidi/Frame.ts +++ b/packages/puppeteer-core/src/common/bidi/Frame.ts @@ -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>(); + override async exposeFunction( + name: string, + apply: (...args: Args) => Awaitable + ): Promise { + 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; + } + } } diff --git a/packages/puppeteer-core/src/common/bidi/Page.ts b/packages/puppeteer-core/src/common/bidi/Page.ts index b1d3a4d4..a64f086b 100644 --- a/packages/puppeteer-core/src/common/bidi/Page.ts +++ b/packages/puppeteer-core/src/common/bidi/Page.ts @@ -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( + name: string, + pptrFunction: + | ((...args: Args) => Awaitable) + | {default: (...args: Args) => Awaitable} + ): Promise { + return await this.mainFrame().exposeFunction( + name, + 'default' in pptrFunction ? pptrFunction.default : pptrFunction + ); + } } function isConsoleLogEntry( diff --git a/test/TestExpectations.json b/test/TestExpectations.json index fde2cf1b..123dbc84 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -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"],