feat: implement detailed errors for evaluation (#10114)

This commit is contained in:
jrandolf 2023-05-10 08:23:29 +00:00 committed by GitHub
parent 75a50257e0
commit 317fa732f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 568 additions and 70 deletions

View File

@ -128,7 +128,6 @@ sidebar_label: API
| [defaultArgs](./puppeteer.defaultargs.md) | |
| [devices](./puppeteer.devices.md) | |
| [errors](./puppeteer.errors.md) | |
| [EVALUATION_SCRIPT_URL](./puppeteer.evaluation_script_url.md) | |
| [executablePath](./puppeteer.executablepath.md) | |
| [KnownDevices](./puppeteer.knowndevices.md) | A list of devices to be used with [Page.emulate()](./puppeteer.page.emulate.md). |
| [launch](./puppeteer.launch.md) | |

View File

@ -1,11 +0,0 @@
---
sidebar_label: EVALUATION_SCRIPT_URL
---
# EVALUATION_SCRIPT_URL variable
#### Signature:
```typescript
EVALUATION_SCRIPT_URL = 'pptr://__puppeteer_evaluation_script__';
```

View File

@ -19,9 +19,13 @@ import {Protocol} from 'devtools-protocol';
import {assert} from '../util/assert.js';
import {CDPSession} from './Connection.js';
import {EVALUATION_SCRIPT_URL} from './ExecutionContext.js';
import {addEventListener, debugError, PuppeteerEventListener} from './util.js';
import {removeEventListeners} from './util.js';
import {
addEventListener,
debugError,
PuppeteerEventListener,
PuppeteerURL,
removeEventListeners,
} from './util.js';
/**
* @internal
@ -264,7 +268,7 @@ export class JSCoverage {
event: Protocol.Debugger.ScriptParsedEvent
): Promise<void> {
// Ignore puppeteer-injected scripts
if (event.url === EVALUATION_SCRIPT_URL) {
if (PuppeteerURL.isPuppeteerURL(event.url)) {
return;
}
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true.

View File

@ -41,7 +41,7 @@ import {LazyArg} from './LazyArg.js';
import {CDPPage} from './Page.js';
import {ElementFor, EvaluateFuncWith, HandleFor, NodeFor} from './types.js';
import {KeyInput} from './USKeyboardLayout.js';
import {debugError, isString} from './util.js';
import {debugError, isString, withSourcePuppeteerURLIfNone} from './util.js';
const applyOffsetsToQuad = (
quad: Point[],
@ -138,6 +138,7 @@ export class CDPElementHandle<
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction);
const elementHandle = await this.$(selector);
if (!elementHandle) {
throw new Error(
@ -161,6 +162,7 @@ export class CDPElementHandle<
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
const results = await this.$$(selector);
const elements = await this.evaluateHandle((_, ...elements) => {
return elements;

View File

@ -32,18 +32,20 @@ import {LazyArg} from './LazyArg.js';
import {scriptInjector} from './ScriptInjector.js';
import {EvaluateFunc, HandleFor} from './types.js';
import {
PuppeteerURL,
createEvaluationError,
createJSHandle,
getExceptionMessage,
getSourcePuppeteerURLIfAvailable,
isString,
valueFromRemoteObject,
} from './util.js';
/**
* @public
*/
export const EVALUATION_SCRIPT_URL = 'pptr://__puppeteer_evaluation_script__';
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
const getSourceUrlComment = (url: string) => {
return `//# sourceURL=${url}`;
};
/**
* Represents a context for JavaScript execution.
*
@ -270,14 +272,17 @@ export class ExecutionContext {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
const sourceUrlComment = getSourceUrlComment(
getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ??
PuppeteerURL.INTERNAL_URL
);
if (isString(pageFunction)) {
const contextId = this._contextId;
const expression = pageFunction;
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression)
? expression
: expression + '\n' + suffix;
: `${expression}\n${sourceUrlComment}\n`;
const {exceptionDetails, result: remoteObject} = await this._client
.send('Runtime.evaluate', {
@ -290,9 +295,7 @@ export class ExecutionContext {
.catch(rewriteError);
if (exceptionDetails) {
throw new Error(
'Evaluation failed: ' + getExceptionMessage(exceptionDetails)
);
throw createEvaluationError(exceptionDetails);
}
return returnByValue
@ -300,10 +303,16 @@ export class ExecutionContext {
: createJSHandle(this, remoteObject);
}
const functionDeclaration = stringifyFunction(pageFunction);
const functionDeclarationWithSourceUrl = SOURCE_URL_REGEX.test(
functionDeclaration
)
? functionDeclaration
: `${functionDeclaration}\n${sourceUrlComment}\n`;
let callFunctionOnPromise;
try {
callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', {
functionDeclaration: `${stringifyFunction(pageFunction)}\n${suffix}\n`,
functionDeclaration: functionDeclarationWithSourceUrl,
executionContextId: this._contextId,
arguments: await Promise.all(args.map(convertArgument.bind(this))),
returnByValue,
@ -322,9 +331,7 @@ export class ExecutionContext {
const {exceptionDetails, result: remoteObject} =
await callFunctionOnPromise.catch(rewriteError);
if (exceptionDetails) {
throw new Error(
'Evaluation failed: ' + getExceptionMessage(exceptionDetails)
);
throw createEvaluationError(exceptionDetails);
}
return returnByValue
? valueFromRemoteObject(remoteObject)

View File

@ -39,7 +39,7 @@ import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
import {LazyArg} from './LazyArg.js';
import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js';
import {EvaluateFunc, EvaluateFuncWith, HandleFor, NodeFor} from './types.js';
import {importFSPromises} from './util.js';
import {importFSPromises, withSourcePuppeteerURLIfNone} from './util.js';
/**
* @public
@ -459,6 +459,10 @@ export class Frame {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
return this.worlds[MAIN_WORLD].evaluateHandle(pageFunction, ...args);
}
@ -475,6 +479,10 @@ export class Frame {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
return this.worlds[MAIN_WORLD].evaluate(pageFunction, ...args);
}
@ -536,6 +544,7 @@ export class Frame {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction);
return this.worlds[MAIN_WORLD].$eval(selector, pageFunction, ...args);
}
@ -571,6 +580,7 @@ export class Frame {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
return this.worlds[MAIN_WORLD].$$eval(selector, pageFunction, ...args);
}

View File

@ -23,7 +23,7 @@ import {isErrorLike} from '../util/ErrorLike.js';
import {CDPSession, isTargetClosedError} from './Connection.js';
import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js';
import {EventEmitter} from './EventEmitter.js';
import {EVALUATION_SCRIPT_URL, ExecutionContext} from './ExecutionContext.js';
import {ExecutionContext} from './ExecutionContext.js';
import {Frame} from './Frame.js';
import {FrameTree} from './FrameTree.js';
import {IsolatedWorld} from './IsolatedWorld.js';
@ -31,7 +31,7 @@ import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
import {NetworkManager} from './NetworkManager.js';
import {Target} from './Target.js';
import {TimeoutSettings} from './TimeoutSettings.js';
import {debugError} from './util.js';
import {debugError, PuppeteerURL} from './util.js';
const UTILITY_WORLD_NAME = '__puppeteer_utility_world__';
@ -349,7 +349,7 @@ export class FrameManager extends EventEmitter {
}
await session.send('Page.addScriptToEvaluateOnNewDocument', {
source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`,
source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`,
worldName: name,
});

View File

@ -42,6 +42,7 @@ import {
createJSHandle,
debugError,
setPageContent,
withSourcePuppeteerURLIfNone,
} from './util.js';
import {TaskManager, WaitTask} from './WaitTask.js';
@ -183,6 +184,10 @@ export class IsolatedWorld {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
const context = await this.executionContext();
return context.evaluateHandle(pageFunction, ...args);
}
@ -194,6 +199,10 @@ export class IsolatedWorld {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
const context = await this.executionContext();
return context.evaluate(pageFunction, ...args);
}
@ -240,6 +249,7 @@ export class IsolatedWorld {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction);
const document = await this.document();
return document.$eval(selector, pageFunction, ...args);
}
@ -256,6 +266,7 @@ export class IsolatedWorld {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
const document = await this.document();
return document.$$eval(selector, pageFunction, ...args);
}

View File

@ -23,7 +23,12 @@ import {CDPSession} from './Connection.js';
import type {CDPElementHandle} from './ElementHandle.js';
import {ExecutionContext} from './ExecutionContext.js';
import {EvaluateFuncWith, HandleFor, HandleOr} from './types.js';
import {createJSHandle, releaseObject, valueFromRemoteObject} from './util.js';
import {
createJSHandle,
releaseObject,
valueFromRemoteObject,
withSourcePuppeteerURLIfNone,
} from './util.js';
declare const __JSHandleSymbol: unique symbol;
@ -71,6 +76,10 @@ export class CDPJSHandle<T = unknown> extends JSHandle<T> {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
return await this.executionContext().evaluate(pageFunction, this, ...args);
}
@ -84,6 +93,10 @@ export class CDPJSHandle<T = unknown> extends JSHandle<T> {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
return await this.executionContext().evaluateHandle(
pageFunction,
this,

View File

@ -85,10 +85,10 @@ import {
NodeFor,
} from './types.js';
import {
createClientError,
createJSHandle,
debugError,
evaluationString,
getExceptionMessage,
getReadableAsBuffer,
getReadableFromProtocolStream,
isString,
@ -97,6 +97,7 @@ import {
valueFromRemoteObject,
waitForEvent,
waitWithTimeout,
withSourcePuppeteerURLIfNone,
} from './util.js';
import {WebWorker} from './WebWorker.js';
@ -518,6 +519,10 @@ export class CDPPage extends Page {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
const context = await this.mainFrame().executionContext();
return context.evaluateHandle(pageFunction, ...args);
}
@ -549,6 +554,7 @@ export class CDPPage extends Page {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction);
return this.mainFrame().$eval(selector, pageFunction, ...args);
}
@ -564,6 +570,7 @@ export class CDPPage extends Page {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
return this.mainFrame().$$eval(selector, pageFunction, ...args);
}
@ -735,10 +742,7 @@ export class CDPPage extends Page {
}
#handleException(exceptionDetails: Protocol.Runtime.ExceptionDetails): void {
const message = getExceptionMessage(exceptionDetails);
const err = new Error(message);
err.stack = ''; // Don't report clientside error with a node stack attached
this.emit(PageEmittedEvents.PageError, err);
this.emit(PageEmittedEvents.PageError, createClientError(exceptionDetails));
}
async #onConsoleAPI(
@ -1257,6 +1261,10 @@ export class CDPPage extends Page {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
return this.#frameManager.mainFrame().evaluate(pageFunction, ...args);
}

View File

@ -23,7 +23,7 @@ import {EventEmitter} from './EventEmitter.js';
import {ExecutionContext} from './ExecutionContext.js';
import {CDPJSHandle} from './JSHandle.js';
import {EvaluateFunc, HandleFor} from './types.js';
import {debugError} from './util.js';
import {debugError, withSourcePuppeteerURLIfNone} from './util.js';
/**
* @internal
@ -150,6 +150,10 @@ export class WebWorker extends EventEmitter {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
const context = await this.#executionContext;
return context.evaluate(pageFunction, ...args);
}
@ -173,6 +177,10 @@ export class WebWorker extends EventEmitter {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
const context = await this.#executionContext;
return context.evaluateHandle(pageFunction, ...args);
}

View File

@ -26,12 +26,25 @@ import {EventEmitter} from '../EventEmitter.js';
import {PuppeteerLifeCycleEvent} from '../LifecycleWatcher.js';
import {TimeoutSettings} from '../TimeoutSettings.js';
import {EvaluateFunc, HandleFor} from '../types.js';
import {isString, setPageContent, waitWithTimeout} from '../util.js';
import {
getSourcePuppeteerURLIfAvailable,
isString,
PuppeteerURL,
setPageContent,
waitWithTimeout,
} from '../util.js';
import {Connection} from './Connection.js';
import {ElementHandle} from './ElementHandle.js';
import {JSHandle} from './JSHandle.js';
import {BidiSerializer} from './Serializer.js';
import {createEvaluationError} from './utils.js';
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
const getSourceUrlComment = (url: string) => {
return `//# sourceURL=${url}`;
};
/**
* @internal
@ -120,18 +133,31 @@ export class Context extends EventEmitter {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
const sourceUrlComment = getSourceUrlComment(
getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ??
PuppeteerURL.INTERNAL_URL
);
let responsePromise;
const resultOwnership = returnByValue ? 'none' : 'root';
if (isString(pageFunction)) {
const expression = SOURCE_URL_REGEX.test(pageFunction)
? pageFunction
: `${pageFunction}\n${sourceUrlComment}\n`;
responsePromise = this.#connection.send('script.evaluate', {
expression: pageFunction,
expression: expression,
target: {context: this._contextId},
resultOwnership,
awaitPromise: true,
});
} else {
let functionDeclaration = stringifyFunction(pageFunction);
functionDeclaration = SOURCE_URL_REGEX.test(functionDeclaration)
? functionDeclaration
: `${functionDeclaration}\n${sourceUrlComment}\n`;
responsePromise = this.#connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(pageFunction),
functionDeclaration,
arguments: await Promise.all(
args.map(arg => {
return BidiSerializer.serialize(arg, this);
@ -146,7 +172,7 @@ export class Context extends EventEmitter {
const {result} = await responsePromise;
if ('type' in result && result.type === 'exception') {
throw new Error(result.exceptionDetails.text);
throw createEvaluationError(result.exceptionDetails);
}
return returnByValue

View File

@ -19,6 +19,7 @@ import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {ElementHandle} from '../../api/ElementHandle.js';
import {JSHandle as BaseJSHandle} from '../../api/JSHandle.js';
import {EvaluateFuncWith, HandleFor, HandleOr} from '../../common/types.js';
import {withSourcePuppeteerURLIfNone} from '../util.js';
import {Connection} from './Connection.js';
import {Context} from './Context.js';
@ -55,6 +56,10 @@ export class JSHandle<T = unknown> extends BaseJSHandle<T> {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
return await this.context().evaluate(pageFunction, this, ...args);
}
@ -65,6 +70,10 @@ export class JSHandle<T = unknown> extends BaseJSHandle<T> {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
return await this.context().evaluateHandle(pageFunction, this, ...args);
}

View File

@ -31,7 +31,11 @@ import {Handler} from '../EventEmitter.js';
import {PDFOptions} from '../PDFOptions.js';
import {Viewport} from '../PuppeteerViewport.js';
import {EvaluateFunc, HandleFor} from '../types.js';
import {debugError, waitWithTimeout} from '../util.js';
import {
debugError,
waitWithTimeout,
withSourcePuppeteerURLIfNone,
} from '../util.js';
import {Context, getBidiHandle} from './Context.js';
import {BidiSerializer} from './Serializer.js';
@ -151,6 +155,10 @@ export class Page extends PageBase {
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
return this.#context.evaluateHandle(pageFunction, ...args);
}
@ -161,6 +169,10 @@ export class Page extends PageBase {
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
return this.#context.evaluate(pageFunction, ...args);
}

View File

@ -17,8 +17,10 @@
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {debug} from '../Debug.js';
import {PuppeteerURL} from '../util.js';
import {Context} from './Context.js';
import {BidiSerializer} from './Serializer.js';
/**
* @internal
@ -45,3 +47,50 @@ export async function releaseReference(
debugError(error);
});
}
/**
* @internal
*/
export function createEvaluationError(
details: Bidi.Script.ExceptionDetails
): unknown {
if (details.exception.type !== 'error') {
return BidiSerializer.deserialize(details.exception);
}
const [name = '', ...parts] = details.text.split(': ');
const message = parts.join(': ');
const error = new Error(message);
error.name = name;
// The first line is this function which we ignore.
const stackLines = [];
if (details.stackTrace && stackLines.length < Error.stackTraceLimit) {
for (const frame of details.stackTrace.callFrames.reverse()) {
if (
PuppeteerURL.isPuppeteerURL(frame.url) &&
frame.url !== PuppeteerURL.INTERNAL_URL
) {
const url = PuppeteerURL.parse(frame.url);
stackLines.unshift(
` at ${frame.functionName || url.functionName} (${
url.functionName
} at ${url.siteString}, <anonymous>:${frame.lineNumber}:${
frame.columnNumber
})`
);
} else {
stackLines.push(
` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
frame.lineNumber
}:${frame.columnNumber})`
);
}
if (stackLines.length >= Error.stackTraceLimit) {
break;
}
}
}
error.stack = [details.text, ...stackLines].join('\n');
return error;
}

View File

@ -41,30 +41,213 @@ export const debugError = debug('puppeteer:error');
/**
* @internal
*/
export function getExceptionMessage(
exceptionDetails: Protocol.Runtime.ExceptionDetails
): string {
if (exceptionDetails.exception) {
return (
exceptionDetails.exception.description || exceptionDetails.exception.value
);
export function createEvaluationError(
details: Protocol.Runtime.ExceptionDetails
): unknown {
let name: string;
let message: string;
if (!details.exception) {
name = 'Error';
message = details.text;
} else if (
details.exception.type !== 'object' ||
details.exception.subtype !== 'error'
) {
return valueFromRemoteObject(details.exception);
} else {
const detail = getErrorDetails(details);
name = detail.name;
message = detail.message;
}
let message = exceptionDetails.text;
if (exceptionDetails.stackTrace) {
for (const callframe of exceptionDetails.stackTrace.callFrames) {
const location =
callframe.url +
':' +
callframe.lineNumber +
':' +
callframe.columnNumber;
const functionName = callframe.functionName || '<anonymous>';
message += `\n at ${functionName} (${location})`;
const messageHeight = message.split('\n').length;
const error = new Error(message);
error.name = name;
const stackLines = error.stack!.split('\n');
const messageLines = stackLines.splice(0, messageHeight);
// The first line is this function which we ignore.
stackLines.shift();
if (details.stackTrace && stackLines.length < Error.stackTraceLimit) {
for (const frame of details.stackTrace.callFrames.reverse()) {
if (
PuppeteerURL.isPuppeteerURL(frame.url) &&
frame.url !== PuppeteerURL.INTERNAL_URL
) {
const url = PuppeteerURL.parse(frame.url);
stackLines.unshift(
` at ${frame.functionName || url.functionName} (${
url.functionName
} at ${url.siteString}, <anonymous>:${frame.lineNumber}:${
frame.columnNumber
})`
);
} else {
stackLines.push(
` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
frame.lineNumber
}:${frame.columnNumber})`
);
}
if (stackLines.length >= Error.stackTraceLimit) {
break;
}
}
}
return message;
error.stack = [...messageLines, ...stackLines].join('\n');
return error;
}
/**
* @internal
*/
export function createClientError(
details: Protocol.Runtime.ExceptionDetails
): unknown {
let name: string;
let message: string;
if (!details.exception) {
name = 'Error';
message = details.text;
} else if (
details.exception.type !== 'object' ||
details.exception.subtype !== 'error'
) {
return valueFromRemoteObject(details.exception);
} else {
const detail = getErrorDetails(details);
name = detail.name;
message = detail.message;
}
const messageHeight = message.split('\n').length;
const error = new Error(message);
error.name = name;
const stackLines = [];
const messageLines = error.stack!.split('\n').splice(0, messageHeight);
if (details.stackTrace && stackLines.length < Error.stackTraceLimit) {
for (const frame of details.stackTrace.callFrames.reverse()) {
stackLines.push(
` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
frame.lineNumber
}:${frame.columnNumber})`
);
if (stackLines.length >= Error.stackTraceLimit) {
break;
}
}
}
error.stack = [...messageLines, ...stackLines].join('\n');
return error;
}
const getErrorDetails = (details: Protocol.Runtime.ExceptionDetails) => {
let name = '';
let message: string;
const lines = details.exception?.description?.split('\n') ?? [];
const size = details.stackTrace?.callFrames.length ?? 0;
lines.splice(-size, size);
if (details.exception?.className) {
name = details.exception.className;
}
message = lines.join('\n');
if (name && message.startsWith(`${name}: `)) {
message = message.slice(name.length + 2);
}
return {message, name};
};
/**
* @internal
*/
const SOURCE_URL = Symbol('Source URL for Puppeteer evaluation scripts');
/**
* @internal
*/
export class PuppeteerURL {
static INTERNAL_URL = 'pptr:internal';
static fromCallSite(
functionName: string,
site: NodeJS.CallSite
): PuppeteerURL {
const url = new PuppeteerURL();
url.#functionName = functionName;
url.#siteString = site.toString();
return url;
}
static parse = (url: string): PuppeteerURL => {
url = url.slice('pptr:'.length);
const [functionName = '', siteString = ''] = url.split(';');
const puppeteerUrl = new PuppeteerURL();
puppeteerUrl.#functionName = functionName;
puppeteerUrl.#siteString = globalThis.atob(siteString);
return puppeteerUrl;
};
static isPuppeteerURL = (url: string): boolean => {
return url.startsWith('pptr:');
};
#functionName!: string;
#siteString!: string;
get functionName(): string {
return this.#functionName;
}
get siteString(): string {
return this.#siteString;
}
toString(): string {
return `pptr:${[this.#functionName, globalThis.btoa(this.#siteString)].join(
';'
)}`;
}
}
/**
* @internal
*/
export const withSourcePuppeteerURLIfNone = <T extends NonNullable<unknown>>(
functionName: string,
object: T
): T => {
if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) {
return object;
}
const original = Error.prepareStackTrace;
Error.prepareStackTrace = (_, stack) => {
// First element is the function. Second element is the caller of this
// function. Third element is the caller of the caller of this function
// which is precisely what we want.
return stack[2];
};
const site = new Error().stack as unknown as NodeJS.CallSite;
Error.prepareStackTrace = original;
return Object.assign(object, {
[SOURCE_URL]: PuppeteerURL.fromCallSite(functionName, site),
});
};
/**
* @internal
*/
export const getSourcePuppeteerURLIfAvailable = <
T extends NonNullable<unknown>
>(
object: T
): PuppeteerURL | undefined => {
if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) {
return object[SOURCE_URL as keyof T] as PuppeteerURL;
}
return undefined;
};
/**
* @internal
*/

View File

@ -1979,6 +1979,30 @@
"parameters": ["cdp", "firefox"],
"expectations": ["SKIP"]
},
{
"testIdPattern": "[stacktrace.spec] Stack trace should work",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["cdp", "firefox"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[stacktrace.spec] Stack trace should work with contiguous evaluation",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["cdp", "firefox"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[stacktrace.spec] Stack trace should work with handles",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["cdp", "firefox"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[stacktrace.spec] Stack trace should work with nested function calls",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["cdp", "firefox"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[target.spec] Target Browser.waitForTarget should wait for a target",
"platforms": ["darwin", "linux", "win32"],

View File

@ -230,8 +230,7 @@ describe('Evaluation specs', function () {
.catch(error_ => {
return (error = error_);
});
expect(error).toBeTruthy();
expect(error.message).toContain('qwerty');
expect(error).toEqual('qwerty');
});
it('should support thrown numbers as error messages', async () => {
const {page} = getTestState();
@ -244,8 +243,7 @@ describe('Evaluation specs', function () {
.catch(error_ => {
return (error = error_);
});
expect(error).toBeTruthy();
expect(error.message).toContain('100500');
expect(error).toEqual(100500);
});
it('should return complex objects', async () => {
const {page} = getTestState();

146
test/src/stacktrace.spec.ts Normal file
View File

@ -0,0 +1,146 @@
/**
* 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 assert from 'assert';
import expect from 'expect';
import {
getTestState,
setupTestBrowserHooks,
setupTestPageAndContextHooks,
} from './mocha-utils.js';
const FILENAME = __filename.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
describe('Stack trace', function () {
setupTestBrowserHooks();
setupTestPageAndContextHooks();
it('should work', async () => {
const {page} = getTestState();
const error = (await page
.evaluate(() => {
throw new Error('Test');
})
.catch((error: Error) => {
return error;
})) as Error;
expect(error.name).toEqual('Error');
expect(error.message).toEqual('Test');
assert(error.stack);
error.stack = error.stack.replace(new RegExp(FILENAME, 'g'), '<filename>');
expect(error.stack.split('\n at ').slice(0, 2)).toMatchObject({
...[
'Error: Test',
'evaluate (evaluate at Context.<anonymous> (<filename>:31:14), <anonymous>:1:18)',
],
});
});
it('should work with handles', async () => {
const {page} = getTestState();
const error = (await page
.evaluateHandle(() => {
throw new Error('Test');
})
.catch((error: Error) => {
return error;
})) as Error;
expect(error.name).toEqual('Error');
expect(error.message).toEqual('Test');
assert(error.stack);
error.stack = error.stack.replace(new RegExp(FILENAME, 'g'), '<filename>');
expect(error.stack.split('\n at ').slice(0, 2)).toMatchObject({
...[
'Error: Test',
'evaluateHandle (evaluateHandle at Context.<anonymous> (<filename>:51:14), <anonymous>:1:18)',
],
});
});
it('should work with contiguous evaluation', async () => {
const {page} = getTestState();
const thrower = await page.evaluateHandle(() => {
return () => {
throw new Error('Test');
};
});
const error = (await thrower
.evaluate(thrower => {
thrower();
})
.catch((error: Error) => {
return error;
})) as Error;
expect(error.name).toEqual('Error');
expect(error.message).toEqual('Test');
assert(error.stack);
error.stack = error.stack.replace(new RegExp(FILENAME, 'g'), '<filename>');
expect(error.stack.split('\n at ').slice(0, 3)).toMatchObject({
...[
'Error: Test',
'evaluateHandle (evaluateHandle at Context.<anonymous> (<filename>:70:36), <anonymous>:2:22)',
'evaluate (evaluate at Context.<anonymous> (<filename>:76:14), <anonymous>:1:12)',
],
});
});
it('should work with nested function calls', async () => {
const {page} = getTestState();
const error = (await page
.evaluate(() => {
function a() {
throw new Error('Test');
}
function b() {
a();
}
function c() {
b();
}
function d() {
c();
}
d();
})
.catch((error: Error) => {
return error;
})) as Error;
expect(error.name).toEqual('Error');
expect(error.message).toEqual('Test');
assert(error.stack);
error.stack = error.stack.replace(new RegExp(FILENAME, 'g'), '<filename>');
expect(error.stack.split('\n at ').slice(0, 6)).toMatchObject({
...[
'Error: Test',
'a (evaluate at Context.<anonymous> (<filename>:97:14), <anonymous>:2:22)',
'b (evaluate at Context.<anonymous> (<filename>:97:14), <anonymous>:5:16)',
'c (evaluate at Context.<anonymous> (<filename>:97:14), <anonymous>:8:16)',
'd (evaluate at Context.<anonymous> (<filename>:97:14), <anonymous>:11:16)',
'evaluate (evaluate at Context.<anonymous> (<filename>:97:14), <anonymous>:13:12)',
],
});
});
});