api(eval): allow non-toplevel handles as eval arguments (#1404)

This commit is contained in:
Dmitry Gozman 2020-03-18 10:41:46 -07:00 committed by GitHub
parent 045277d5cd
commit dd850ada89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 274 additions and 174 deletions

View File

@ -50,7 +50,6 @@
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0",
"rimraf": "^3.0.2",
"uuid": "^3.4.0",
"ws": "^6.1.0"
},
"devDependencies": {
@ -60,7 +59,6 @@
"@types/pngjs": "^3.4.0",
"@types/proxy-from-env": "^1.0.0",
"@types/rimraf": "^2.0.2",
"@types/uuid": "^3.4.6",
"@types/ws": "^6.0.1",
"@typescript-eslint/eslint-plugin": "^2.6.1",
"@typescript-eslint/parser": "^2.6.1",

View File

@ -55,28 +55,35 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
if (typeof pageFunction !== 'function')
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`);
let functionText = pageFunction.toString();
try {
new Function('(' + functionText + ')');
} catch (e1) {
// 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 (e2) {
// We tried hard to serialize, but there's a weird beast here.
throw new Error('Passed function is not well-serializable!');
const { functionText, values, handles } = js.prepareFunctionCall<Protocol.Runtime.CallArgument>(pageFunction, context, args, (value: any) => {
if (typeof value === 'bigint') // eslint-disable-line valid-typeof
return { handle: { unserializableValue: `${value.toString()}n` } };
if (Object.is(value, -0))
return { handle: { unserializableValue: '-0' } };
if (Object.is(value, Infinity))
return { handle: { unserializableValue: 'Infinity' } };
if (Object.is(value, -Infinity))
return { handle: { unserializableValue: '-Infinity' } };
if (Object.is(value, NaN))
return { handle: { unserializableValue: 'NaN' } };
if (value && (value instanceof js.JSHandle)) {
const remoteObject = toRemoteObject(value);
if (remoteObject.unserializableValue)
return { handle: { unserializableValue: remoteObject.unserializableValue } };
if (!remoteObject.objectId)
return { value: remoteObject.value };
return { handle: { objectId: remoteObject.objectId } };
}
}
return { value };
});
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: functionText + '\n' + suffix + '\n',
executionContextId: this._contextId,
arguments: args.map(convertArgument.bind(this)),
arguments: [
...values.map(value => ({ value })),
...handles,
],
returnByValue,
awaitPromise: true,
userGesture: true
@ -85,33 +92,6 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails));
return returnByValue ? valueFromRemoteObject(remoteObject) : context._createHandle(remoteObject);
function convertArgument(arg: any): any {
if (typeof arg === 'bigint') // eslint-disable-line valid-typeof
return { unserializableValue: `${arg.toString()}n` };
if (Object.is(arg, -0))
return { unserializableValue: '-0' };
if (Object.is(arg, Infinity))
return { unserializableValue: 'Infinity' };
if (Object.is(arg, -Infinity))
return { unserializableValue: '-Infinity' };
if (Object.is(arg, NaN))
return { unserializableValue: 'NaN' };
const objectHandle = arg && (arg instanceof js.JSHandle) ? arg : null;
if (objectHandle) {
if (objectHandle._context !== context)
throw new Error('JSHandles can be evaluated only in the context they were created!');
if (objectHandle._disposed)
throw new Error('JSHandle is disposed!');
const remoteObject = toRemoteObject(objectHandle);
if (remoteObject.unserializableValue)
return { unserializableValue: remoteObject.unserializableValue };
if (!remoteObject.objectId)
return { value: remoteObject.value };
return { objectId: remoteObject.objectId };
}
return { value: arg };
}
function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue {
if (error.message.includes('Object reference chain is too long'))
return {result: {type: 'undefined'}};

View File

@ -44,45 +44,26 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
if (typeof pageFunction !== 'function')
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`);
let functionText = pageFunction.toString();
try {
new Function('(' + functionText + ')');
} catch (e1) {
// 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 (e2) {
// We tried hard to serialize, but there's a weird beast here.
throw new Error('Passed function is not well-serializable!');
}
}
const protocolArgs = args.map(arg => {
if (arg instanceof js.JSHandle) {
if (arg._context !== context)
throw new Error('JSHandles can be evaluated only in the context they were created!');
if (arg._disposed)
throw new Error('JSHandle is disposed!');
return this._toCallArgument(arg._remoteObject);
}
if (Object.is(arg, Infinity))
return {unserializableValue: 'Infinity'};
if (Object.is(arg, -Infinity))
return {unserializableValue: '-Infinity'};
if (Object.is(arg, -0))
return {unserializableValue: '-0'};
if (Object.is(arg, NaN))
return {unserializableValue: 'NaN'};
return {value: arg};
const { functionText, values, handles } = js.prepareFunctionCall<Protocol.Runtime.CallFunctionArgument>(pageFunction, context, args, (value: any) => {
if (Object.is(value, -0))
return { handle: { unserializableValue: '-0' } };
if (Object.is(value, Infinity))
return { handle: { unserializableValue: 'Infinity' } };
if (Object.is(value, -Infinity))
return { handle: { unserializableValue: '-Infinity' } };
if (Object.is(value, NaN))
return { handle: { unserializableValue: 'NaN' } };
if (value && (value instanceof js.JSHandle))
return { handle: this._toCallArgument(value._remoteObject) };
return { value };
});
const payload = await this._session.send('Runtime.callFunction', {
functionDeclaration: functionText,
args: protocolArgs,
args: [
...values.map(value => ({ value })),
...handles,
],
returnByValue,
executionContextId: this._executionContextId
}).catch(rewriteError);

View File

@ -16,6 +16,7 @@
import * as types from './types';
import * as dom from './dom';
import * as platform from './platform';
export interface ExecutionContextDelegate {
evaluate(context: ExecutionContext, returnByValue: boolean, pageFunction: string | Function, ...args: any[]): Promise<any>;
@ -102,3 +103,110 @@ export class JSHandle<T = any> {
return this._context._delegate.handleToString(this, true /* includeType */);
}
}
export function prepareFunctionCall<T>(
pageFunction: Function,
context: ExecutionContext,
args: any[],
toCallArgumentIfNeeded: (value: any) => { handle?: T, value?: any }): { functionText: string, values: any[], handles: T[] } {
let functionText = pageFunction.toString();
try {
new Function('(' + functionText + ')');
} catch (e1) {
// 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 (e2) {
// We tried hard to serialize, but there's a weird beast here.
throw new Error('Passed function is not well-serializable!');
}
}
const guids: string[] = [];
const handles: T[] = [];
const pushHandle = (handle: T): string => {
const guid = platform.guid();
guids.push(guid);
handles.push(handle);
return guid;
};
const visited = new Set<any>();
let error: string | undefined;
const visit = (arg: any, depth: number): any => {
if (!depth) {
error = 'Argument nesting is too deep';
return;
}
if (visited.has(arg)) {
error = 'Argument is a circular structure';
return;
}
if (Array.isArray(arg)) {
visited.add(arg);
const result = [];
for (let i = 0; i < arg.length; ++i)
result.push(visit(arg[i], depth - 1));
visited.delete(arg);
return result;
}
if (arg && (typeof arg === 'object') && !(arg instanceof JSHandle)) {
visited.add(arg);
const result: any = {};
for (const name of Object.keys(arg))
result[name] = visit(arg[name], depth - 1);
visited.delete(arg);
return result;
}
if (arg && (arg instanceof JSHandle)) {
if (arg._context !== context)
throw new Error('JSHandles can be evaluated only in the context they were created!');
if (arg._disposed)
throw new Error('JSHandle is disposed!');
}
const { handle, value } = toCallArgumentIfNeeded(arg);
if (handle)
return pushHandle(handle);
return value;
};
args = args.map(arg => visit(arg, 100));
if (error)
throw new Error(error);
if (!guids.length)
return { functionText, values: args, handles: [] };
functionText = `(...__playwright__args__) => {
return (${functionText})(...(() => {
const args = __playwright__args__;
__playwright__args__ = undefined;
const argCount = args[0];
const handleCount = args[argCount + 1];
const handles = { __proto__: null };
for (let i = 0; i < handleCount; i++)
handles[args[argCount + 2 + i]] = args[argCount + 2 + handleCount + i];
const visit = (arg) => {
if ((typeof arg === 'string') && (arg in handles))
return handles[arg];
if (arg && (typeof arg === 'object')) {
for (const name of Object.keys(arg))
arg[name] = visit(arg[name]);
}
return arg;
};
const result = [];
for (let i = 0; i < argCount; i++)
result[i] = visit(args[i + 1]);
return result;
})());
}`;
return { functionText, values: [ args.length, ...args, guids.length, ...guids ], handles };
}

View File

@ -26,6 +26,7 @@ import * as png from 'pngjs';
import * as http from 'http';
import * as https from 'https';
import * as NodeWebSocket from 'ws';
import * as crypto from 'crypto';
import { assert, helper } from './helper';
import { ConnectionTransport } from './transport';
@ -300,6 +301,14 @@ export function makeWaitForNextTask() {
};
}
export function guid(): string {
if (isNode)
return crypto.randomBytes(16).toString('hex');
const a = new Uint8Array(16);
window.crypto.getRandomValues(a);
return Array.from(a).map(b => b.toString(16).padStart(2, '0')).join('');
}
// 'onmessage' handler must be installed synchronously when 'onopen' callback is invoked to
// avoid missing incoming messages.
export async function connectToWebsocket<T>(url: string, onopen: (transport: ConnectionTransport) => Promise<T>): Promise<T> {

View File

@ -30,7 +30,6 @@ import { kBrowserCloseMessageId } from '../webkit/wkConnection';
import { LaunchOptions, BrowserArgOptions, BrowserType } from './browserType';
import { ConnectionTransport } from '../transport';
import * as ws from 'ws';
import * as uuidv4 from 'uuid/v4';
import { ConnectOptions, LaunchType } from '../browser';
import { BrowserServer } from './browserServer';
import { Events } from '../events';
@ -263,7 +262,7 @@ class SequenceNumberMixer<V> {
function wrapTransportWithWebSocket(transport: ConnectionTransport, port: number) {
const server = new ws.Server({ port });
const guid = uuidv4();
const guid = platform.guid();
const idMixer = new SequenceNumberMixer<{id: number, socket: ws}>();
const pendingBrowserContextCreations = new Set<number>();
const pendingBrowserContextDeletions = new Map<number, string>();

View File

@ -27,7 +27,10 @@ module.exports = {
options: {
transpileOnly: true
},
exclude: /node_modules/
exclude: [
/node_modules/,
/crypto/,
]
}
]
},
@ -41,6 +44,7 @@ module.exports = {
path: path.resolve(__dirname, '../')
},
externals: {
'crypto': 'dummy',
'events': 'dummy',
'fs': 'dummy',
'path': 'dummy',

View File

@ -24,6 +24,8 @@ import * as js from '../javascript';
export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
type MaybeCallArgument = Protocol.Runtime.CallArgument | { unserializable: any };
export class WKExecutionContext implements js.ExecutionContextDelegate {
private _globalObjectIdPromise?: Promise<Protocol.Runtime.RemoteObjectId>;
private readonly _session: WKSession;
@ -45,7 +47,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
try {
let response = await this._evaluateRemoteObject(pageFunction, args);
let response = await this._evaluateRemoteObject(context, pageFunction, args);
if (response.result.type === 'object' && response.result.className === 'Promise') {
response = await Promise.race([
this._executionContextDestroyedPromise.then(() => contextDestroyedResult),
@ -69,7 +71,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
}
private async _evaluateRemoteObject(pageFunction: Function | string, args: any[]): Promise<any> {
private async _evaluateRemoteObject(context: js.ExecutionContext, pageFunction: Function | string, args: any[]): Promise<any> {
if (helper.isString(pageFunction)) {
const contextId = this._contextId;
const expression: string = pageFunction;
@ -85,60 +87,50 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
if (typeof pageFunction !== 'function')
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`);
try {
const callParams = this._serializeFunctionAndArguments(pageFunction, args);
const thisObjectId = await this._contextGlobalObjectId();
return await this._session.send('Runtime.callFunctionOn', {
functionDeclaration: callParams.functionText + '\n' + suffix + '\n',
objectId: thisObjectId,
arguments: callParams.callArguments,
returnByValue: false,
emulateUserGesture: true
});
} catch (err) {
if (err instanceof TypeError && err.message.startsWith('Converting circular structure to JSON'))
err.message += ' Are you passing a nested JSHandle?';
throw err;
}
const { functionText, values, handles } = js.prepareFunctionCall<MaybeCallArgument>(pageFunction, context, args, (value: any) => {
if (typeof value === 'bigint' || Object.is(value, -0) || Object.is(value, Infinity) || Object.is(value, -Infinity) || Object.is(value, NaN))
return { handle: { unserializable: value } };
if (value && (value instanceof js.JSHandle)) {
const remoteObject = toRemoteObject(value);
if (!remoteObject.objectId && !Object.is(valueFromRemoteObject(remoteObject), remoteObject.value))
return { handle: { unserializable: value } };
if (!remoteObject.objectId)
return { value: valueFromRemoteObject(remoteObject) };
return { handle: { objectId: remoteObject.objectId } };
}
return { value };
});
const callParams = this._serializeFunctionAndArguments(functionText, values, handles);
const thisObjectId = await this._contextGlobalObjectId();
return await this._session.send('Runtime.callFunctionOn', {
functionDeclaration: callParams.functionText + '\n' + suffix + '\n',
objectId: thisObjectId,
arguments: callParams.callArguments,
returnByValue: false,
emulateUserGesture: true
});
}
private _serializeFunctionAndArguments(pageFunction: Function, args: any[]): { functionText: string, callArguments: Protocol.Runtime.CallArgument[] } {
let functionText = pageFunction.toString();
try {
new Function('(' + functionText + ')');
} catch (e1) {
// 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 (e2) {
// We tried hard to serialize, but there's a weird beast here.
throw new Error('Passed function is not well-serializable!');
}
}
let serializableArgs;
if (args.some(isUnserializable)) {
serializableArgs = [];
private _serializeFunctionAndArguments(functionText: string, values: any[], handles: MaybeCallArgument[]): { functionText: string, callArguments: Protocol.Runtime.CallArgument[] } {
const callArguments: Protocol.Runtime.CallArgument[] = values.map(value => ({ value }));
if (handles.some(handle => 'unserializable' in handle)) {
const paramStrings = [];
for (const arg of args) {
if (isUnserializable(arg)) {
paramStrings.push(unserializableToString(arg));
for (let i = 0; i < callArguments.length; i++)
paramStrings.push('a[' + i + ']');
for (const handle of handles) {
if ('unserializable' in handle) {
paramStrings.push(unserializableToString(handle.unserializable));
} else {
paramStrings.push('arguments[' + serializableArgs.length + ']');
serializableArgs.push(arg);
paramStrings.push('a[' + callArguments.length + ']');
callArguments.push(handle);
}
}
functionText = `() => (${functionText})(${paramStrings.join(',')})`;
functionText = `(...a) => (${functionText})(${paramStrings.join(',')})`;
} else {
serializableArgs = args;
callArguments.push(...(handles as Protocol.Runtime.CallArgument[]));
}
const serialized = serializableArgs.map((arg: any) => this._convertArgument(arg));
return { functionText, callArguments: serialized };
return { functionText, callArguments };
function unserializableToString(arg: any) {
if (Object.is(arg, -0))
@ -156,25 +148,6 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
throw new Error('Unsupported value: ' + arg + ' (' + (typeof arg) + ')');
}
function isUnserializable(arg: any) {
if (typeof arg === 'bigint')
return true;
if (Object.is(arg, -0))
return true;
if (Object.is(arg, Infinity))
return true;
if (Object.is(arg, -Infinity))
return true;
if (Object.is(arg, NaN))
return true;
if (arg instanceof js.JSHandle) {
const remoteObj = toRemoteObject(arg);
if (!remoteObj.objectId)
return !Object.is(valueFromRemoteObject(remoteObj), remoteObj.value);
}
return false;
}
}
private _contextGlobalObjectId(): Promise<Protocol.Runtime.RemoteObjectId> {
@ -252,21 +225,6 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
return (includeType ? 'JSHandle:' : '') + valueFromRemoteObject(object);
}
private _convertArgument(arg: js.JSHandle | any): Protocol.Runtime.CallArgument {
const objectHandle = arg && (arg instanceof js.JSHandle) ? arg : null;
if (objectHandle) {
if (objectHandle._context._delegate !== this)
throw new Error('JSHandles can be evaluated only in the context they were created!');
if (objectHandle._disposed)
throw new Error('JSHandle is disposed!');
const remoteObject = toRemoteObject(arg);
if (!remoteObject.objectId)
return { value: valueFromRemoteObject(remoteObject) };
return { objectId: remoteObject.objectId };
}
return { value: arg };
}
}
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;

View File

@ -38,19 +38,74 @@ module.exports.describe = function({testRunner, expect, CHROMIUM, FFOX, WEBKIT})
const isFive = await page.evaluate(e => Object.is(e, 5), aHandle);
expect(isFive).toBeTruthy();
});
it('should warn on nested object handles', async({page, server}) => {
const aHandle = await page.evaluateHandle(() => document.body);
let error = null;
await page.evaluateHandle(
opts => opts.elem.querySelector('p'),
{ elem: aHandle }
).catch(e => error = e);
expect(error.message).toContain('Are you passing a nested JSHandle?');
it('should accept nested handle', async({page, server}) => {
const foo = await page.evaluateHandle(() => ({ x: 1, y: 'foo' }));
const result = await page.evaluate(({ foo }) => {
return foo;
}, { foo });
expect(result).toEqual({ x: 1, y: 'foo' });
});
it('should accept nested window handle', async({page, server}) => {
const foo = await page.evaluateHandle(() => window);
const result = await page.evaluate(({ foo }) => {
return foo === window;
}, { foo });
expect(result).toBe(true);
});
it('should accept multiple nested handles', async({page, server}) => {
const foo = await page.evaluateHandle(() => ({ x: 1, y: 'foo' }));
const bar = await page.evaluateHandle(() => 5);
const baz = await page.evaluateHandle(() => (['baz']));
const result = await page.evaluate((a1, a2) => {
return JSON.stringify({ a1, a2 });
}, { foo }, { bar, arr: [{ baz }] });
expect(JSON.parse(result)).toEqual({
a1: { foo: { x: 1, y: 'foo' } },
a2: { bar: 5, arr: [{ baz: ['baz'] }] }
});
});
it('should throw for deep objects', async({page, server}) => {
let a = { x: 1 };
for (let i = 0; i < 98; i++)
a = { x: a };
expect(await page.evaluate(x => x, a)).toEqual(a);
let error = await page.evaluate(x => x, {a}).catch(e => e);
expect(error.message).toBe('Argument nesting is too deep');
error = await page.evaluate(x => x, [a]).catch(e => e);
expect(error.message).toBe('Argument nesting is too deep');
});
it('should throw for circular objects', async({page, server}) => {
const a = { x: 1 };
a.y = a;
const error = await page.evaluate(x => x, a).catch(e => e);
expect(error.message).toBe('Argument is a circular structure');
});
it('should accept same handle multiple times', async({page, server}) => {
const foo = await page.evaluateHandle(() => 1);
expect(await page.evaluate(x => x, { foo, bar: [foo], baz: { foo }})).toEqual({ foo: 1, bar: [1], baz: { foo: 1 } });
});
it('should accept same nested object multiple times', async({page, server}) => {
const foo = { x: 1 };
expect(await page.evaluate(x => x, { foo, bar: [foo], baz: { foo }})).toEqual({ foo: { x: 1 }, bar: [{ x : 1 }], baz: { foo: { x : 1 } } });
});
it('should accept object handle to unserializable value', async({page, server}) => {
const aHandle = await page.evaluateHandle(() => Infinity);
expect(await page.evaluate(e => Object.is(e, Infinity), aHandle)).toBe(true);
});
it.fail(FFOX)('should pass configurable args', async({page, server}) => {
const result = await page.evaluate(arg => {
if (arg.foo !== 42)
throw new Error('Not a 42');
arg.foo = 17;
if (arg.foo !== 17)
throw new Error('Not 17');
delete arg.foo;
if (arg.foo === 17)
throw new Error('Still 17');
return arg;
}, { foo: 42 });
expect(result).toEqual({});
});
it('should use the same JS wrappers', async({page, server}) => {
const aHandle = await page.evaluateHandle(() => {
window.FOO = 123;

View File

@ -58,6 +58,14 @@ module.exports.describe = function({testRunner, expect, defaultBrowserOptions, b
expect(url).toBe(server.EMPTY_PAGE);
});
it('should evaluate handles', async({page, server}) => {
const foo = await page.evaluateHandle(() => ({ x: 1, y: 'foo' }));
const result = await page.evaluate(({ foo }) => {
return foo;
}, { foo });
expect(result).toEqual({ x: 1, y: 'foo' });
});
it('should receive events', async({page, server}) => {
const logs = await page.evaluate(async () => {
const logs = [];