diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index 684327a01..443f35e2a 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -11,7 +11,9 @@ import {Mutex} from './Mutex.js'; import {DevTools} from './third_party/index.js'; import type { Browser, + ConsoleMessage, Page, + Protocol, Target as PuppeteerTarget, } from './third_party/index.js'; @@ -270,3 +272,58 @@ const SKIP_ALL_PAUSES = { // Do nothing. }, }; + +export async function createStackTraceForConsoleMessage( + devTools: TargetUniverse, + consoleMessage: ConsoleMessage, +): Promise { + const message = consoleMessage as ConsoleMessage & { + _rawStackTrace(): Protocol.Runtime.StackTrace | undefined; + _targetId(): string | undefined; + }; + const rawStackTrace = message._rawStackTrace(); + if (!rawStackTrace) { + return undefined; + } + + const targetManager = devTools.universe.context.get(DevTools.TargetManager); + const messageTargetId = message._targetId(); + const target = messageTargetId + ? targetManager.targetById(messageTargetId) || devTools.target + : devTools.target; + const model = target.model(DevTools.DebuggerModel) as DevTools.DebuggerModel; + + // DevTools doesn't wait for source maps to attach before building a stack trace, rather it'll send + // an update event once a source map was attached and the stack trace retranslated. This doesn't + // work in the MCP case, so we'll collect all script IDs upfront and wait for any pending source map + // loads before creating the stack trace. + const scriptIds = new Set(); + rawStackTrace.callFrames.forEach(frame => scriptIds.add(frame.scriptId)); + for ( + let asyncStack = rawStackTrace.parent; + asyncStack; + asyncStack = asyncStack.parent + ) { + asyncStack.callFrames.forEach(frame => scriptIds.add(frame.scriptId)); + } + await Promise.all( + [...scriptIds].map(id => { + const script = model.scriptForId(id); + if (!script) { + return Promise.resolve(); + } + return model.sourceMapManager().sourceMapForClientPromise(script); + }), + ); + + const binding = devTools.universe.context.get( + DevTools.DebuggerWorkspaceBinding, + ); + // DevTools uses branded types for ScriptId and others. Casting the puppeteer protocol type to the DevTools protocol type is safe. + return binding.createStackTraceFromProtocolRuntime( + rawStackTrace as Parameters< + DevTools.DebuggerWorkspaceBinding['createStackTraceFromProtocolRuntime'] + >[0], + target, + ); +} diff --git a/src/McpContext.ts b/src/McpContext.ts index 05bf6f16f..c84e9eab3 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -8,7 +8,12 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js'; +import type {TargetUniverse} from './DevtoolsUtils.js'; +import { + extractUrlLikeFromDevToolsTitle, + UniverseManager, + urlsEqual, +} from './DevtoolsUtils.js'; import type {ListenerMap} from './PageCollector.js'; import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; import {Locator} from './third_party/index.js'; @@ -104,6 +109,7 @@ export class McpContext implements Context { #textSnapshot: TextSnapshot | null = null; #networkCollector: NetworkCollector; #consoleCollector: ConsoleCollector; + #devtoolsUniverseManager: UniverseManager; #isRunningTrace = false; #networkConditionsMap = new WeakMap(); @@ -152,17 +158,20 @@ export class McpContext implements Context { }, } as ListenerMap; }); + this.#devtoolsUniverseManager = new UniverseManager(this.browser); } async #init() { const pages = await this.createPagesSnapshot(); await this.#networkCollector.init(pages); await this.#consoleCollector.init(pages); + await this.#devtoolsUniverseManager.init(pages); } dispose() { this.#networkCollector.dispose(); this.#consoleCollector.dispose(); + this.#devtoolsUniverseManager.dispose(); } static async from( @@ -229,6 +238,10 @@ export class McpContext implements Context { return this.#consoleCollector.getData(page, includePreservedMessages); } + getDevToolsUniverse(): TargetUniverse | null { + return this.#devtoolsUniverseManager.get(this.getSelectedPage()); + } + getConsoleMessageStableId( message: ConsoleMessage | Error | DevTools.AggregatedIssue, ): number { diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 17a8c098e..2bf7bef80 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {mapIssueToMessageObject} from './DevtoolsUtils.js'; +import { + createStackTraceForConsoleMessage, + mapIssueToMessageObject, +} from './DevtoolsUtils.js'; import type {ConsoleMessageData} from './formatters/consoleFormatter.js'; import { formatConsoleEventShort, @@ -231,6 +234,11 @@ export class McpResponse implements Response { const consoleMessageStableId = this.#attachedConsoleMessageId; if ('args' in message) { const consoleMessage = message as ConsoleMessage; + const devTools = context.getDevToolsUniverse(); + const stackTrace = devTools + ? await createStackTraceForConsoleMessage(devTools, consoleMessage) + : undefined; + consoleData = { consoleMessageStableId, type: consoleMessage.type(), @@ -245,6 +253,7 @@ export class McpResponse implements Response { : String(stringArg); }), ), + stackTrace, }; } else if (message instanceof DevTools.AggregatedIssue) { const mappedIssueMessage = mapIssueToMessageObject(message); @@ -293,6 +302,13 @@ export class McpResponse implements Response { context.getConsoleMessageStableId(item); if ('args' in item) { const consoleMessage = item as ConsoleMessage; + const devTools = context.getDevToolsUniverse(); + const stackTrace = devTools + ? await createStackTraceForConsoleMessage( + devTools, + consoleMessage, + ) + : undefined; return { consoleMessageStableId, type: consoleMessage.type(), @@ -307,6 +323,7 @@ export class McpResponse implements Response { : String(stringArg); }), ), + stackTrace, }; } if (item instanceof DevTools.AggregatedIssue) { diff --git a/src/third_party/devtools.ts b/src/third_party/devtools.ts index 3bcda21f3..6c01c9568 100644 --- a/src/third_party/devtools.ts +++ b/src/third_party/devtools.ts @@ -30,4 +30,5 @@ export { IssuesManagerEvents, createIssuesFromProtocolIssue, IssueAggregator, + DebuggerWorkspaceBinding, } from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js'; diff --git a/tests/tools/console.test.js.snapshot b/tests/tools/console.test.js.snapshot index e58e91212..aafe4e868 100644 --- a/tests/tools/console.test.js.snapshot +++ b/tests/tools/console.test.js.snapshot @@ -1,3 +1,16 @@ +exports[`console > get_console_message > applies source maps to stack traces of console messages 1`] = ` +# test response +ID: 1 +Message: warn> hello world +### Arguments +Arg #0: hello world +### Stack trace +at bar (main.js:2:10) +at foo (main.js:6:2) +at Iife (main.js:10:2) +at (main.js:9:0) +`; + exports[`console > get_console_message > issues type > gets issue details with node id parsing 1`] = ` # test response ID: 1 diff --git a/tests/tools/console.test.ts b/tests/tools/console.test.ts index 190281c55..b207ca853 100644 --- a/tests/tools/console.test.ts +++ b/tests/tools/console.test.ts @@ -120,6 +120,8 @@ describe('console', () => { }); describe('get_console_message', () => { + const server = serverHooks(); + it('gets a specific console message', async () => { await withMcpContext(async (response, context) => { const page = await context.newPage(); @@ -143,8 +145,6 @@ describe('console', () => { }); describe('issues type', () => { - const server = serverHooks(); - it('gets issue details with node id parsing', async t => { await withMcpContext(async (response, context) => { const page = await context.newPage(); @@ -228,5 +228,34 @@ describe('console', () => { }); }); }); + + it('applies source maps to stack traces of console messages', async t => { + server.addRoute('/main.min.js', (_req, res) => { + res.setHeader('Content-Type', 'text/javascript'); + res.statusCode = 200; + res.end(`function n(){console.warn("hello world")}function o(){n()}(function n(){o()})(); + //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJjb25zb2xlIiwid2FybiIsImZvbyIsIklpZmUiXSwic291cmNlcyI6WyIuL21haW4uanMiXSwic291cmNlc0NvbnRlbnQiOlsiXG5mdW5jdGlvbiBiYXIoKSB7XG4gIGNvbnNvbGUud2FybignaGVsbG8gd29ybGQnKTtcbn1cblxuZnVuY3Rpb24gZm9vKCkge1xuICBiYXIoKTtcbn1cblxuKGZ1bmN0aW9uIElpZmUoKSB7XG4gIGZvbygpO1xufSkoKTtcblxuIl0sIm1hcHBpbmdzIjoiQUFDQSxTQUFTQSxJQUNQQyxRQUFRQyxLQUFLLGNBQ2YsQ0FFQSxTQUFTQyxJQUNQSCxHQUNGLEVBRUEsU0FBVUksSUFDUkQsR0FDRCxFQUZEIiwiaWdub3JlTGlzdCI6W119 + `); + }); + 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); + }); + }); }); });