diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index a5903728a..d6058169d 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -227,6 +227,73 @@ const SKIP_ALL_PAUSES = { }, }; +/** + * Constructed from Runtime.ExceptionDetails of an uncaught error. + * + * TODO: Also construct from a RemoteObject of subtype 'error'. + * + * Consists of the message, a fully resolved stack trace and a fully resolved 'cause' chain. + */ +export class SymbolizedError { + readonly message: string; + readonly stackTrace?: DevTools.StackTrace.StackTrace.StackTrace; + readonly cause?: SymbolizedError; + + private constructor( + message: string, + stackTrace?: DevTools.StackTrace.StackTrace.StackTrace, + cause?: SymbolizedError, + ) { + this.message = message; + this.stackTrace = stackTrace; + this.cause = cause; + } + + static async fromDetails(opts: { + devTools?: TargetUniverse; + details: Protocol.Runtime.ExceptionDetails; + targetId: string; + includeStackAndCause?: boolean; + resolvedStackTraceForTesting?: DevTools.StackTrace.StackTrace.StackTrace; + }): Promise { + const message = SymbolizedError.#getMessage(opts.details); + if (!opts.includeStackAndCause || !opts.devTools) { + return new SymbolizedError(message, opts.resolvedStackTraceForTesting); + } + + let stackTrace: DevTools.StackTrace.StackTrace.StackTrace | undefined; + if (opts.resolvedStackTraceForTesting) { + stackTrace = opts.resolvedStackTraceForTesting; + } else if (opts.details.stackTrace) { + try { + stackTrace = await createStackTrace( + opts.devTools, + opts.details.stackTrace, + opts.targetId, + ); + } catch { + // ignore + } + } + + // TODO: Turn opts.details.exception into a JSHandle and retrieve the 'cause' property. + // If its an Error, recursively create a SymbolizedError. + return new SymbolizedError(message, stackTrace); + } + + static #getMessage(details: Protocol.Runtime.ExceptionDetails): string { + // For Runtime.exceptionThrown with a present exception object, `details.text` will be "Uncaught" and + // we have to manually parse out the error text from the exception description. + // In the case of Runtime.getExceptionDetails, `details.text` has the Error.message. + if (details.text === 'Uncaught') { + const messageWithRest = + details.exception?.description?.split('\n at ', 2) ?? []; + return 'Uncaught ' + (messageWithRest[0] ?? ''); + } + return details.text; + } +} + export async function createStackTraceForConsoleMessage( devTools: TargetUniverse, consoleMessage: ConsoleMessage, diff --git a/src/PageCollector.ts b/src/PageCollector.ts index abbd6ebd3..eeb29a209 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -23,17 +23,11 @@ import { } from './third_party/index.js'; export class UncaughtError { - readonly message: string; - readonly stackTrace?: Protocol.Runtime.StackTrace; + readonly details: Protocol.Runtime.ExceptionDetails; readonly targetId: string; - constructor( - message: string, - stackTrace: Protocol.Runtime.StackTrace | undefined, - targetId: string, - ) { - this.message = message; - this.stackTrace = stackTrace; + constructor(details: Protocol.Runtime.ExceptionDetails, targetId: string) { + this.details = details; this.targetId = targetId; } } @@ -328,13 +322,9 @@ class PageEventSubscriber { }; #onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent) => { - const {exception, text, stackTrace} = event.exceptionDetails; - const messageWithRest = exception?.description?.split('\n at ', 2) ?? []; - const message = text + ' ' + (messageWithRest[0] ?? ''); - this.#page.emit( 'uncaughtError', - new UncaughtError(message, stackTrace, this.#targetId), + new UncaughtError(event.exceptionDetails, this.#targetId), ); }; diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index c08108009..1159307a9 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -6,10 +6,10 @@ import { createStackTraceForConsoleMessage, - createStackTrace, type TargetUniverse, + SymbolizedError, } from '../DevtoolsUtils.js'; -import type {UncaughtError} from '../PageCollector.js'; +import {UncaughtError} from '../PageCollector.js'; import type * as DevTools from '../third_party/index.js'; import type {ConsoleMessage} from '../third_party/index.js'; @@ -21,13 +21,13 @@ export interface ConsoleFormatterOptions { } export class ConsoleFormatter { - #msg: ConsoleMessage | Error | UncaughtError; + #msg: ConsoleMessage | Error | SymbolizedError; #resolvedArgs: unknown[] = []; #resolvedStackTrace?: DevTools.DevTools.StackTrace.StackTrace.StackTrace; #id?: number; private constructor( - msg: ConsoleMessage | Error | UncaughtError, + msg: ConsoleMessage | Error | SymbolizedError, options?: ConsoleFormatterOptions, ) { this.#msg = msg; @@ -39,6 +39,19 @@ export class ConsoleFormatter { msg: ConsoleMessage | Error | UncaughtError, options?: ConsoleFormatterOptions, ): Promise { + if (msg instanceof UncaughtError) { + return new ConsoleFormatter( + await SymbolizedError.fromDetails({ + devTools: options?.devTools, + details: msg.details, + targetId: msg.targetId, + includeStackAndCause: options?.fetchDetailedData, + resolvedStackTraceForTesting: options?.resolvedStackTraceForTesting, + }), + options, + ); + } + const formatter = new ConsoleFormatter(msg, options); if (options?.fetchDetailedData) { await formatter.#loadDetailedData(options?.devTools); @@ -47,7 +60,7 @@ export class ConsoleFormatter { } #isConsoleMessage( - msg: ConsoleMessage | Error | UncaughtError, + msg: ConsoleMessage | Error | SymbolizedError, ): msg is ConsoleMessage { // No `instanceof` as tests mock `ConsoleMessage`. return 'args' in msg && typeof msg.args === 'function'; @@ -77,12 +90,6 @@ export class ConsoleFormatter { devTools, this.#msg, ); - } else if (this.#msg.stackTrace) { - this.#resolvedStackTrace = await createStackTrace( - devTools, - this.#msg.stackTrace, - this.#msg.targetId, - ); } } catch { // ignore @@ -105,7 +112,11 @@ export class ConsoleFormatter { this.#id !== undefined ? `ID: ${this.#id}` : '', `Message: ${this.#getType()}> ${this.#getText()}`, this.#formatArgs(), - this.#formatStackTrace(this.#resolvedStackTrace), + this.#formatStackTrace( + this.#msg instanceof SymbolizedError + ? this.#msg.stackTrace + : this.#resolvedStackTrace, + ), ].filter(line => !!line); return result.join('\n'); } diff --git a/tests/PageCollector.test.ts b/tests/PageCollector.test.ts index 0c49d435d..48e60cf36 100644 --- a/tests/PageCollector.test.ts +++ b/tests/PageCollector.test.ts @@ -408,8 +408,9 @@ describe('ConsoleCollector', () => { onUncaughtErrorListener, sinon.match(e => { return ( - e.message === 'Uncaught SyntaxError: Expected {' && - e.stackTrace.callFrames.length === 0 + e.details.exception.description === 'SyntaxError: Expected {', + e.details.text === 'Uncaught', + e.details.stackTrace.callFrames.length === 0 ); }), ); diff --git a/tests/formatters/ConsoleFormatter.test.ts b/tests/formatters/ConsoleFormatter.test.ts index aaea6bbe4..58e727f3f 100644 --- a/tests/formatters/ConsoleFormatter.test.ts +++ b/tests/formatters/ConsoleFormatter.test.ts @@ -70,8 +70,16 @@ describe('ConsoleFormatter', () => { it('formats an UncaughtError', async t => { const error = new UncaughtError( - 'Uncaught TypeError: Cannot read properties of undefined', - undefined, + { + exceptionId: 1, + lineNumber: 0, + columnNumber: 5, + exception: { + type: 'object', + description: 'TypeError: Cannot read properties of undefined', + }, + text: 'Uncaught', + }, '', ); const result = ( @@ -231,8 +239,16 @@ describe('ConsoleFormatter', () => { ], } as unknown as DevTools.StackTrace.StackTrace.StackTrace; const error = new UncaughtError( - 'Uncaught TypeError: Cannot read properties of undefined', - undefined, + { + exceptionId: 1, + lineNumber: 0, + columnNumber: 5, + exception: { + type: 'object', + description: 'TypeError: Cannot read properties of undefined', + }, + text: 'Uncaught', + }, '', );