Skip to content

Commit f4cfd46

Browse files
authored
chore: introduce SymbolizedError class (#890)
The "SymbolizedError" class represents a fully resolved error: The stack trace is fully resolved and (in the future), the full `Error.cause` chain is also fully resolved. We'll use the `SymbolizedError` for both "uncaught exceptions" as well as when logging `Error` objects to the console (e.g. `console.log(new Error())`. This means the `resolvedArgs` array in `ConsoleFormatter` will contain one `SymbolizedError` instance for every `Error` object logged.
1 parent b747f9d commit f4cfd46

5 files changed

Lines changed: 117 additions & 32 deletions

File tree

src/DevtoolsUtils.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,73 @@ const SKIP_ALL_PAUSES = {
227227
},
228228
};
229229

230+
/**
231+
* Constructed from Runtime.ExceptionDetails of an uncaught error.
232+
*
233+
* TODO: Also construct from a RemoteObject of subtype 'error'.
234+
*
235+
* Consists of the message, a fully resolved stack trace and a fully resolved 'cause' chain.
236+
*/
237+
export class SymbolizedError {
238+
readonly message: string;
239+
readonly stackTrace?: DevTools.StackTrace.StackTrace.StackTrace;
240+
readonly cause?: SymbolizedError;
241+
242+
private constructor(
243+
message: string,
244+
stackTrace?: DevTools.StackTrace.StackTrace.StackTrace,
245+
cause?: SymbolizedError,
246+
) {
247+
this.message = message;
248+
this.stackTrace = stackTrace;
249+
this.cause = cause;
250+
}
251+
252+
static async fromDetails(opts: {
253+
devTools?: TargetUniverse;
254+
details: Protocol.Runtime.ExceptionDetails;
255+
targetId: string;
256+
includeStackAndCause?: boolean;
257+
resolvedStackTraceForTesting?: DevTools.StackTrace.StackTrace.StackTrace;
258+
}): Promise<SymbolizedError> {
259+
const message = SymbolizedError.#getMessage(opts.details);
260+
if (!opts.includeStackAndCause || !opts.devTools) {
261+
return new SymbolizedError(message, opts.resolvedStackTraceForTesting);
262+
}
263+
264+
let stackTrace: DevTools.StackTrace.StackTrace.StackTrace | undefined;
265+
if (opts.resolvedStackTraceForTesting) {
266+
stackTrace = opts.resolvedStackTraceForTesting;
267+
} else if (opts.details.stackTrace) {
268+
try {
269+
stackTrace = await createStackTrace(
270+
opts.devTools,
271+
opts.details.stackTrace,
272+
opts.targetId,
273+
);
274+
} catch {
275+
// ignore
276+
}
277+
}
278+
279+
// TODO: Turn opts.details.exception into a JSHandle and retrieve the 'cause' property.
280+
// If its an Error, recursively create a SymbolizedError.
281+
return new SymbolizedError(message, stackTrace);
282+
}
283+
284+
static #getMessage(details: Protocol.Runtime.ExceptionDetails): string {
285+
// For Runtime.exceptionThrown with a present exception object, `details.text` will be "Uncaught" and
286+
// we have to manually parse out the error text from the exception description.
287+
// In the case of Runtime.getExceptionDetails, `details.text` has the Error.message.
288+
if (details.text === 'Uncaught') {
289+
const messageWithRest =
290+
details.exception?.description?.split('\n at ', 2) ?? [];
291+
return 'Uncaught ' + (messageWithRest[0] ?? '');
292+
}
293+
return details.text;
294+
}
295+
}
296+
230297
export async function createStackTraceForConsoleMessage(
231298
devTools: TargetUniverse,
232299
consoleMessage: ConsoleMessage,

src/PageCollector.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,11 @@ import {
2323
} from './third_party/index.js';
2424

2525
export class UncaughtError {
26-
readonly message: string;
27-
readonly stackTrace?: Protocol.Runtime.StackTrace;
26+
readonly details: Protocol.Runtime.ExceptionDetails;
2827
readonly targetId: string;
2928

30-
constructor(
31-
message: string,
32-
stackTrace: Protocol.Runtime.StackTrace | undefined,
33-
targetId: string,
34-
) {
35-
this.message = message;
36-
this.stackTrace = stackTrace;
29+
constructor(details: Protocol.Runtime.ExceptionDetails, targetId: string) {
30+
this.details = details;
3731
this.targetId = targetId;
3832
}
3933
}
@@ -328,13 +322,9 @@ class PageEventSubscriber {
328322
};
329323

330324
#onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent) => {
331-
const {exception, text, stackTrace} = event.exceptionDetails;
332-
const messageWithRest = exception?.description?.split('\n at ', 2) ?? [];
333-
const message = text + ' ' + (messageWithRest[0] ?? '');
334-
335325
this.#page.emit(
336326
'uncaughtError',
337-
new UncaughtError(message, stackTrace, this.#targetId),
327+
new UncaughtError(event.exceptionDetails, this.#targetId),
338328
);
339329
};
340330

src/formatters/ConsoleFormatter.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
import {
88
createStackTraceForConsoleMessage,
9-
createStackTrace,
109
type TargetUniverse,
10+
SymbolizedError,
1111
} from '../DevtoolsUtils.js';
12-
import type {UncaughtError} from '../PageCollector.js';
12+
import {UncaughtError} from '../PageCollector.js';
1313
import type * as DevTools from '../third_party/index.js';
1414
import type {ConsoleMessage} from '../third_party/index.js';
1515

@@ -21,13 +21,13 @@ export interface ConsoleFormatterOptions {
2121
}
2222

2323
export class ConsoleFormatter {
24-
#msg: ConsoleMessage | Error | UncaughtError;
24+
#msg: ConsoleMessage | Error | SymbolizedError;
2525
#resolvedArgs: unknown[] = [];
2626
#resolvedStackTrace?: DevTools.DevTools.StackTrace.StackTrace.StackTrace;
2727
#id?: number;
2828

2929
private constructor(
30-
msg: ConsoleMessage | Error | UncaughtError,
30+
msg: ConsoleMessage | Error | SymbolizedError,
3131
options?: ConsoleFormatterOptions,
3232
) {
3333
this.#msg = msg;
@@ -39,6 +39,19 @@ export class ConsoleFormatter {
3939
msg: ConsoleMessage | Error | UncaughtError,
4040
options?: ConsoleFormatterOptions,
4141
): Promise<ConsoleFormatter> {
42+
if (msg instanceof UncaughtError) {
43+
return new ConsoleFormatter(
44+
await SymbolizedError.fromDetails({
45+
devTools: options?.devTools,
46+
details: msg.details,
47+
targetId: msg.targetId,
48+
includeStackAndCause: options?.fetchDetailedData,
49+
resolvedStackTraceForTesting: options?.resolvedStackTraceForTesting,
50+
}),
51+
options,
52+
);
53+
}
54+
4255
const formatter = new ConsoleFormatter(msg, options);
4356
if (options?.fetchDetailedData) {
4457
await formatter.#loadDetailedData(options?.devTools);
@@ -47,7 +60,7 @@ export class ConsoleFormatter {
4760
}
4861

4962
#isConsoleMessage(
50-
msg: ConsoleMessage | Error | UncaughtError,
63+
msg: ConsoleMessage | Error | SymbolizedError,
5164
): msg is ConsoleMessage {
5265
// No `instanceof` as tests mock `ConsoleMessage`.
5366
return 'args' in msg && typeof msg.args === 'function';
@@ -77,12 +90,6 @@ export class ConsoleFormatter {
7790
devTools,
7891
this.#msg,
7992
);
80-
} else if (this.#msg.stackTrace) {
81-
this.#resolvedStackTrace = await createStackTrace(
82-
devTools,
83-
this.#msg.stackTrace,
84-
this.#msg.targetId,
85-
);
8693
}
8794
} catch {
8895
// ignore
@@ -105,7 +112,11 @@ export class ConsoleFormatter {
105112
this.#id !== undefined ? `ID: ${this.#id}` : '',
106113
`Message: ${this.#getType()}> ${this.#getText()}`,
107114
this.#formatArgs(),
108-
this.#formatStackTrace(this.#resolvedStackTrace),
115+
this.#formatStackTrace(
116+
this.#msg instanceof SymbolizedError
117+
? this.#msg.stackTrace
118+
: this.#resolvedStackTrace,
119+
),
109120
].filter(line => !!line);
110121
return result.join('\n');
111122
}

tests/PageCollector.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,9 @@ describe('ConsoleCollector', () => {
408408
onUncaughtErrorListener,
409409
sinon.match(e => {
410410
return (
411-
e.message === 'Uncaught SyntaxError: Expected {' &&
412-
e.stackTrace.callFrames.length === 0
411+
e.details.exception.description === 'SyntaxError: Expected {',
412+
e.details.text === 'Uncaught',
413+
e.details.stackTrace.callFrames.length === 0
413414
);
414415
}),
415416
);

tests/formatters/ConsoleFormatter.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,16 @@ describe('ConsoleFormatter', () => {
7070

7171
it('formats an UncaughtError', async t => {
7272
const error = new UncaughtError(
73-
'Uncaught TypeError: Cannot read properties of undefined',
74-
undefined,
73+
{
74+
exceptionId: 1,
75+
lineNumber: 0,
76+
columnNumber: 5,
77+
exception: {
78+
type: 'object',
79+
description: 'TypeError: Cannot read properties of undefined',
80+
},
81+
text: 'Uncaught',
82+
},
7583
'<mock target ID>',
7684
);
7785
const result = (
@@ -231,8 +239,16 @@ describe('ConsoleFormatter', () => {
231239
],
232240
} as unknown as DevTools.StackTrace.StackTrace.StackTrace;
233241
const error = new UncaughtError(
234-
'Uncaught TypeError: Cannot read properties of undefined',
235-
undefined,
242+
{
243+
exceptionId: 1,
244+
lineNumber: 0,
245+
columnNumber: 5,
246+
exception: {
247+
type: 'object',
248+
description: 'TypeError: Cannot read properties of undefined',
249+
},
250+
text: 'Uncaught',
251+
},
236252
'<mock target ID>',
237253
);
238254

0 commit comments

Comments
 (0)