Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/DevtoolsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -270,3 +272,58 @@ const SKIP_ALL_PAUSES = {
// Do nothing.
},
};

export async function createStackTraceForConsoleMessage(
devTools: TargetUniverse,
consoleMessage: ConsoleMessage,
): Promise<DevTools.StackTrace.StackTrace.StackTrace | undefined> {
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<Protocol.Runtime.ScriptId>();
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,
);
}
15 changes: 14 additions & 1 deletion src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +109,7 @@ export class McpContext implements Context {
#textSnapshot: TextSnapshot | null = null;
#networkCollector: NetworkCollector;
#consoleCollector: ConsoleCollector;
#devtoolsUniverseManager: UniverseManager;

#isRunningTrace = false;
#networkConditionsMap = new WeakMap<Page, string>();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 18 additions & 1 deletion src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -245,6 +253,7 @@ export class McpResponse implements Response {
: String(stringArg);
}),
),
stackTrace,
};
} else if (message instanceof DevTools.AggregatedIssue) {
const mappedIssueMessage = mapIssueToMessageObject(message);
Expand Down Expand Up @@ -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(),
Expand All @@ -307,6 +323,7 @@ export class McpResponse implements Response {
: String(stringArg);
}),
),
stackTrace,
};
}
if (item instanceof DevTools.AggregatedIssue) {
Expand Down
1 change: 1 addition & 0 deletions src/third_party/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ export {
IssuesManagerEvents,
createIssuesFromProtocolIssue,
IssueAggregator,
DebuggerWorkspaceBinding,
} from '../../node_modules/chrome-devtools-frontend/mcp/mcp.js';
13 changes: 13 additions & 0 deletions tests/tools/console.test.js.snapshot
Original file line number Diff line number Diff line change
@@ -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 <anonymous> (main.js:9:0)
`;

exports[`console > get_console_message > issues type > gets issue details with node id parsing 1`] = `
# test response
ID: 1
Expand Down
33 changes: 31 additions & 2 deletions tests/tools/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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',
`<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);
});
});
});
});
Loading