Skip to content

Commit

Permalink
feat(evaluate): return user-readable error from evaluate (#2329)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed May 21, 2020
1 parent 0a8fa6e commit 5ee6494
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 51 deletions.
32 changes: 16 additions & 16 deletions src/chromium/crExecutionContext.ts
Expand Up @@ -42,18 +42,10 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {

async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
if (helper.isString(pageFunction)) {
const contextId = this._contextId;
const expression: string = pageFunction;
const {exceptionDetails, result: remoteObject} = await this._client.send('Runtime.evaluate', {
expression: js.ensureSourceUrl(expression),
contextId,
returnByValue,
awaitPromise: true,
userGesture: true
}).catch(rewriteError);
if (exceptionDetails)
throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails));
return returnByValue ? valueFromRemoteObject(remoteObject) : context.createHandle(remoteObject);
return this._callOnUtilityScript(context,
`evaluate`, [
{ value: js.ensureSourceUrl(pageFunction) },
], returnByValue, () => { });
}

if (typeof pageFunction !== 'function')
Expand Down Expand Up @@ -81,15 +73,23 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
return { value };
});

return this._callOnUtilityScript(context,
'callFunction', [
{ value: functionText },
...values.map(value => ({ value })),
...handles,
], returnByValue, dispose);
}

