diff --git a/docs/api/index.md b/docs/api/index.md index a83dd52f862..520cba940a0 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -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) | | diff --git a/docs/api/puppeteer.evaluation_script_url.md b/docs/api/puppeteer.evaluation_script_url.md deleted file mode 100644 index e00bbc863e9..00000000000 --- a/docs/api/puppeteer.evaluation_script_url.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -sidebar_label: EVALUATION_SCRIPT_URL ---- - -# EVALUATION_SCRIPT_URL variable - -#### Signature: - -```typescript -EVALUATION_SCRIPT_URL = 'pptr://__puppeteer_evaluation_script__'; -``` diff --git a/packages/puppeteer-core/src/common/Coverage.ts b/packages/puppeteer-core/src/common/Coverage.ts index b19d79e95e0..7a80e8d3e52 100644 --- a/packages/puppeteer-core/src/common/Coverage.ts +++ b/packages/puppeteer-core/src/common/Coverage.ts @@ -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 { // 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. diff --git a/packages/puppeteer-core/src/common/ElementHandle.ts b/packages/puppeteer-core/src/common/ElementHandle.ts index 351d7057bf9..694d3804fd7 100644 --- a/packages/puppeteer-core/src/common/ElementHandle.ts +++ b/packages/puppeteer-core/src/common/ElementHandle.ts @@ -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>> { + 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>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); const results = await this.$$(selector); const elements = await this.evaluateHandle((_, ...elements) => { return elements; diff --git a/packages/puppeteer-core/src/common/ExecutionContext.ts b/packages/puppeteer-core/src/common/ExecutionContext.ts index e00c45b2dd7..4ecd900c415 100644 --- a/packages/puppeteer-core/src/common/ExecutionContext.ts +++ b/packages/puppeteer-core/src/common/ExecutionContext.ts @@ -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>> | Awaited>> { - 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) diff --git a/packages/puppeteer-core/src/common/Frame.ts b/packages/puppeteer-core/src/common/Frame.ts index b605e60637c..9152ac86c59 100644 --- a/packages/puppeteer-core/src/common/Frame.ts +++ b/packages/puppeteer-core/src/common/Frame.ts @@ -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>>> { + 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>> { + 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>> { + 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>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); return this.worlds[MAIN_WORLD].$$eval(selector, pageFunction, ...args); } diff --git a/packages/puppeteer-core/src/common/FrameManager.ts b/packages/puppeteer-core/src/common/FrameManager.ts index 148fe34095b..5e5e1e67220 100644 --- a/packages/puppeteer-core/src/common/FrameManager.ts +++ b/packages/puppeteer-core/src/common/FrameManager.ts @@ -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, }); diff --git a/packages/puppeteer-core/src/common/IsolatedWorld.ts b/packages/puppeteer-core/src/common/IsolatedWorld.ts index 82272ae32af..d2f9004b1ad 100644 --- a/packages/puppeteer-core/src/common/IsolatedWorld.ts +++ b/packages/puppeteer-core/src/common/IsolatedWorld.ts @@ -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>>> { + 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>> { + 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>> { + 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>> { + pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction); const document = await this.document(); return document.$$eval(selector, pageFunction, ...args); } diff --git a/packages/puppeteer-core/src/common/JSHandle.ts b/packages/puppeteer-core/src/common/JSHandle.ts index e755e9344cb..3232cb27fe2 100644 --- a/packages/puppeteer-core/src/common/JSHandle.ts +++ b/packages/puppeteer-core/src/common/JSHandle.ts @@ -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 extends JSHandle { pageFunction: Func | string, ...args: Params ): Promise>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); return await this.executionContext().evaluate(pageFunction, this, ...args); } @@ -84,6 +93,10 @@ export class CDPJSHandle extends JSHandle { pageFunction: Func | string, ...args: Params ): Promise>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); return await this.executionContext().evaluateHandle( pageFunction, this, diff --git a/packages/puppeteer-core/src/common/Page.ts b/packages/puppeteer-core/src/common/Page.ts index 543065597e6..60083934b64 100644 --- a/packages/puppeteer-core/src/common/Page.ts +++ b/packages/puppeteer-core/src/common/Page.ts @@ -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>>> { + 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>> { + 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>> { + 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>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); return this.#frameManager.mainFrame().evaluate(pageFunction, ...args); } diff --git a/packages/puppeteer-core/src/common/WebWorker.ts b/packages/puppeteer-core/src/common/WebWorker.ts index fface119ad1..d4c04c9d147 100644 --- a/packages/puppeteer-core/src/common/WebWorker.ts +++ b/packages/puppeteer-core/src/common/WebWorker.ts @@ -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>> { + 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>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); const context = await this.#executionContext; return context.evaluateHandle(pageFunction, ...args); } diff --git a/packages/puppeteer-core/src/common/bidi/Context.ts b/packages/puppeteer-core/src/common/bidi/Context.ts index 4d3711d6aac..5be18db74c8 100644 --- a/packages/puppeteer-core/src/common/bidi/Context.ts +++ b/packages/puppeteer-core/src/common/bidi/Context.ts @@ -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>> | Awaited>> { + 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 diff --git a/packages/puppeteer-core/src/common/bidi/JSHandle.ts b/packages/puppeteer-core/src/common/bidi/JSHandle.ts index 2cd2876622f..eb1a737e467 100644 --- a/packages/puppeteer-core/src/common/bidi/JSHandle.ts +++ b/packages/puppeteer-core/src/common/bidi/JSHandle.ts @@ -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 extends BaseJSHandle { pageFunction: Func | string, ...args: Params ): Promise>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); return await this.context().evaluate(pageFunction, this, ...args); } @@ -65,6 +70,10 @@ export class JSHandle extends BaseJSHandle { pageFunction: Func | string, ...args: Params ): Promise>>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluateHandle.name, + pageFunction + ); return await this.context().evaluateHandle(pageFunction, this, ...args); } diff --git a/packages/puppeteer-core/src/common/bidi/Page.ts b/packages/puppeteer-core/src/common/bidi/Page.ts index 524f5ed122f..bea82eefdc9 100644 --- a/packages/puppeteer-core/src/common/bidi/Page.ts +++ b/packages/puppeteer-core/src/common/bidi/Page.ts @@ -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>>> { + 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>> { + pageFunction = withSourcePuppeteerURLIfNone( + this.evaluate.name, + pageFunction + ); return this.#context.evaluate(pageFunction, ...args); } diff --git a/packages/puppeteer-core/src/common/bidi/utils.ts b/packages/puppeteer-core/src/common/bidi/utils.ts index ad4a590c5ab..b493e5b0c6f 100644 --- a/packages/puppeteer-core/src/common/bidi/utils.ts +++ b/packages/puppeteer-core/src/common/bidi/utils.ts @@ -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}, :${frame.lineNumber}:${ + frame.columnNumber + })` + ); + } else { + stackLines.push( + ` at ${frame.functionName || ''} (${frame.url}:${ + frame.lineNumber + }:${frame.columnNumber})` + ); + } + if (stackLines.length >= Error.stackTraceLimit) { + break; + } + } + } + + error.stack = [details.text, ...stackLines].join('\n'); + return error; +} diff --git a/packages/puppeteer-core/src/common/util.ts b/packages/puppeteer-core/src/common/util.ts index d9b66934dec..00decd903da 100644 --- a/packages/puppeteer-core/src/common/util.ts +++ b/packages/puppeteer-core/src/common/util.ts @@ -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 || ''; - 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}, :${frame.lineNumber}:${ + frame.columnNumber + })` + ); + } else { + stackLines.push( + ` at ${frame.functionName || ''} (${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 || ''} (${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 = >( + 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 +>( + object: T +): PuppeteerURL | undefined => { + if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) { + return object[SOURCE_URL as keyof T] as PuppeteerURL; + } + return undefined; +}; + /** * @internal */ diff --git a/test/TestExpectations.json b/test/TestExpectations.json index a7c824d75a6..43b8799b2c4 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -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"], diff --git a/test/src/evaluation.spec.ts b/test/src/evaluation.spec.ts index f8992b051e6..7590e934769 100644 --- a/test/src/evaluation.spec.ts +++ b/test/src/evaluation.spec.ts @@ -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(); diff --git a/test/src/stacktrace.spec.ts b/test/src/stacktrace.spec.ts new file mode 100644 index 00000000000..288f29b9131 --- /dev/null +++ b/test/src/stacktrace.spec.ts @@ -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'), ''); + expect(error.stack.split('\n at ').slice(0, 2)).toMatchObject({ + ...[ + 'Error: Test', + 'evaluate (evaluate at Context. (:31:14), :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'), ''); + expect(error.stack.split('\n at ').slice(0, 2)).toMatchObject({ + ...[ + 'Error: Test', + 'evaluateHandle (evaluateHandle at Context. (:51:14), :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'), ''); + expect(error.stack.split('\n at ').slice(0, 3)).toMatchObject({ + ...[ + 'Error: Test', + 'evaluateHandle (evaluateHandle at Context. (:70:36), :2:22)', + 'evaluate (evaluate at Context. (:76:14), :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'), ''); + expect(error.stack.split('\n at ').slice(0, 6)).toMatchObject({ + ...[ + 'Error: Test', + 'a (evaluate at Context. (:97:14), :2:22)', + 'b (evaluate at Context. (:97:14), :5:16)', + 'c (evaluate at Context. (:97:14), :8:16)', + 'd (evaluate at Context. (:97:14), :11:16)', + 'evaluate (evaluate at Context. (:97:14), :13:12)', + ], + }); + }); +});