Skip to content

Commit 31bb922

Browse files
committed
feat: include stack trace in 'get_console_message' tool
This is the stack trace of the console message itself. If the argument is an Error object or an "Error.stack" like string we don't do anything special (yet). The stack trace is source mapped if source maps are available. For now, this only works for console messages of the main page target as puppeteer doesn't tell us yet from which target a console message is coming from.
1 parent 7ba63a8 commit 31bb922

File tree

6 files changed

+134
-4
lines changed

6 files changed

+134
-4
lines changed

src/DevtoolsUtils.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {Mutex} from './Mutex.js';
1111
import {DevTools} from './third_party/index.js';
1212
import type {
1313
Browser,
14+
ConsoleMessage,
1415
Page,
16+
Protocol,
1517
Target as PuppeteerTarget,
1618
} from './third_party/index.js';
1719

@@ -270,3 +272,58 @@ const SKIP_ALL_PAUSES = {
270272
// Do nothing.
271273
},
272274
};
275+
276+
export async function createStackTraceForConsoleMessage(
277+
devTools: TargetUniverse,
278+
consoleMessage: ConsoleMessage,
279+
): Promise<DevTools.StackTrace.StackTrace.StackTrace | undefined> {
280+
const message = consoleMessage as ConsoleMessage & {
281+
_rawStackTrace(): Protocol.Runtime.StackTrace | undefined;
282+
_targetId(): string | undefined;
283+
};
284+
const rawStackTrace = message._rawStackTrace();
285+
if (!rawStackTrace) {
286+
return undefined;
287+
}
288+
289+
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
290+
const messageTargetId = message._targetId();
291+
const target = messageTargetId
292+
? targetManager.targetById(messageTargetId) || devTools.target
293+
: devTools.target;
294+
const model = target.model(DevTools.DebuggerModel) as DevTools.DebuggerModel;
295+
296+
// DevTools doesn't wait for source maps to attach before building a stack trace, rather it'll send
297+
// an update event once a source map was attached and the stack trace retranslated. This doesn't
298+
// work in the MCP case, so we'll collect all script IDs upfront and wait for any pending source map
299+
// loads before creating the stack trace.
300+
const scriptIds = new Set<Protocol.Runtime.ScriptId>();
301+
rawStackTrace.callFrames.forEach(frame => scriptIds.add(frame.scriptId));
302+
for (
303+
let asyncStack = rawStackTrace.parent;
304+
asyncStack;
305+
asyncStack = asyncStack.parent
306+
) {
307+
asyncStack.callFrames.forEach(frame => scriptIds.add(frame.scriptId));
308+
}
309+
await Promise.all(
310+
[...scriptIds].map(id => {
311+
const script = model.scriptForId(id);
312+
if (!script) {
313+
return Promise.resolve();
314+
}
315+
return model.sourceMapManager().sourceMapForClientPromise(script);
316+
}),
317+
);
318+
319+
const binding = devTools.universe.context.get(
320+
DevTools.DebuggerWorkspaceBinding,
321+
);
322+
// DevTools uses branded types for ScriptId and others. Casting the puppeteer protocol type to the DevTools protocol type is safe.
323+
return binding.createStackTraceFromProtocolRuntime(
324+
rawStackTrace as Parameters<
325+
DevTools.DebuggerWorkspaceBinding['createStackTraceFromProtocolRuntime']
326+
>[0],
327+
target,
328+
);
329+
}

src/McpContext.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import fs from 'node:fs/promises';
88
import os from 'node:os';
99
import path from 'node:path';
1010

11-
import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js';
11+
import type {TargetUniverse} from './DevtoolsUtils.js';
12+
import {
13+
extractUrlLikeFromDevToolsTitle,
14+
UniverseManager,
15+
urlsEqual,
16+
} from './DevtoolsUtils.js';
1217
import type {ListenerMap} from './PageCollector.js';
1318
import {NetworkCollector, ConsoleCollector} from './PageCollector.js';
1419
import {Locator} from './third_party/index.js';
@@ -104,6 +109,7 @@ export class McpContext implements Context {
104109
#textSnapshot: TextSnapshot | null = null;
105110
#networkCollector: NetworkCollector;
106111
#consoleCollector: ConsoleCollector;
112+
#devtoolsUniverseManager: UniverseManager;
107113

108114
#isRunningTrace = false;
109115
#networkConditionsMap = new WeakMap<Page, string>();
@@ -152,17 +158,20 @@ export class McpContext implements Context {
152158
},
153159
} as ListenerMap;
154160
});
161+
this.#devtoolsUniverseManager = new UniverseManager(this.browser);
155162
}
156163

157164
async #init() {
158165
const pages = await this.createPagesSnapshot();
159166
await this.#networkCollector.init(pages);
160167
await this.#consoleCollector.init(pages);
168+
await this.#devtoolsUniverseManager.init(pages);
161169
}
162170

163171
dispose() {
164172
this.#networkCollector.dispose();
165173
this.#consoleCollector.dispose();
174+
this.#devtoolsUniverseManager.dispose();
166175
}
167176

