豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit a3a0021

Browse files
authored
feat: include stack trace in 'get_console_message' tool (#740)
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. The function matches the target ID of the console message to the `SDK.Target` of DevTools. The only oddity is that DevTools works in a lazy manor: It creates stack traces upfront and as source maps come in, stack traces get updated. This does not work for the MCP server. Instead, we wait for script parsed events and source maps to attach before creating the stack trace. Since this could take potentially a while, we guard it with a time out.
1 parent c29d097 commit a3a0021

File tree

6 files changed

+167
-3
lines changed

6 files changed

+167
-3
lines changed

src/DevtoolsUtils.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {Mutex} from './Mutex.js';
99
import {DevTools} from './third_party/index.js';
1010
import type {
1111
Browser,
12+
ConsoleMessage,
1213
Page,
14+
Protocol,
1315
Target as PuppeteerTarget,
1416
} from './third_party/index.js';
1517

@@ -224,3 +226,94 @@ const SKIP_ALL_PAUSES = {
224226
// Do nothing.
225227
},
226228
};
229+
230+
export async function createStackTraceForConsoleMessage(
231+
devTools: TargetUniverse,
232+
consoleMessage: ConsoleMessage,
233+
): Promise<DevTools.StackTrace.StackTrace.StackTrace | undefined> {
234+
const message = consoleMessage as ConsoleMessage & {
235+
_rawStackTrace(): Protocol.Runtime.StackTrace | undefined;
236+
_targetId(): string | undefined;
237+
};
238+
const rawStackTrace = message._rawStackTrace();
239+
if (!rawStackTrace) {
240+
return undefined;
241+
}
242+
243+
const targetManager = devTools.universe.context.get(DevTools.TargetManager);
244+
const messageTargetId = message._targetId();
245+
const target = messageTargetId
246+
? targetManager.targetById(messageTargetId) || devTools.target
247+
: devTools.target;
248+
const model = target.model(DevTools.DebuggerModel) as DevTools.DebuggerModel;
249+
250+
// DevTools doesn't wait for source maps to attach before building a stack trace, rather it'll send
251+
// an update event once a source map was attached and the stack trace retranslated. This doesn't
252+
// work in the MCP case, so we'll collect all script IDs upfront and wait for any pending source map
253+
// loads before creating the stack trace. We might also have to wait for Debugger.ScriptParsed events if
254+
// the stack trace is created particularly early.
255+
const scriptIds = new Set<Protocol.Runtime.ScriptId>();
256+
for (const frame of rawStackTrace.callFrames) {
257+
scriptIds.add(frame.scriptId);
258+
}
259+
for (
260+
let asyncStack = rawStackTrace.parent;
261+
asyncStack;
262+
asyncStack = asyncStack.parent
263+
) {
264+
for (const frame of asyncStack.callFrames) {
265+
scriptIds.add(frame.scriptId);
266+
}
267+
}
268+
269+
const signal = AbortSignal.timeout(1_000);
270+
await Promise.all(
271+
[...scriptIds].map(id =>
272+
waitForScript(model, id, signal)
273+
.then(script =>
274+
model.sourceMapManager().sourceMapForClientPromise(script),
275+
)
276+
.catch(),
277+
),
278+
);
279+
280+
const binding = devTools.universe.context.get(
281+
DevTools.DebuggerWorkspaceBinding,
282+
);
283+
// DevTools uses branded types for ScriptId and others. Casting the puppeteer protocol type to the DevTools protocol type is safe.
284+
return binding.createStackTraceFromProtocolRuntime(
285+
rawStackTrace as Parameters<
286+
DevTools.DebuggerWorkspaceBinding['createStackTraceFromProtocolRuntime']
287+
>[0],
288+
target,
289+
);
290+
}
291+
292+
// Waits indefinitely for the script so pair it with Promise.race.
293+
async function waitForScript(
294+
model: DevTools.DebuggerModel,
295+
scriptId: Protocol.Runtime.ScriptId,
296+
signal: AbortSignal,
297+
) {
298+
while (true) {
299+
if (signal.aborted) {
300+
throw signal.reason;
301+
}
302+
303+
const script = model.scriptForId(scriptId);
304+
if (script) {
305+
return script;
306+
}
307+
308+
await new Promise((resolve, reject) => {
309+
signal.addEventListener('abort', () => reject(signal.reason), {
310+
once: true,
311+
});
312+
void model
313+
.once(
314+
'ParsedScriptSource' as Parameters<DevTools.DebuggerModel['once']>[0],
315+
)
316+
.then(resolve);
317+
});
318+
}
319+
}

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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {createStackTraceForConsoleMessage} from './DevtoolsUtils.js';
78
import type {ConsoleMessageData} from './formatters/consoleFormatter.js';
89
import {
910
formatConsoleEventShort,
@@ -242,6 +243,11 @@ export class McpResponse implements Response {
242243
const consoleMessageStableId = this.#attachedConsoleMessageId;
243244
if ('args' in message) {
244245
const consoleMessage = message as ConsoleMessage;
246+
const devTools = context.getDevToolsUniverse();
247+
const stackTrace = devTools
248+
? await createStackTraceForConsoleMessage(devTools, consoleMessage)
249+
: undefined;
250+
245251
consoleData = {
246252
consoleMessageStableId,
247253
type: consoleMessage.type(),
@@ -256,6 +262,7 @@ export class McpResponse implements Response {
256262
: String(stringArg);
257263
}),
258264
),
265+
stackTrace,
259266
};
260267
} else if (message instanceof DevTools.AggregatedIssue) {
261268
const formatter = new IssueFormatter(message, {
@@ -308,6 +315,13 @@ export class McpResponse implements Response {
308315
context.getConsoleMessageStableId(item);
309316
if ('args' in item) {
310317
const consoleMessage = item as ConsoleMessage;
318+
const devTools = context.getDevToolsUniverse();
319+
const stackTrace = devTools
320+
? await createStackTraceForConsoleMessage(
321+
devTools,
322+
consoleMessage,
323+
)
324+
: undefined;
311325
return {
312326
consoleMessageStableId,
313327
type: consoleMessage.type(),
@@ -322,6 +336,7 @@ export class McpResponse implements Response {
322336
: String(stringArg);
323337
}),
324338
),
339+
stackTrace,
325340
};
326341
}
327342
if (item instanceof DevTools.AggregatedIssue) {

src/third_party/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import 'core-js/modules/es.promise.with-resolvers.js';
8+
import 'core-js/modules/es.set.union.v2.js';
89
import 'core-js/proposals/iterator-helpers.js';
910

1011
export type {Options as YargsOptions} from 'yargs';

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)