chore: Add BiDi Page.evaluate (#9609)

This commit is contained in:
Nikolay Vitkov 2023-02-02 15:14:28 +01:00 committed by GitHub
parent 45b7197a73
commit abcc1756dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 438 additions and 93 deletions

11
package-lock.json generated
View File

@ -40,7 +40,6 @@
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"c8": "7.12.0",
"chromium-bidi": "0.4.3",
"commonmark": "0.30.0",
"cross-env": "7.0.3",
"diff": "5.1.0",
@ -2663,7 +2662,6 @@
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.3.tgz",
"integrity": "sha512-A40H1rdpJqkTdnGhnYDzMhtDdIbkXNFj2wgIfivMXL7LyHFDmBtv1hdyycDhnxtYunbPLDZtTs/n+ZT5j7Vnew==",
"dev": true,
"peerDependencies": {
"devtools-protocol": "*",
"mitt": "*"
@ -6036,8 +6034,7 @@
"node_modules/mitt": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.0.tgz",
"integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==",
"dev": true
"integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ=="
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
@ -8677,6 +8674,7 @@
"version": "19.6.3",
"license": "Apache-2.0",
"dependencies": {
"chromium-bidi": "0.4.3",
"cross-fetch": "3.1.5",
"debug": "4.3.4",
"devtools-protocol": "0.0.1082910",
@ -11003,7 +11001,6 @@
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.3.tgz",
"integrity": "sha512-A40H1rdpJqkTdnGhnYDzMhtDdIbkXNFj2wgIfivMXL7LyHFDmBtv1hdyycDhnxtYunbPLDZtTs/n+ZT5j7Vnew==",
"dev": true,
"requires": {}
},
"cli-cursor": {
@ -13404,8 +13401,7 @@
"mitt": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.0.tgz",
"integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==",
"dev": true
"integrity": "sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ=="
},
"mkdirp-classic": {
"version": "0.5.3",
@ -14106,6 +14102,7 @@
"puppeteer-core": {
"version": "file:packages/puppeteer-core",
"requires": {
"chromium-bidi": "0.4.3",
"cross-fetch": "3.1.5",
"debug": "4.3.4",
"devtools-protocol": "0.0.1082910",

View File

@ -66,7 +66,6 @@
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"c8": "7.12.0",
"chromium-bidi": "0.4.3",
"commonmark": "0.30.0",
"cross-env": "7.0.3",
"diff": "5.1.0",

View File

@ -163,6 +163,7 @@
"author": "The Chromium Authors",
"license": "Apache-2.0",
"dependencies": {
"chromium-bidi": "0.4.3",
"cross-fetch": "3.1.5",
"debug": "4.3.4",
"devtools-protocol": "0.0.1082910",

View File

@ -26,6 +26,7 @@ import {
createJSHandle,
getExceptionMessage,
isString,
stringifyFunction,
valueFromRemoteObject,
} from './util.js';
@ -266,29 +267,11 @@ export class ExecutionContext {
: createJSHandle(this, remoteObject);
}
let functionText = pageFunction.toString();
try {
new Function('(' + functionText + ')');
} catch (error) {
// This means we might have a function shorthand. Try another
// time prefixing 'function '.
if (functionText.startsWith('async ')) {
functionText =
'async function ' + functionText.substring('async '.length);
} else {
functionText = 'function ' + functionText;
}
try {
new Function('(' + functionText + ')');
} catch (error) {
// We tried hard to serialize, but there's a weird beast here.
throw new Error('Passed function is not well-serializable!');
}
}
let callFunctionOnPromise;
try {
callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', {
functionDeclaration: functionText + '\n' + suffix + '\n',
functionDeclaration:
stringifyFunction(pageFunction) + '\n' + suffix + '\n',
executionContextId: this._contextId,
arguments: await Promise.all(args.map(convertArgument.bind(this))),
returnByValue,

View File

@ -1,6 +1,7 @@
import {CDPSession, Connection as CDPPPtrConnection} from '../Connection.js';
import {Connection as BidiPPtrConnection} from './Connection.js';
import {Bidi, BidiMapper} from '../../../third_party/chromium-bidi/index.js';
import * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/bidiMapper.js';
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
import {Handler} from '../EventEmitter.js';

View File

@ -34,7 +34,10 @@ export class Browser extends BrowserBase {
static async create(opts: Options): Promise<Browser> {
// TODO: await until the connection is established.
try {
(await opts.connection.send('session.new', {})) as {sessionId: string};
// TODO: Add 'session.new' to BiDi types
(await opts.connection.send('session.new' as any, {})) as unknown as {
sessionId: string;
};
} catch {}
return new Browser(opts);
}

View File

@ -31,10 +31,10 @@ export class BrowserContext extends BrowserContextBase {
}
override async newPage(): Promise<PageBase> {
const result = (await this.#connection.send('browsingContext.create', {
const response = await this.#connection.send('browsingContext.create', {
type: 'tab',
})) as {context: string};
return new Page(this.#connection, result.context);
});
return new Page(this.#connection, response.result.context);
}
override async close(): Promise<void> {}

View File

@ -22,28 +22,32 @@ import {ConnectionTransport} from '../ConnectionTransport.js';
import {EventEmitter} from '../EventEmitter.js';
import {ProtocolError} from '../Errors.js';
import {ConnectionCallback} from '../Connection.js';
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
interface Command {
id: number;
method: string;
params: object;
}
interface CommandResponse {
id: number;
result: object;
}
interface ErrorResponse {
id: number;
error: string;
message: string;
stacktrace?: string;
}
interface Event {
method: string;
params: object;
/**
* @internal
*/
interface Commands {
'script.evaluate': {
params: Bidi.Script.EvaluateParameters;
returnType: Bidi.Script.EvaluateResult;
};
'script.callFunction': {
params: Bidi.Script.CallFunctionParameters;
returnType: Bidi.Script.CallFunctionResult;
};
'browsingContext.create': {
params: Bidi.BrowsingContext.CreateParameters;
returnType: Bidi.BrowsingContext.CreateResult;
};
'browsingContext.close': {
params: Bidi.BrowsingContext.CloseParameters;
returnType: Bidi.BrowsingContext.CloseResult;
};
'session.status': {
params: {context: string}; // TODO: Update Types in chromium bidi
returnType: Bidi.Session.StatusResult;
};
}
/**
@ -69,13 +73,16 @@ export class Connection extends EventEmitter {
return this.#closed;
}
send(method: string, params: object): Promise<CommandResponse['result']> {
send<T extends keyof Commands>(
method: T,
params: Commands[T]['params']
): Promise<Commands[T]['returnType']> {
const id = ++this.#lastId;
const stringifiedMessage = JSON.stringify({
id,
method,
params,
} as Command);
} as Bidi.Message.CommandRequest);
debugProtocolSend(stringifiedMessage);
this.#transport.send(stringifiedMessage);
return new Promise((resolve, reject) => {
@ -99,9 +106,8 @@ export class Connection extends EventEmitter {
}
debugProtocolReceive(message);
const object = JSON.parse(message) as
| Event
| ErrorResponse
| CommandResponse;
| Bidi.Message.CommandResponse
| Bidi.EventResponse<string, unknown>;
if ('id' in object) {
const callback = this.#callbacks.get(object.id);
// Callbacks could be all rejected if someone has called `.dispose()`.
@ -112,7 +118,7 @@ export class Connection extends EventEmitter {
createProtocolError(callback.error, callback.method, object)
);
} else {
callback.resolve(object.result);
callback.resolve(object);
}
}
} else {
@ -154,10 +160,13 @@ function rewriteError(
return error;
}
/**
* @internal
*/
function createProtocolError(
error: ProtocolError,
method: string,
object: ErrorResponse
object: Bidi.Message.ErrorResult
): Error {
let message = `Protocol error (${method}): ${object.error} ${object.message}`;
if (object.stacktrace) {

View File

@ -16,8 +16,9 @@
import {Page as PageBase} from '../../api/Page.js';
import {Connection} from './Connection.js';
import type {EvaluateFunc} from '..//types.js';
import type {EvaluateFunc} from '../types.js';
import {isString, stringifyFunction} from '../util.js';
import {BidiSerializer} from './Serializer.js';
/**
* @internal
*/
@ -42,15 +43,32 @@ export class Page extends PageBase {
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
>(
pageFunction: Func | string,
..._args: Params
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
// TODO: re-use evaluate logic from Execution context.
const str = `(${pageFunction.toString()})()`;
const result = (await this.#connection.send('script.evaluate', {
expression: str,
target: {context: this.#contextId},
awaitPromise: true,
})) as {result: {type: string; value: any}};
return result.result.value;
let responsePromise;
if (isString(pageFunction)) {
responsePromise = this.#connection.send('script.evaluate', {
expression: pageFunction,
target: {context: this.#contextId},
awaitPromise: true,
});
} else {
responsePromise = this.#connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(pageFunction),
arguments: await Promise.all(args.map(BidiSerializer.serialize)),
target: {context: this.#contextId},
awaitPromise: true,
});
}
const {result} = await responsePromise;
if ('type' in result && result.type === 'exception') {
throw new Error(result.exceptionDetails.text);
}
return BidiSerializer.deserialize(result.result) as Awaited<
ReturnType<Func>
>;
}
}

View File

@ -0,0 +1,203 @@
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {debugError, isPlainObject} from '../util.js';
/**
* @internal
*/
class UnserializableError extends Error {}
/**
* @internal
*/
export class BidiSerializer {
static serializeNumber(arg: number): Bidi.CommonDataTypes.LocalOrRemoteValue {
let value: Bidi.CommonDataTypes.SpecialNumber | number;
if (Object.is(arg, -0)) {
value = '-0';
} else if (Object.is(arg, Infinity)) {
value = 'Infinity';
} else if (Object.is(arg, -Infinity)) {
value = '-Infinity';
} else if (Object.is(arg, NaN)) {
value = 'NaN';
} else {
value = arg;
}
return {
type: 'number',
value,
};
}
static serializeObject(
arg: object | null
): Bidi.CommonDataTypes.LocalOrRemoteValue {
if (arg === null) {
return {
type: 'null',
};
} else if (Array.isArray(arg)) {
const parsedArray = arg.map(subArg => {
return BidiSerializer.serializeRemoveValue(subArg);
});
return {
type: 'array',
value: parsedArray,
};
} else if (isPlainObject(arg)) {
const parsedObject: Bidi.CommonDataTypes.MappingLocalValue = [];
for (const key in arg) {
parsedObject.push([
BidiSerializer.serializeRemoveValue(key),
BidiSerializer.serializeRemoveValue(arg[key]),
]);
}
return {
type: 'object',
value: parsedObject,
};
}
throw new UnserializableError(
'Custom object sterilization not possible. Use plain objects instead.'
);
}
static serializeRemoveValue(
arg: unknown
): Bidi.CommonDataTypes.LocalOrRemoteValue {
switch (typeof arg) {
case 'symbol':
case 'function':
throw new UnserializableError(`Unable to serializable ${typeof arg}`);
case 'object':
return BidiSerializer.serializeObject(arg);
case 'undefined':
return {
type: 'undefined',
};
case 'number':
return BidiSerializer.serializeNumber(arg);
case 'bigint':
return {
type: 'bigint',
value: arg.toString(),
};
case 'string':
return {
type: 'string',
value: arg,
};
case 'boolean':
return {
type: 'boolean',
value: arg,
};
}
}
static serialize(arg: unknown): Bidi.CommonDataTypes.LocalOrRemoteValue {
// TODO: See use case of LazyArgs
return BidiSerializer.serializeRemoveValue(arg);
}
static deserializeNumber(
value: Bidi.CommonDataTypes.SpecialNumber | number
): number {
switch (value) {
case '-0':
return -0;
case 'NaN':
return NaN;
case 'Infinity':
case '+Infinity':
return Infinity;
case '-Infinity':
return -Infinity;
default:
return value;
}
}
static deserializeLocalValue(
result: Bidi.CommonDataTypes.RemoteValue
): unknown {
switch (result.type) {
case 'array':
// TODO: Check expected output when value is undefined
return result.value?.map(value => {
return BidiSerializer.deserializeLocalValue(value);
});
case 'set':
// TODO: Check expected output when value is undefined
return result.value.reduce((acc: Set<unknown>, value: unknown) => {
return acc.add(value);
}, new Set());
case 'object':
if (result.value) {
return result.value.reduce((acc: Record<any, unknown>, tuple) => {
const {key, value} = BidiSerializer.deserializeTuple(tuple);
acc[key as any] = value;
return acc;
}, {});
}
break;
case 'map':
return result.value.reduce((acc: Map<unknown, unknown>, tuple) => {
const {key, value} = BidiSerializer.deserializeTuple(tuple);
return acc.set(key, value);
}, new Map());
case 'promise':
return {};
case 'undefined':
return undefined;
case 'null':
return null;
case 'number':
return BidiSerializer.deserializeNumber(result.value);
case 'bigint':
return BigInt(result.value);
case 'boolean':
return Boolean(result.value);
case 'string':
return result.value;
}
throw new UnserializableError(
`Deserialization of type ${result.type} not supported.`
);
}
static deserializeTuple([serializedKey, serializedValue]: [
Bidi.CommonDataTypes.RemoteValue | string,
Bidi.CommonDataTypes.RemoteValue
]): {key: unknown; value: unknown} {
const key =
typeof serializedKey === 'string'
? serializedKey
: BidiSerializer.deserializeLocalValue(serializedKey);
const value = BidiSerializer.deserializeLocalValue(serializedValue);
return {key, value};
}
static deserialize(result: Bidi.CommonDataTypes.RemoteValue): unknown {
if (!result) {
debugError('Service did not produce a result.');
return undefined;
}
try {
return BidiSerializer.deserializeLocalValue(result);
} catch (error) {
if (error instanceof UnserializableError) {
debugError(error.message);
return undefined;
}
throw error;
}
}
}

View File

@ -159,6 +159,13 @@ export const isNumber = (obj: unknown): obj is number => {
return typeof obj === 'number' || obj instanceof Number;
};
/**
* @internal
*/
export const isPlainObject = (obj: unknown): obj is Record<any, unknown> => {
return typeof obj === 'object' && obj?.constructor === Object;
};
/**
* @internal
*/
@ -440,3 +447,29 @@ export async function getReadableFromProtocolStream(
},
});
}
/**
* @internal
*/
export function stringifyFunction(expression: Function): string {
let functionText = expression.toString();
try {
new Function('(' + functionText + ')');
} catch (error) {
// This means we might have a function shorthand. Try another
// time prefixing 'function '.
if (functionText.startsWith('async ')) {
functionText =
'async function ' + functionText.substring('async '.length);
} else {
functionText = 'function ' + functionText;
}
try {
new Function('(' + functionText + ')');
} catch (error) {
// We tried hard to serialize, but there's a weird beast here.
throw new Error('Passed function is not well-serializable!');
}
}
return functionText;
}

View File

@ -1,18 +0,0 @@
/**
* Copyright 2022 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.
*/
export * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/bidiMapper.js';
export * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';

View File

@ -379,7 +379,7 @@
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should not throw an error when evaluation does a navigation",
"platforms": ["darwin", "win32"],
"platforms": ["darwin", "win32", "linux"],
"parameters": ["firefox"],
"expectations": ["SKIP"]
},
@ -3174,9 +3174,111 @@
"expectations": ["PASS"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work",
"testIdPattern": "[evaluation.spec]",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["PASS"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should evaluate in the page context",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work right after framenavigated",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should work from-inside an exposed function",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should be able to throw a tricky error",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should accept element handle as an argument",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should throw if underlying element was disposed",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should throw if elementHandles are from other frames",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should throw a nice error after a navigation",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should throw when evaluation triggers reload",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should simulate a user gesture",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should not throw an error when evaluation does a navigation",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluate should not throw an error when evaluation does a navigation",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluateOnNewDocument should evaluate before anything else on the page",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Page.evaluateOnNewDocument should work with CSP",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Frame.evaluate should have different execution contexts",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Frame.evaluate should have correct execution contexts",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[evaluation.spec] Evaluation specs Frame.evaluate should execute after cross-site navigation",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP"]
}
]

View File

@ -51,7 +51,7 @@ describe('WebDriver BiDi', () => {
JSON.stringify(rawResponse)
);
const response = await responsePromise;
expect(response).toEqual(rawResponse.result);
expect(response).toEqual(rawResponse);
connection.dispose();
expect(transport.closed).toBeTruthy();
});

View File

@ -328,6 +328,20 @@ describe('Evaluation specs', function () {
})
).toBe(undefined);
});
it('should return promise as empty object', async () => {
const {page} = getTestState();
const result = await page.evaluate(() => {
return {
promise: new Promise(resolve => {
setTimeout(resolve, 1000);
}),
};
});
expect(result).toEqual({
promise: {},
});
});
it('should fail for circular object', async () => {
const {page} = getTestState();