168177
static async from(
@@ -229,6 +238,10 @@ export class McpContext implements Context {
229238
return this.#consoleCollector.getData(page, includePreservedMessages);
230239
}
231240

241+
getDevToolsUniverse(): TargetUniverse | null {
242+
return this.#devtoolsUniverseManager.get(this.getSelectedPage());
243+
}
244+
232245
getConsoleMessageStableId(
233246
message: ConsoleMessage | Error | DevTools.AggregatedIssue,
234247
): number {

src/McpResponse.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7-
import {mapIssueToMessageObject} from './DevtoolsUtils.js';
7+
import {
8+
createStackTraceForConsoleMessage,
9+
mapIssueToMessageObject,
10+
} from './DevtoolsUtils.js';
811
import type {ConsoleMessageData} from './formatters/consoleFormatter.js';
912
import {
1013
formatConsoleEventShort,
@@ -231,6 +234,11 @@ export class McpResponse implements Response {
231234
const consoleMessageStableId = this.#attachedConsoleMessageId;
232235
if ('args' in message) {
233236
const consoleMessage = message as ConsoleMessage;
237+
const devTools = context.getDevToolsUniverse();
238+
const stackTrace = devTools
239+
? await createStackTraceForConsoleMessage(devTools, consoleMessage)
240+
: undefined;
241+
234242
consoleData = {
235243
consoleMessageStableId,
236244
type: consoleMessage.type(),
@@ -245,6 +253,7 @@ export class McpResponse implements Response {
245253
: String(stringArg);
246254
}),
247255
),
256+
stackTrace,
248257
};
249258
} else if (message instanceof DevTools.AggregatedIssue) {
250259
const mappedIssueMessage = mapIssueToMessageObject(message);
@@ -293,6 +302,13 @@ export class McpResponse implements Response {
293302
context.getConsoleMessageStableId(item);
294303
if ('args' in item) {
295304
const consoleMessage = item as ConsoleMessage;
305+
const devTools = context.getDevToolsUniverse();
306+
const stackTrace = devTools
307+
? await createStackTraceForConsoleMessage(
308+
devTools,
309+
consoleMessage,
310+
)
311+
: undefined;
296312
return {
297313
consoleMessageStableId,
298314
type: consoleMessage.type(),
@@ -307,6 +323,7 @@ export class McpResponse implements Response {
307323
: String(stringArg);
308324
}),
309325
),
326+
stackTrace,
310327
};
311328
}
312329
if (item instanceof DevTools.AggregatedIssue) {

src/third_party/devtools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ export {
3030
IssuesManagerEvents,
3131
createIssuesFromProtocolIssue,
3232
IssueAggregator,
33+
DebuggerWorkspaceBinding,
3334
} from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js';

tests/tools/console.test.js.snapshot

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
exports[`console > get_console_message > applies source maps to stack traces of console messages 1`] = `
2+
# test response
3+
ID: 1
4+
Message: warn> hello world
5+
### Arguments
6+
Arg #0: hello world
7+
### Stack trace
8+
at bar (main.js:2:10)
9+
at foo (main.js:6:2)
10+
at Iife (main.js:10:2)
11+
at <anonymous> (main.js:9:0)
12+
`;
13+
114
exports[`console > get_console_message > issues type > gets issue details with node id parsing 1`] = `
215
# test response
316
ID: 1

tests/tools/console.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ describe('console', () => {
120120
});
121121

122122
describe('get_console_message', () => {
123+
const server = serverHooks();
124+
123125
it('gets a specific console message', async () => {
124126
await withMcpContext(async (response, context) => {
125127
const page = await context.newPage();
@@ -143,8 +145,6 @@ describe('console', () => {
143145
});
144146

145147
describe('issues type', () => {
146-
const server = serverHooks();
147-
148148
it('gets issue details with node id parsing', async t => {
149149
await withMcpContext(async (response, context) => {
150150
const page = await context.newPage();
@@ -228,5 +228,34 @@ describe('console', () => {
228228
});
229229
});
230230
});
231+
232+
it('applies source maps to stack traces of console messages', async t => {
233+
server.addRoute('/main.min.js', (_req, res) => {
234+
res.setHeader('Content-Type', 'text/javascript');
235+
res.statusCode = 200;
236+
res.end(`function n(){console.warn("hello world")}function o(){n()}(function n(){o()})();
237+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJiYXIiLCJjb25zb2xlIiwid2FybiIsImZvbyIsIklpZmUiXSwic291cmNlcyI6WyIuL21haW4uanMiXSwic291cmNlc0NvbnRlbnQiOlsiXG5mdW5jdGlvbiBiYXIoKSB7XG4gIGNvbnNvbGUud2FybignaGVsbG8gd29ybGQnKTtcbn1cblxuZnVuY3Rpb24gZm9vKCkge1xuICBiYXIoKTtcbn1cblxuKGZ1bmN0aW9uIElpZmUoKSB7XG4gIGZvbygpO1xufSkoKTtcblxuIl0sIm1hcHBpbmdzIjoiQUFDQSxTQUFTQSxJQUNQQyxRQUFRQyxLQUFLLGNBQ2YsQ0FFQSxTQUFTQyxJQUNQSCxHQUNGLEVBRUEsU0FBVUksSUFDUkQsR0FDRCxFQUZEIiwiaWdub3JlTGlzdCI6W119
238+
`);
239+
});
240+
server.addHtmlRoute(
241+
'/index.html',
242+
`<script src="${server.getRoute('/main.min.js')}"></script>`,
243+
);
244+
245+
await withMcpContext(async (response, context) => {
246+
const page = await context.newPage();
247+
await page.goto(server.getRoute('/index.html'));
248+
249+
await getConsoleMessage.handler(
250+
{params: {msgid: 1}},
251+
response,
252+
context,
253+
);
254+
const formattedResponse = await response.handle('test', context);
255+
const rawText = getTextContent(formattedResponse.content[0]);
256+
257+
t.assert.snapshot?.(rawText);
258+
});
259+
});
231260
});
232261
});

0 commit comments

Comments
 (0)