feat: show message and stack trace in details when console.log'ging Error objects (#902)

This PR improves the `get_console_message` tool when logging `Error`
objects. We use the existing `getExceptionDetails` CDP command to
retrieve the structured stack trace, we'll then source map.

Example:

```js
try {
  compute();
} catch (e) {
  console.log('Compute failed', e);
}
```

Before:
```txt
### Arguments
Arg #0: Compute failed
Arg #1: {}
```

After:
```txt
### Arguments
Arg #0: Compute failed
Arg #1: ComputeError: Invariant violation
at compute (foo.ts:1:20)
at <anonymous> (main.ts:2:8)
Note: line and column numbers use 1-based indexing
```

---------

Co-authored-by: Alex Rudenko <OrKoN@users.noreply.github.com>
This commit is contained in:
Simon ZündandAlex Rudenko authored and GitHub committed 2026-02-06 09:38:27 +00:00
1 parent 1fad330bad
commit ffa00dab1b
5 files changed
+166 -16

No files matched your search

+57 -4
View File
@@ -281,18 +281,71 @@ export class SymbolizedError {
return new SymbolizedError(message, stackTrace);
}
static async fromError(opts: {
devTools?: TargetUniverse;
error: Protocol.Runtime.RemoteObject;
targetId: string;
}): Promise<SymbolizedError> {
const details = await SymbolizedError.#getExceptionDetails(
opts.devTools,
opts.error,
opts.targetId,
);
if (details) {
return SymbolizedError.fromDetails({
details,
devTools: opts.devTools,
targetId: opts.targetId,
includeStackAndCause: true,
});
}
return new SymbolizedError(
SymbolizedError.#getMessageFromException(opts.error),
);
}
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] ?? '');
if (details.text === 'Uncaught' && details.exception) {
return (
'Uncaught ' +
SymbolizedError.#getMessageFromException(details.exception)
);
}
return details.text;
}
static #getMessageFromException(
error: Protocol.Runtime.RemoteObject,
): string {
const messageWithRest = error.description?.split('\n at ', 2) ?? [];
return messageWithRest[0] ?? '';
}
static async #getExceptionDetails(
devTools: TargetUniverse | undefined,
error: Protocol.Runtime.RemoteObject,
targetId: string,
): Promise<Protocol.Runtime.ExceptionDetails | null> {
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
return null;
}
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
const target = targetId
? targetManager.targetById(targetId) || devTools.target
: devTools.target;
const model = target.model(DevTools.RuntimeModel) as DevTools.RuntimeModel;
return (
(await model.getExceptionDetails(
error.objectId as DevTools.Protocol.Runtime.RemoteObjectId,
)) ?? null
);
}
static createForTesting(
message: string,
stackTrace?: DevTools.StackTrace.StackTrace.StackTrace,
+12
View File
@@ -78,6 +78,18 @@ export class ConsoleFormatter {
resolvedArgs = await Promise.all(
msg.args().map(async (arg, i) => {
try {
const remoteObject = arg.remoteObject();
if (
remoteObject.type === 'object' &&
remoteObject.subtype === 'error'
) {
return await SymbolizedError.fromError({
devTools: options.devTools,
error: remoteObject,
// @ts-expect-error Internal ConsoleMessage API
targetId: msg._targetId(),
});
}
return await arg.jsonValue();
} catch {
return `<error: Argument ${i} is no longer available>`;
+50 -12
View File
@@ -10,13 +10,16 @@ import {describe, it} from 'node:test';
import {SymbolizedError} from '../../src/DevtoolsUtils.js';
import {ConsoleFormatter} from '../../src/formatters/ConsoleFormatter.js';
import {UncaughtError} from '../../src/PageCollector.js';
import type {ConsoleMessage} from '../../src/third_party/index.js';
import type {ConsoleMessage, Protocol} from '../../src/third_party/index.js';
import type {DevTools} from '../../src/third_party/index.js';
interface MockConsoleMessage {
type: () => string;
text: () => string;
args: () => Array<{jsonValue: () => Promise<unknown>}>;
args: () => Array<{
jsonValue: () => Promise<unknown>;
remoteObject: () => Protocol.Runtime.RemoteObject;
}>;
stackTrace?: DevTools.StackTrace.StackTrace.StackTrace;
}
@@ -46,7 +49,12 @@ describe('ConsoleFormatter', () => {
const message = createMockMessage({
type: () => 'log',
text: () => 'Processing file:',
args: () => [{jsonValue: async () => 'file.txt'}],
args: () => [
{
jsonValue: async () => 'file.txt',
remoteObject: () => ({type: 'string'}),
},
],
});
const result = (
await ConsoleFormatter.from(message, {id: 2, fetchDetailedData: true})
@@ -59,8 +67,14 @@ describe('ConsoleFormatter', () => {
type: () => 'log',
text: () => 'Processing file:',
args: () => [
{jsonValue: async () => 'file.txt'},
{jsonValue: async () => 'another file'},
{
jsonValue: async () => 'file.txt',
remoteObject: () => ({type: 'string'}),
},
{
jsonValue: async () => 'another file',
remoteObject: () => ({type: 'string'}),
},
],
});
const result = (
@@ -106,7 +120,12 @@ describe('ConsoleFormatter', () => {
const message = createMockMessage({
type: () => 'log',
text: () => 'Processing file:',
args: () => [{jsonValue: async () => 'file.txt'}],
args: () => [
{
jsonValue: async () => 'file.txt',
remoteObject: () => ({type: 'string'}),
},
],
});
const result = (
await ConsoleFormatter.from(message, {id: 2, fetchDetailedData: true})
@@ -119,8 +138,14 @@ describe('ConsoleFormatter', () => {
type: () => 'log',
text: () => 'Processing file:',
args: () => [
{jsonValue: async () => 'file.txt'},
{jsonValue: async () => 'another file'},
{
jsonValue: async () => 'file.txt',
remoteObject: () => ({type: 'string'}),
},
{
jsonValue: async () => 'another file',
remoteObject: () => ({type: 'string'}),
},
],
});
const result = (
@@ -195,6 +220,7 @@ describe('ConsoleFormatter', () => {
jsonValue: async () => {
throw new Error('Execution context is not available');
},
remoteObject: () => ({type: 'string'}),
},
],
});
@@ -320,8 +346,14 @@ describe('ConsoleFormatter', () => {
type: () => 'log',
text: () => 'Processing file:',
args: () => [
{jsonValue: async () => 'file.txt'},
{jsonValue: async () => 'another file'},
{
jsonValue: async () => 'file.txt',
remoteObject: () => ({type: 'string'}),
},
{
jsonValue: async () => 'another file',
remoteObject: () => ({type: 'string'}),
},
],
});
const result = (await ConsoleFormatter.from(message, {id: 1})).toJSON();
@@ -357,8 +389,14 @@ describe('ConsoleFormatter', () => {
type: () => 'log',
text: () => 'Processing file:',
args: () => [
{jsonValue: async () => 'file.txt'},
{jsonValue: async () => 'another file'},
{
jsonValue: async () => 'file.txt',
remoteObject: () => ({type: 'string'}),
},
{
jsonValue: async () => 'another file',
remoteObject: () => ({type: 'string'}),
},
],
});
const result = (
+18
View File
@@ -1,3 +1,21 @@
exports[`console > get_console_message > applies source maps to stack traces of Error object console.log arguments 1`] = `
# test response
ID: 1
Message: log> An error happened: JSHandle@error
### Arguments
Arg #0: An error happened:
Arg #1: Error: b00m!
at bar (main.js:3:9)
at foo (main.js:7:3)
at Iife (main.js:12:5)
at <anonymous> (main.js:10:1)
Note: line and column numbers use 1-based indexing
### Stack trace
at Iife (main.js:14:13)
at <anonymous> (main.js:10:1)
Note: line and column numbers use 1-based indexing
`;
exports[`console > get_console_message > applies source maps to stack traces of console messages 1`] = `
# test response
ID: 1
+29
View File
@@ -286,5 +286,34 @@ describe('console', () => {
t.assert.snapshot?.(rawText);
});
});
it('applies source maps to stack traces of Error object console.log arguments', async t => {
server.addRoute('/main.min.js', (_req, res) => {
res.setHeader('Content-Type', 'text/javascript');
res.statusCode = 200;
res.end(`function n(){throw new Error("b00m!")}function o(){n()}(function n(){try{o()}catch(n){console.log("An error happened:",n)}})();
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJFcnJvciIsImZvbyIsIklpZmUiLCJlIiwiY29uc29sZSIsImxvZyJdLCJzb3VyY2VzIjpbIi4vbWFpbi5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJcbmZ1bmN0aW9uIGJhcigpIHtcbiAgdGhyb3cgbmV3IEVycm9yKCdiMDBtIScpO1xufVxuXG5mdW5jdGlvbiBmb28oKSB7XG4gIGJhcigpO1xufVxuXG4oZnVuY3Rpb24gSWlmZSgpIHtcbiAgdHJ5IHtcbiAgICBmb28oKTtcbiAgfSBjYXRjaCAoZSkge1xuICAgIGNvbnNvbGUubG9nKCdBbiBlcnJvciBoYXBwZW5lZDonLCBlKTtcbiAgfVxufSkoKTtcblxuIl0sIm1hcHBpbmdzIjoiQUFDQSxTQUFTQSxJQUNQLE1BQU0sSUFBSUMsTUFBTSxRQUNsQixDQUVBLFNBQVNDLElBQ1BGLEdBQ0YsRUFFQSxTQUFVRyxJQUNSLElBQ0VELEdBQ0YsQ0FBRSxNQUFPRSxHQUNQQyxRQUFRQyxJQUFJLHFCQUFzQkYsRUFDcEMsQ0FDRCxFQU5EIiwiaWdub3JlTGlzdCI6W119
`);
});
server.addHtmlRoute(
'/index.html',
`<script src="${server.getRoute('/main.min.js')}"></script>`,
);
await withMcpContext(async (response, context) => {
const page = await context.newPage();
await page.goto(server.getRoute('/index.html'));
await getConsoleMessage.handler(
{params: {msgid: 1}},
response,
context,
);
const formattedResponse = await response.handle('test', context);
const rawText = getTextContent(formattedResponse.content[0]);
t.assert.snapshot?.(rawText);
});
});
});
});