mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
1fad330bad
commit
ffa00dab1b
5 files changed
+166
-16
No files matched your search
+57
-4
@@ -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,
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user