private async _callOnUtilityScript(context: js.ExecutionContext, method: string, args: Protocol.Runtime.CallArgument[], returnByValue: boolean, dispose: () => void) {
try {
const utilityScript = await context.utilityScript();
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: `function (...args) { return this.evaluate(...args) }${js.generateSourceUrl()}`,
functionDeclaration: `function (...args) { return this.${method}(...args) }${js.generateSourceUrl()}`,
objectId: utilityScript._remoteObject.objectId,
arguments: [
{ value: functionText },
...values.map(value => ({ value })),
...handles,
{ value: returnByValue },
...args
],
returnByValue,
awaitPromise: true,
Expand Down
29 changes: 16 additions & 13 deletions src/firefox/ffExecutionContext.ts
Expand Up @@ -41,15 +41,10 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {

async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
if (helper.isString(pageFunction)) {
const payload = await this._session.send('Runtime.evaluate', {
expression: js.ensureSourceUrl(pageFunction),
returnByValue,
executionContextId: this._executionContextId,
}).catch(rewriteError);
checkException(payload.exceptionDetails);
if (returnByValue)
return deserializeValue(payload.result!);
return context.createHandle(payload.result);
return this._callOnUtilityScript(context,
`evaluate`, [
{ value: pageFunction },
], returnByValue, () => {});
}
if (typeof pageFunction !== 'function')
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`);
Expand All @@ -68,15 +63,23 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
return { value };
});

return this._callOnUtilityScript(context,
`callFunction`, [
{ value: functionText },
...values.map(value => ({ value })),
...handles,
], returnByValue, dispose);
}

private async _callOnUtilityScript(context: js.ExecutionContext, method: string, args: Protocol.Runtime.CallFunctionArgument[], returnByValue: boolean, dispose: () => void) {
try {
const utilityScript = await context.utilityScript();
const payload = await this._session.send('Runtime.callFunction', {
functionDeclaration: `(utilityScript, ...args) => utilityScript.evaluate(...args)`,
functionDeclaration: `(utilityScript, ...args) => utilityScript.${method}(...args)`,
args: [
{ objectId: utilityScript._remoteObject.objectId, value: undefined },
{ value: functionText },
...values.map(value => ({ value })),
...handles,
{ value: returnByValue },
...args
],
returnByValue,
executionContextId: this._executionContextId
Expand Down
22 changes: 20 additions & 2 deletions src/injected/utilityScript.ts
Expand Up @@ -15,7 +15,12 @@
*/

export default class UtilityScript {
evaluate(functionText: string, ...args: any[]) {
evaluate(returnByValue: boolean, expression: string) {
const result = global.eval(expression);
return returnByValue ? this._serialize(result) : result;
}

callFunction(returnByValue: boolean, functionText: string, ...args: any[]) {
const argCount = args[0] as number;
const handleCount = args[argCount + 1] as number;
const handles = { __proto__: null } as any;
Expand All @@ -34,6 +39,19 @@ export default class UtilityScript {
for (let i = 0; i < argCount; i++)
processedArgs[i] = visit(args[i + 1]);
const func = global.eval('(' + functionText + ')');
return func(...processedArgs);
const result = func(...processedArgs);
return returnByValue ? this._serialize(result) : result;
}

private _serialize(value: any): any {
if (value instanceof Error) {
const error = value;
if ('captureStackTrace' in global.Error) {
// v8
return error.stack;
}
return `${error.name}: ${error.message}\n${error.stack}`;
}
return value;
}
}
33 changes: 17 additions & 16 deletions src/webkit/wkExecutionContext.ts
Expand Up @@ -55,8 +55,8 @@ 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(context, pageFunction, args);
if (response.result.type === 'object' && response.result.className === 'Promise') {
let response = await this._evaluateRemoteObject(context, pageFunction, args, returnByValue);
if (response.result.objectId && response.result.className === 'Promise') {
response = await Promise.race([
this._executionContextDestroyedPromise.then(() => contextDestroyedResult),
this._session.send('Runtime.awaitPromise', {
Expand All @@ -79,14 +79,15 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
}

private async _evaluateRemoteObject(context: js.ExecutionContext, pageFunction: Function | string, args: any[]): Promise<any> {
private async _evaluateRemoteObject(context: js.ExecutionContext, pageFunction: Function | string, args: any[], returnByValue: boolean): Promise<Protocol.Runtime.callFunctionOnReturnValue> {
if (helper.isString(pageFunction)) {
const contextId = this._contextId;
const expression: string = pageFunction;
return await this._session.send('Runtime.evaluate', {
expression: js.ensureSourceUrl(expression),
contextId,
returnByValue: false,
const utilityScript = await context.utilityScript();
const functionDeclaration = `function (returnByValue, pageFunction) { return this.evaluate(returnByValue, pageFunction); }${js.generateSourceUrl()}`;
return await this._session.send('Runtime.callFunctionOn', {
functionDeclaration,
objectId: utilityScript._remoteObject.objectId!,
arguments: [ { value: returnByValue }, { value: pageFunction } ],
returnByValue: false, // We need to return real Promise if that is a promise.
emulateUserGesture: true
});
}
Expand All @@ -110,22 +111,22 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {

try {
const utilityScript = await context.utilityScript();
const callParams = this._serializeFunctionAndArguments(functionText, values, handles);
const callParams = this._serializeFunctionAndArguments(functionText, values, handles, returnByValue);
return await this._session.send('Runtime.callFunctionOn', {
functionDeclaration: callParams.functionText,
objectId: utilityScript._remoteObject.objectId!,
arguments: [ ...callParams.callArguments ],
returnByValue: false,
arguments: callParams.callArguments,
returnByValue: false, // We need to return real Promise if that is a promise.
emulateUserGesture: true
});
} finally {
dispose();
}
}

private _serializeFunctionAndArguments(originalText: string, values: any[], handles: MaybeCallArgument[]): { functionText: string, callArguments: Protocol.Runtime.CallArgument[] } {
private _serializeFunctionAndArguments(originalText: string, values: any[], handles: MaybeCallArgument[], returnByValue: boolean): { functionText: string, callArguments: Protocol.Runtime.CallArgument[]} {
const callArguments: Protocol.Runtime.CallArgument[] = values.map(value => ({ value }));
let functionText = `function (functionText, ...args) { return this.evaluate(functionText, ...args); }${js.generateSourceUrl()}`;
let functionText = `function (returnByValue, functionText, ...args) { return this.callFunction(returnByValue, functionText, ...args); }${js.generateSourceUrl()}`;
if (handles.some(handle => 'unserializable' in handle)) {
const paramStrings = [];
for (let i = 0; i < callArguments.length; i++)
Expand All @@ -138,11 +139,11 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
callArguments.push(handle);
}
}
functionText = `function (functionText, ...a) { return this.evaluate(functionText, ${paramStrings.join(',')}); }${js.generateSourceUrl()}`;
functionText = `function (returnByValue, functionText, ...a) { return this.callFunction(returnByValue, functionText, ${paramStrings.join(',')}); }${js.generateSourceUrl()}`;
} else {
callArguments.push(...(handles as Protocol.Runtime.CallArgument[]));
}
return { functionText, callArguments: [ { value: originalText }, ...callArguments ] };
return { functionText, callArguments: [ { value: returnByValue }, { value: originalText }, ...callArguments ] };

function unserializableToString(arg: any) {
if (Object.is(arg, -0))
Expand Down
10 changes: 6 additions & 4 deletions test/chromium/session.spec.js
Expand Up @@ -42,13 +42,15 @@ describe('ChromiumBrowserContext.createSession', function() {
// JS coverage enables and then disables Debugger domain.
await page.coverage.startJSCoverage();
await page.coverage.stopJSCoverage();
page.on('console', console.log);
// generate a script in page and wait for the event.
const [event] = await Promise.all([
new Promise(f => client.on('Debugger.scriptParsed', f)),
await Promise.all([
new Promise(f => client.on('Debugger.scriptParsed', event => {
if (event.url === 'foo.js')
f();
})),
page.evaluate('//# sourceURL=foo.js')
]);
// expect events to be dispatched.
expect(event.url).toBe('foo.js');
});
it('should be able to detach session', async function({page, browser, server}) {
const client = await page.context().newCDPSession(page);
Expand Down
29 changes: 29 additions & 0 deletions test/evaluation.spec.js
Expand Up @@ -326,6 +326,35 @@ describe('Page.evaluate', function() {
await page.goto(server.EMPTY_PAGE);
expect(await page.evaluate(() => 2 + 2)).toBe(4);
});
it('should evaluate exception', async({page, server}) => {
const error = await page.evaluate(() => {
return (function functionOnStack() {
return new Error('error message');
})();
});
expect(error).toContain('Error: error message');
expect(error).toContain('functionOnStack');
});
it('should evaluate exception', async({page, server}) => {
const error = await page.evaluate(`new Error('error message')`);
expect(error).toContain('Error: error message');
});
it('should evaluate date as {}', async({page}) => {
const result = await page.evaluate(() => ({ date: new Date() }));
expect(result).toEqual({ date: {} });
});
it('should jsonValue() date as {}', async({page}) => {
const resultHandle = await page.evaluateHandle(() => ({ date: new Date() }));
expect(await resultHandle.jsonValue()).toEqual({ date: {} });
});
it.fail(FFOX)('should not use toJSON when evaluating', async({page, server}) => {
const result = await page.evaluate(() => ({ toJSON: () => 'string', data: 'data' }));
expect(result).toEqual({ data: 'data', toJSON: {} });
});
it.fail(FFOX)('should not use toJSON in jsonValue', async({page, server}) => {
const resultHandle = await page.evaluateHandle(() => ({ toJSON: () => 'string', data: 'data' }));
expect(await resultHandle.jsonValue()).toEqual({ data: 'data', toJSON: {} });
});
});

describe('Page.addInitScript', function() {
Expand Down

0 comments on commit 5ee6494

Please sign in to comment.