diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index 38a66bce4..018d41900 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -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 { + 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 { + 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, diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index 71968393e..c0f5ad8a2 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -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 ``; diff --git a/tests/formatters/ConsoleFormatter.test.ts b/tests/formatters/ConsoleFormatter.test.ts index efc1a3b72..1cd2ed2a9 100644 --- a/tests/formatters/ConsoleFormatter.test.ts +++ b/tests/formatters/ConsoleFormatter.test.ts @@ -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}>; + args: () => Array<{ + jsonValue: () => Promise; + 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 = ( diff --git a/tests/tools/console.test.js.snapshot b/tests/tools/console.test.js.snapshot index 6d4f14bfd..9574c9257 100644 --- a/tests/tools/console.test.js.snapshot +++ b/tests/tools/console.test.js.snapshot @@ -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 (main.js:10:1) +Note: line and column numbers use 1-based indexing +### Stack trace +at Iife (main.js:14:13) +at (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 diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index d80bc43a1..7d12c4363 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -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', + ``, + ); + + 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); + }); + }); }); });