mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
fix: resolve error causes on the session that reported the exception (#3220)
Fixes #2950. ### Problem Uncaught exceptions are collected from the page's Puppeteer CDP session (`PageCollector` listens to `Runtime.exceptionThrown` on `page._client()`), but `SymbolizedError` resolved the exception's `cause` through the DevTools universe target's runtime agent, a different session. Remote object ids are scoped to the session that issued them, so `Runtime.getProperties` failed with `Could not find object with given id`, the error was swallowed, and the `Caused by` section was dropped. Errors thrown during load appeared to work because the ids happened to line up: both sessions had wrapped the same objects in the same order at that point. For errors thrown later the ids diverge, which is why the result depended on timing. ### Change - `UncaughtError` keeps the CDP session that reported it (page collector and service worker collector). - `SymbolizedError.fromDetails` / `fromError` take an optional `session` and, when present, use it for `Runtime.getProperties` (cause lookup) and `Runtime.getExceptionDetails` (cause details). Without a session the existing DevTools-universe path is unchanged. ### Testing - New test in `tests/tools/console.test.ts`: an error with a `cause` thrown 100 ms after `load`. It fails on `main` (no `Caused by`) and passes with this change. - Existing console and `ConsoleFormatter` tests pass. - Ran the fix check from the issue against the built server (`--headless --isolated`): with delays 0/50/300/1000 ms, `list_console_messages` with `includeStackTraces` and `get_console_message` both print `Caused by: Error: async-inner` for every delay (on `main` only the 0 ms case did). An error without a `cause` still prints no `Caused by`.
This commit is contained in:
1 parent
968e4e5c50
commit
b2d37e851a
6 files changed
+156
-9
No files matched your search
@@ -27,10 +27,16 @@ import {logger} from '../utils/logger.js';
|
||||
export class UncaughtError {
|
||||
readonly details: Protocol.Runtime.ExceptionDetails;
|
||||
readonly targetId: string;
|
||||
readonly session?: CDPSession;
|
||||
|
||||
constructor(details: Protocol.Runtime.ExceptionDetails, targetId: string) {
|
||||
constructor(
|
||||
details: Protocol.Runtime.ExceptionDetails,
|
||||
targetId: string,
|
||||
session?: CDPSession,
|
||||
) {
|
||||
this.details = details;
|
||||
this.targetId = targetId;
|
||||
this.session = session;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +302,7 @@ class PageEventSubscriber {
|
||||
#onExceptionThrown = (event: Protocol.Runtime.ExceptionThrownEvent): void => {
|
||||
this.#page.emit(
|
||||
'uncaughtError',
|
||||
new UncaughtError(event.exceptionDetails, this.#targetId),
|
||||
new UncaughtError(event.exceptionDetails, this.#targetId, this.#session),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,9 @@ export class ServiceWorkerSubscriber {
|
||||
const extensionId = extractExtensionId(url);
|
||||
|
||||
if (extensionId) {
|
||||
this.#callback(new UncaughtError(event.exceptionDetails, extensionId));
|
||||
this.#callback(
|
||||
new UncaughtError(event.exceptionDetails, extensionId, this.#session),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,12 +255,15 @@ export class SymbolizedError {
|
||||
devTools?: TargetUniverse;
|
||||
details: ExceptionDetailsLike;
|
||||
targetId: string;
|
||||
// The session that reported `details`. Remote object ids are scoped to
|
||||
// the session that issued them, so the cause has to be resolved there.
|
||||
session?: CDPSession;
|
||||
includeStackAndCause?: boolean;
|
||||
resolvedStackTraceForTesting?: DevTools.StackTrace.StackTrace.StackTrace;
|
||||
resolvedCauseForTesting?: SymbolizedError;
|
||||
}): Promise<SymbolizedError> {
|
||||
const message = SymbolizedError.#getMessage(opts.details);
|
||||
if (!opts.includeStackAndCause || !opts.devTools) {
|
||||
if (!opts.includeStackAndCause || (!opts.devTools && !opts.session)) {
|
||||
return new SymbolizedError(
|
||||
message,
|
||||
opts.resolvedStackTraceForTesting,
|
||||
@@ -271,7 +274,7 @@ export class SymbolizedError {
|
||||
let stackTrace: DevTools.StackTrace.StackTrace.StackTrace | undefined;
|
||||
if (opts.resolvedStackTraceForTesting) {
|
||||
stackTrace = opts.resolvedStackTraceForTesting;
|
||||
} else if (opts.details.stackTrace) {
|
||||
} else if (opts.devTools && opts.details.stackTrace) {
|
||||
try {
|
||||
stackTrace = await createStackTrace(
|
||||
opts.devTools,
|
||||
@@ -294,12 +297,14 @@ export class SymbolizedError {
|
||||
opts.devTools,
|
||||
opts.details.exception,
|
||||
opts.targetId,
|
||||
opts.session,
|
||||
);
|
||||
if (causeRemoteObj) {
|
||||
cause = await SymbolizedError.fromError({
|
||||
devTools: opts.devTools,
|
||||
error: causeRemoteObj,
|
||||
targetId: opts.targetId,
|
||||
session: opts.session,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -313,17 +318,20 @@ export class SymbolizedError {
|
||||
devTools?: TargetUniverse;
|
||||
error: RemoteObjectLike;
|
||||
targetId: string;
|
||||
session?: CDPSession;
|
||||
}): Promise<SymbolizedError> {
|
||||
const details = await SymbolizedError.#getExceptionDetails(
|
||||
opts.devTools,
|
||||
opts.error,
|
||||
opts.targetId,
|
||||
opts.session,
|
||||
);
|
||||
if (details) {
|
||||
return await SymbolizedError.fromDetails({
|
||||
details,
|
||||
devTools: opts.devTools,
|
||||
targetId: opts.targetId,
|
||||
session: opts.session,
|
||||
includeStackAndCause: true,
|
||||
});
|
||||
}
|
||||
@@ -370,8 +378,24 @@ export class SymbolizedError {
|
||||
devTools: TargetUniverse | undefined,
|
||||
error: RemoteObjectLike,
|
||||
targetId: string,
|
||||
): Promise<DevTools.Protocol.Runtime.ExceptionDetails | null> {
|
||||
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
|
||||
session?: CDPSession,
|
||||
): Promise<ExceptionDetailsLike | null> {
|
||||
if (error.type !== 'object' && error.subtype !== 'error') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
if (!error.objectId) {
|
||||
return null;
|
||||
}
|
||||
const {exceptionDetails} = await session.send(
|
||||
'Runtime.getExceptionDetails',
|
||||
{errorObjectId: error.objectId},
|
||||
);
|
||||
return exceptionDetails ?? null;
|
||||
}
|
||||
|
||||
if (!devTools) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -391,8 +415,23 @@ export class SymbolizedError {
|
||||
devTools: TargetUniverse | undefined,
|
||||
error: RemoteObjectLike,
|
||||
targetId: string,
|
||||
): Promise<DevTools.Protocol.Runtime.RemoteObject | null> {
|
||||
if (!devTools || (error.type !== 'object' && error.subtype !== 'error')) {
|
||||
session?: CDPSession,
|
||||
): Promise<RemoteObjectLike | null> {
|
||||
if (error.type !== 'object' && error.subtype !== 'error') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
if (!error.objectId) {
|
||||
return null;
|
||||
}
|
||||
const {result} = await session.send('Runtime.getProperties', {
|
||||
objectId: error.objectId,
|
||||
});
|
||||
return result.find(prop => prop.name === 'cause')?.value ?? null;
|
||||
}
|
||||
|
||||
if (!devTools) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ export class ConsoleFormatter {
|
||||
devTools: options?.devTools,
|
||||
details: msg.details,
|
||||
targetId: msg.targetId,
|
||||
session: msg.session,
|
||||
includeStackAndCause:
|
||||
options?.fetchDetailedData || options?.fetchStackTrace,
|
||||
resolvedStackTraceForTesting: options?.resolvedStackTraceForTesting,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import assert from 'node:assert';
|
||||
import {describe, it} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {SymbolizedError} from '../../src/devtools/DevtoolsUtils.js';
|
||||
import {ConsoleFormatter} from '../../src/formatters/ConsoleFormatter.js';
|
||||
import {UncaughtError} from '../../src/collectors/PageCollector.js';
|
||||
@@ -15,6 +17,7 @@ import type {
|
||||
Protocol,
|
||||
DevTools,
|
||||
} from '../../src/third_party/index.js';
|
||||
import {createMockCDPSession} from '../mocks.js';
|
||||
|
||||
interface MockConsoleMessage {
|
||||
type: () => string;
|
||||
@@ -850,6 +853,71 @@ describe('ConsoleFormatter', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the cause of an UncaughtError on its session', async t => {
|
||||
const session = createMockCDPSession();
|
||||
const send: sinon.SinonStub = session.send;
|
||||
send.onFirstCall().resolves({
|
||||
result: [
|
||||
{
|
||||
name: 'cause',
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: {
|
||||
type: 'object',
|
||||
subtype: 'error',
|
||||
objectId: 'cause-id',
|
||||
description: 'Error: root cause',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
send.onSecondCall().resolves({
|
||||
exceptionDetails: {
|
||||
exceptionId: 2,
|
||||
lineNumber: 0,
|
||||
columnNumber: 0,
|
||||
text: 'root cause',
|
||||
},
|
||||
});
|
||||
const error = new UncaughtError(
|
||||
{
|
||||
exceptionId: 1,
|
||||
lineNumber: 0,
|
||||
columnNumber: 5,
|
||||
exception: {
|
||||
type: 'object',
|
||||
subtype: 'error',
|
||||
objectId: 'error-id',
|
||||
description: 'Error: wrapper',
|
||||
},
|
||||
text: 'Uncaught',
|
||||
},
|
||||
'<mock target ID>',
|
||||
session,
|
||||
);
|
||||
|
||||
const formatter = await ConsoleFormatter.from(error, {
|
||||
id: 18,
|
||||
fetchDetailedData: true,
|
||||
resolvedStackTraceForTesting: {
|
||||
syncFragment: {
|
||||
frames: [{line: 1, column: 1, url: 'main.js', name: 'main'}],
|
||||
},
|
||||
asyncFragments: [],
|
||||
} as unknown as DevTools.StackTrace.StackTrace.StackTrace,
|
||||
});
|
||||
|
||||
sinon.assert.calledWithExactly(send.firstCall, 'Runtime.getProperties', {
|
||||
objectId: 'error-id',
|
||||
});
|
||||
sinon.assert.calledWithExactly(
|
||||
send.secondCall,
|
||||
'Runtime.getExceptionDetails',
|
||||
{errorObjectId: 'cause-id'},
|
||||
);
|
||||
t.assert.match(formatter.toStringDetailed(), /Caused by: root cause/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleFormatter arguments', () => {
|
||||
|
||||
@@ -719,6 +719,37 @@ console.error('inline-line-4');
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the cause of an uncaught exception thrown after load', async () => {
|
||||
server.addHtmlRoute(
|
||||
'/index.html',
|
||||
`<!DOCTYPE html><script>
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(() => {
|
||||
window.thrown = true;
|
||||
throw new Error('outer', {cause: new Error('inner')});
|
||||
}, 100);
|
||||
});
|
||||
</script>`,
|
||||
);
|
||||
|
||||
await withMcpContext(async (response, context, args) => {
|
||||
const page = context.getSelectedMcpPage();
|
||||
await page.pptrPage.goto(server.getRoute('/index.html'));
|
||||
await page.pptrPage.waitForFunction(() => 'thrown' in window);
|
||||
|
||||
await getConsoleMessage(args).handler(
|
||||
{params: {msgid: 1}, page: context.getSelectedMcpPage()},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
const formattedResponse = await response.handle(context);
|
||||
const rawText = getTextContent(formattedResponse.content[0]);
|
||||
|
||||
assert.ok(rawText.includes('Uncaught Error: outer'), rawText);
|
||||
assert.ok(rawText.includes('Caused by: Error: inner'), rawText);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies source maps to stack traces of Error object (with cause) console.log arguments', async t => {
|
||||
server.addRoute('/main.min.js', (_req, res) => {
|
||||
res.setHeader('Content-Type', 'text/javascript');
|
||||
|
||||
Reference in new issue
Block a user