Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/DevtoolsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SymbolizedError> {
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,
Expand Down
18 changes: 4 additions & 14 deletions src/PageCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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),
);
};

Expand Down
35 changes: 23 additions & 12 deletions src/formatters/ConsoleFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -39,6 +39,19 @@ export class ConsoleFormatter {
msg: ConsoleMessage | Error | UncaughtError,
options?: ConsoleFormatterOptions,
): Promise<ConsoleFormatter> {
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);
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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');
}
Expand Down
5 changes: 3 additions & 2 deletions tests/PageCollector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}),
);
Expand Down
24 changes: 20 additions & 4 deletions tests/formatters/ConsoleFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
'<mock target ID>',
);
const result = (
Expand Down Expand Up @@ -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',
},
'<mock target ID>',
);

Expand Down