fix(console): include the reason of unhandled promise rejections (#3148)

Closes #2959

Unhandled promise rejections were listed as just "Uncaught (in
promise)". `SymbolizedError.#getMessage` only appended the exception
text when `details.text` was exactly "Uncaught". Rejections with a
primitive reason (`Promise.reject('reason-B')`) also had no
`description` to read from. Since every rejection had the same text,
`list_console_messages` grouped different rejections into one line with
"[3 times]".

Changes:
- Treat "Uncaught (in promise)" like "Uncaught": append the reason from
the exception.
- For primitive exceptions with no `description`, use
`unserializableValue` or `value`. `throw undefined` still prints
"Uncaught " as before, so the existing primitive test is unchanged.

Tests:
- Unit: an Error reason and a string reason produce "Uncaught (in
promise) Error: reason-A" and "Uncaught (in promise) reason-B".
- Real browser (console.test.ts): the three rejections from the issue
are listed as three separate messages with their reasons and no "[3
times]". On main this test shows "Uncaught (in promise) (0 args) [3
times]".
This commit is contained in:
nancy authored and GitHub committed 2026-10-16 08:45:19 +00:00
1 parent 8ddce8fc01
commit f7cdf732e3
3 files changed
+100 -7

No files matched your search

+18 -3
View File
@@ -337,9 +337,17 @@ export class SymbolizedError {
// For Runtime.exceptionThrown with a present exception object, `details.text` will be "Uncaught" and
// we have to manually parse out the error text from the exception description.
// In the case of Runtime.getExceptionDetails, `details.text` has the Error.message.
if (details.text === 'Uncaught' && details.exception) {
// Unhandled promise rejections work the same way, with
// "Uncaught (in promise)" as `details.text` and the rejection reason as
// the exception.
if (
(details.text === 'Uncaught' ||
details.text === 'Uncaught (in promise)') &&
details.exception
) {
return (
'Uncaught ' +
details.text +
' ' +
SymbolizedError.#getMessageFromException(details.exception)
);
}
@@ -347,7 +355,14 @@ export class SymbolizedError {
}
static #getMessageFromException(error: RemoteObjectLike): string {
const messageWithRest = error.description?.split('\n at ', 2) ?? [];
if (error.description === undefined) {
// Primitive values, e.g. `Promise.reject('reason')`, have no description.
if (error.unserializableValue !== undefined) {
return error.unserializableValue;
}
return error.value === undefined ? '' : String(error.value);
}
const messageWithRest = error.description.split('\n at ', 2);
return messageWithRest[0] ?? '';
}
+37 -4
View File
@@ -232,6 +232,39 @@ describe('SymbolizedError', () => {
assert.strictEqual(error.cause, undefined);
});
it('includes the reason of an unhandled promise rejection', async () => {
const error = await SymbolizedError.fromDetails({
details: exceptionDetails({
text: 'Uncaught (in promise)',
exception: {
type: 'object',
subtype: 'error',
className: 'Error',
description:
'Error: reason-A\n at http://example.com/app.js:4:16',
},
}),
targetId: '',
});
assert.strictEqual(
error.message,
'Uncaught (in promise) Error: reason-A',
);
});
it('includes a primitive rejection reason', async () => {
const error = await SymbolizedError.fromDetails({
details: exceptionDetails({
text: 'Uncaught (in promise)',
exception: {type: 'string', value: 'reason-B'},
}),
targetId: '',
});
assert.strictEqual(error.message, 'Uncaught (in promise) reason-B');
});
it('keeps the full description when it has no stack frames', async () => {
const error = await SymbolizedError.fromDetails({
details: exceptionDetails({
@@ -268,7 +301,7 @@ describe('SymbolizedError', () => {
);
});
it('uses an empty exception message when the exception only has a value', async () => {
it('uses the value of a thrown primitive as the exception message', async () => {
// A thrown primitive (e.g. `throw 'oops'`) has no description.
const error = await SymbolizedError.fromDetails({
details: exceptionDetails({
@@ -280,7 +313,7 @@ describe('SymbolizedError', () => {
targetId: '',
});
assert.strictEqual(error.message, 'Uncaught ');
assert.strictEqual(error.message, 'Uncaught oops');
});
it('uses the details text verbatim when there is no exception object', async () => {
@@ -402,7 +435,7 @@ describe('SymbolizedError', () => {
assert.strictEqual(error.message, 'TypeError: nope');
});
it('falls back to an empty message without a description', async () => {
it('uses the value of a primitive error without a description', async () => {
const error = await SymbolizedError.fromError({
error: {
type: 'string',
@@ -411,7 +444,7 @@ describe('SymbolizedError', () => {
targetId: '',
});
assert.strictEqual(error.message, '');
assert.strictEqual(error.message, 'oops');
});
});
});
+45
View File
@@ -255,6 +255,51 @@ describe('console', () => {
});
});
it('lists the reason of each unhandled promise rejection', async () => {
await withMcpContext(async (_response, context, args) => {
const page = context.getSelectedMcpPage();
await page.pptrPage.setContent(`<script>
Promise.reject(new Error('reason-A'));
Promise.reject('reason-B');
(async () => { throw new TypeError('reason-C'); })();
</script>`);
// Rejections are reported once the microtask queue drains, after
// setContent resolves, so poll until the collector has all three.
let textContent = '';
await waitExecutionFor(async () => {
const pollResponse = new McpResponse(args);
pollResponse.setPage(page);
await listConsoleMessages(args).handler(
{params: {}, page},
pollResponse,
context,
);
const formattedResponse = await pollResponse.handle(context);
textContent = getTextContent(formattedResponse.content[0]);
assert.ok(textContent.includes('msgid=3 '), textContent);
}, 5000);
assert.ok(
textContent.includes(
'msgid=1 [error] Uncaught (in promise) Error: reason-A (0 args)',
),
textContent,
);
assert.ok(
textContent.includes(
'msgid=2 [error] Uncaught (in promise) reason-B (0 args)',
),
textContent,
);
assert.ok(
textContent.includes(
'msgid=3 [error] Uncaught (in promise) TypeError: reason-C (0 args)',
),
textContent,
);
assert.ok(!textContent.includes('times]'), textContent);
});
});
describe('issues', () => {
it('lists issues', async () => {
await withMcpContext(async (response, context, args) => {