fix(console): describe Map, Set, function, DOM node, symbol and cyclic arguments (#3199)

`list_console_messages` / `get_console_message` print console arguments
through `jsonValue()`. On main a Map, Set or DOM node prints as `{}`,
and a cyclic object shows `<error: Argument N is no longer available>`.
This formats those arguments from the description and preview that CDP
already sends with them, so nothing extra is evaluated in the page.
Plain objects, arrays and primitives still use `jsonValue()`.

**User-visible behavior change (Date).** Dates now print the CDP
description instead of the `jsonValue()` ISO string. Main prints
`1970-01-01T00:00:00.000Z`, this PR prints `Thu Jan 01 1970 00:00:00
GMT+0000 (Coordinated Universal Time)`, the same text the DevTools
console shows. Main's ISO output was already valid; this is a change in
format, not a fix.

**Port origin.** `src/formatters/RemoteObjectFormatter.ts` is a
plain-text port of devtools-frontend's
`front_end/ui/legacy/components/object_ui/RemoteObjectPreviewFormatter.ts`
at revision `f7e7785de52d6f5955d6a9c5956c2fe2d520d091` (the
`third_party/devtools-frontend` revision this branch uses). Intentional
differences:
- Plain text instead of Lit templates: no CSS classes, tooltips or
localized strings.
- No nodeDescription markup: DOM nodes print their description as is (no
`renderNodeTitle` spans); trusted types print their description instead
of `ClassName "value"`.
- `date` and `node` print their description instead of a property
preview.
- The `includeNullOrUndefined` option is dropped.
- Top-level strings print raw; unserializable primitives (NaN, -0, 1n)
print from `unserializableValue`.
- Upstream's inline 400-character limit is named
`MAX_DESCRIPTION_LENGTH` (same value).

**Quoting.** Values formatted from a preview show nested strings as JS
literals (`'x'`), while plain objects still go through `jsonValue()` and
print as JSON (`{"a":1}`), so one message can mix both quoting styles.

**Tests (no browser):**
- `tests/formatters/RemoteObjectFormatter.test.ts`: Map/Set entries,
cyclic objects, arrays with holes and overflow, typed arrays, promise
state, and description-only values (Date, DOM node, function, symbol,
bigint).
- `tests/formatters/ConsoleFormatter.test.ts`: Map and cyclic arguments
are formatted from their preview while a plain object keeps
`jsonValue()`; an argument whose `jsonValue()` rejects and has no
preview is reported as `<error: Argument 0 is no longer available>`.
This commit is contained in:
nancy authored and GitHub committed 2026-10-19 11:53:54 +00:00
1 parent ae3f61c5b4
commit 968e4e5c50
4 files changed
+583 -2

No files matched your search

+32 -2
View File
@@ -11,9 +11,10 @@ import {
} from '../devtools/DevtoolsUtils.js';
import {UncaughtError} from '../collectors/PageCollector.js';
import * as DevTools from '../third_party/index.js';
import type {ConsoleMessage} from '../third_party/index.js';
import type {ConsoleMessage, Protocol} from '../third_party/index.js';
import type {IssueFormatter} from './IssueFormatter.js';
import {formatRemoteObject} from './RemoteObjectFormatter.js';
export interface ConsoleFormatterOptions {
fetchDetailedData?: boolean;
@@ -48,6 +49,23 @@ interface ConsoleMessageDetailed extends ConsoleMessageConcise {
stackTrace?: string;
}
// JSON.stringify prints Map, Set, functions and DOM nodes as `{}`, and throws
// on symbols and cyclic objects. Those are formatted from their preview.
function isJsonLike(remoteObject: Protocol.Runtime.RemoteObject): boolean {
if (remoteObject.type === 'object') {
return (
remoteObject.subtype === undefined ||
remoteObject.subtype === 'array' ||
remoteObject.subtype === 'null'
);
}
return (
remoteObject.type !== 'function' &&
remoteObject.type !== 'symbol' &&
remoteObject.type !== 'bigint'
);
}
export class ConsoleFormatter {
readonly #id: number;
readonly #type: string;
@@ -152,7 +170,19 @@ export class ConsoleFormatter {
targetId: msg._targetId(),
});
}
return await arg.jsonValue();
if (!isJsonLike(remoteObject)) {
return formatRemoteObject(remoteObject);
}
try {
return await arg.jsonValue();
} catch (error) {
// JSON can't represent the value (for example, a cyclic object)
// or the page is gone; the preview still describes it.
if (remoteObject.preview) {
return formatRemoteObject(remoteObject);
}
throw error;
}
} catch {
return `<error: Argument ${i} is no longer available>`;
}
+277
View File
@@ -0,0 +1,277 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Plain-text port of devtools-frontend's RemoteObjectPreviewFormatter
* (front_end/ui/legacy/components/object_ui/RemoteObjectPreviewFormatter.ts)
* at revision f7e7785de52d6f5955d6a9c5956c2fe2d520d091, the
* third_party/devtools-frontend revision this port was written against. Compare
* with that file when syncing. It formats the preview that CDP already attaches
* to console arguments, so nothing is evaluated in the page.
*
* Intentional differences from upstream:
* - Returns plain text instead of Lit templates, so there are no CSS classes,
* tooltips or localized strings ('empty' and 'empty × N' are English).
* - No nodeDescription markup: DOM nodes print their description as is instead
* of splitting it into tag, id and class spans (renderNodeTitle), and trusted
* types print their description instead of `ClassName "value"`.
* - 'date' and 'node' are added to the subtypes that print their description
* instead of a property preview.
* - The `includeNullOrUndefined` option is dropped; null and undefined
* properties are always listed.
* - Top-level strings print as their raw value (as the console does for string
* arguments), and unserializable primitives (NaN, -0, 1n) print from
* `unserializableValue`.
* - The 400-character description limit that upstream passes to
* trimEndWithMaxLength() inline is named MAX_DESCRIPTION_LENGTH.
*/
import {DevTools} from '../third_party/index.js';
import type {Protocol} from '../third_party/index.js';
const {StringUtilities} = DevTools.Platform;
const MAX_DESCRIPTION_LENGTH = 400;
const PROMISE_STATE = '[[PromiseState]]';
const PROMISE_RESULT = '[[PromiseResult]]';
const GENERATOR_STATE = '[[GeneratorState]]';
const PRIMITIVE_VALUE = '[[PrimitiveValue]]';
const WEAK_REF_TARGET = '[[WeakRefTarget]]';
// Subtypes whose description is shown instead of a property preview.
const SUBTYPES_WITHOUT_VALUE_PREVIEW = new Set<string>([
'arraybuffer',
'dataview',
'date',
'error',
'internal#entry',
'null',
'node',
'regexp',
'trustedtype',
'webassemblymemory',
]);
function sortValue(property: Protocol.Runtime.PropertyPreview): number {
switch (property.name) {
case PROMISE_STATE:
return 1;
case PROMISE_RESULT:
return 2;
case GENERATOR_STATE:
case PRIMITIVE_VALUE:
case WEAK_REF_TARGET:
return 3;
}
if (property.type !== 'function' && !property.name.startsWith('#')) {
return 4;
}
return 5;
}
function formatName(name: string): string {
return /^\s|\s$|^$|\n/.test(name) ? `"${name.replace(/\n/g, '↵')}"` : name;
}
function abbreviateClassName(description: string): string {
const parts = description.split('.');
if (parts.length === 1 && parts[0] === 'Object') {
return '{…}';
}
return parts
.map((part, i) =>
i < parts.length - 1 ? StringUtilities.trimMiddle(part, 3) : part,
)
.join('.');
}
function formatPropertyValue(
type: string,
subtype: string | undefined,
description = '',
): string {
switch (type) {
case 'accessor':
return '(...)';
case 'function':
return 'ƒ';
case 'string':
return StringUtilities.formatAsJSLiteral(description);
case 'object':
return subtype ? description : abbreviateClassName(description);
default:
return description;
}
}
function arrayLength(preview: Protocol.Runtime.ObjectPreview): number {
const match = preview.description?.match(/[[(](\d+)[\])]$/);
return match ? Number(match[1]) : 0;
}
function formatArrayItems(preview: Protocol.Runtime.ObjectPreview): string[] {
const length = arrayLength(preview);
const toIndex = (name: string): number => {
const index = Number(name) >>> 0;
return String(index) === name && index < length ? index : -1;
};
const indexed = preview.properties
.filter(p => toIndex(p.name) !== -1)
.sort((a, b) => toIndex(a.name) - toIndex(b.name));
const others = preview.properties
.filter(p => toIndex(p.name) === -1)
.sort((a, b) => sortValue(a) - sortValue(b));
// Gaps can be shown when all properties are guaranteed to be in the preview.
const canShowGaps = !preview.overflow;
const gap = (count: number): string =>
count === 1 ? 'empty' : `empty × ${count}`;
const items: string[] = [];
let previous = -1;
for (const property of indexed) {
const index = toIndex(property.name);
const value = formatPropertyValue(
property.type,
property.subtype,
property.value,
);
if (canShowGaps) {
if (index - previous > 1) {
items.push(gap(index - previous - 1));
}
items.push(value);
} else {
items.push(index === items.length ? value : `${property.name}: ${value}`);
}
previous = index;
}
if (canShowGaps && length - previous > 1) {
items.push(gap(length - previous - 1));
}
for (const property of others) {
items.push(
`${formatName(property.name)}: ${formatPropertyValue(property.type, property.subtype, property.value)}`,
);
}
return items;
}
function formatObjectItems(preview: Protocol.Runtime.ObjectPreview): string[] {
const properties = preview.properties
.filter(p => p.type !== 'accessor')
.sort((a, b) => sortValue(a) - sortValue(b));
const items: string[] = [];
for (let i = 0; i < properties.length; i++) {
const property = properties[i];
const value = formatPropertyValue(
property.type,
property.subtype,
property.value,
);
if (preview.subtype === 'promise' && property.name === PROMISE_STATE) {
const result = properties.at(i + 1);
if (result?.name === PROMISE_RESULT) {
i++;
}
items.push(
property.value !== 'pending' && result?.name === PROMISE_RESULT
? `<${property.value}>: ${formatPropertyValue(result.type, result.subtype, result.value)}`
: `<${property.value}>`,
);
} else if (
preview.subtype === 'generator' &&
property.name === GENERATOR_STATE
) {
items.push(`<${property.value}>`);
} else if (property.name === PRIMITIVE_VALUE) {
items.push(value);
} else if (property.name === WEAK_REF_TARGET) {
items.push(property.type === 'undefined' ? '<cleared>' : value);
} else {
items.push(`${formatName(property.name)}: ${value}`);
}
}
return items;
}
function formatEntryItems(preview: Protocol.Runtime.ObjectPreview): string[] {
return (preview.entries ?? []).map(({key, value}) => {
const formattedValue = formatPropertyValue(
value.type,
value.subtype,
value.description,
);
return key
? `${formatPropertyValue(key.type, key.subtype, key.description)} => ${formattedValue}`
: formattedValue;
});
}
/**
* Formats a `Runtime.ObjectPreview` the way the DevTools console previews it,
* for example `Map(1) {1 => 2}`, `(3) [1, 2, 3]` or `{a: 1, self: {…}}`.
*/
function formatObjectPreview(preview: Protocol.Runtime.ObjectPreview): string {
const description = preview.description ?? '';
if (
preview.type !== 'object' ||
(preview.subtype && SUBTYPES_WITHOUT_VALUE_PREVIEW.has(preview.subtype))
) {
return formatPropertyValue(preview.type, preview.subtype, description);
}
const isArray =
preview.subtype === 'array' || preview.subtype === 'typedarray';
let prefix = '';
if (isArray) {
const length = arrayLength(preview);
const lengthText = length > 1 ? `(${length})` : '';
const name = description.replace(/[[(]\d+[\])]$/, '');
prefix = name === 'Array' ? lengthText : `${name}${lengthText}`;
} else if (description !== 'Object') {
prefix = description;
}
const items = preview.entries
? formatEntryItems(preview)
: isArray
? formatArrayItems(preview)
: formatObjectItems(preview);
if (preview.overflow) {
items.push('…');
}
const body = isArray ? `[${items.join(', ')}]` : `{${items.join(', ')}}`;
return prefix ? `${prefix} ${body}` : body;
}
/**
* Formats a console argument from the description and preview that CDP sends
* with it.
*/
export function formatRemoteObject(
remoteObject: Protocol.Runtime.RemoteObject,
): string {
if (
remoteObject.preview &&
remoteObject.type === 'object' &&
remoteObject.subtype !== 'node' &&
remoteObject.subtype !== 'trustedtype'
) {
return formatObjectPreview(remoteObject.preview);
}
if (remoteObject.type === 'string') {
return String(remoteObject.value ?? '');
}
if (remoteObject.unserializableValue !== undefined) {
return remoteObject.unserializableValue;
}
if (remoteObject.description !== undefined) {
return StringUtilities.trimEndWithMaxLength(
remoteObject.description,
MAX_DESCRIPTION_LENGTH,
);
}
return String(remoteObject.value);
}
+96
View File
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {SymbolizedError} from '../../src/devtools/DevtoolsUtils.js';
@@ -850,3 +851,98 @@ describe('ConsoleFormatter', () => {
);
});
});
describe('ConsoleFormatter arguments', () => {
it('formats non-JSON arguments from their preview and keeps JSON for plain values', async () => {
const message = createMockMessage({
type: () => 'log',
text: () => 'values',
args: () => [
{
jsonValue: async () => ({}),
remoteObject: () => ({
type: 'object',
subtype: 'map',
description: 'Map(1)',
preview: {
type: 'object',
subtype: 'map',
description: 'Map(1)',
overflow: false,
properties: [{name: 'size', type: 'number', value: '1'}],
entries: [
{
key: {
type: 'number',
description: '1',
overflow: false,
properties: [],
},
value: {
type: 'number',
description: '2',
overflow: false,
properties: [],
},
},
],
},
}),
},
{
jsonValue: async () => {
throw new Error('Object reference chain is too long');
},
remoteObject: () => ({
type: 'object',
description: 'Object',
preview: {
type: 'object',
description: 'Object',
overflow: false,
properties: [
{name: 'name', type: 'string', value: 'cyc'},
{name: 'self', type: 'object', value: 'Object'},
],
},
}),
},
{
jsonValue: async () => ({a: 1}),
remoteObject: () => ({type: 'object'}),
},
],
});
const formatter = await ConsoleFormatter.from(message, {
id: 1,
fetchDetailedData: true,
});
assert.deepStrictEqual(formatter.toJSONDetailed().args, [
'Map(1) {1 => 2}',
"{name: 'cyc', self: {…}}",
'{"a":1}',
]);
});
it('reports an argument without a preview whose jsonValue() fails as unavailable', async () => {
const message = createMockMessage({
type: () => 'log',
text: () => 'gone',
args: () => [
{
jsonValue: async () => {
throw new Error('Execution context was destroyed');
},
remoteObject: () => ({type: 'object', description: 'Object'}),
},
],
});
const formatter = await ConsoleFormatter.from(message, {
id: 1,
fetchDetailedData: true,
});
assert.deepStrictEqual(formatter.toJSONDetailed().args, [
'<error: Argument 0 is no longer available>',
]);
});
});
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {formatRemoteObject} from '../../src/formatters/RemoteObjectFormatter.js';
import type {Protocol} from '../../src/third_party/index.js';
function preview(
subtype: Protocol.Runtime.ObjectPreview['subtype'],
description: string,
properties: Protocol.Runtime.PropertyPreview[],
{
overflow = false,
entries,
}: {overflow?: boolean; entries?: Protocol.Runtime.EntryPreview[]} = {},
): Protocol.Runtime.RemoteObject {
return {
type: 'object',
subtype,
description,
preview: {
type: 'object',
subtype,
description,
overflow,
properties,
entries,
},
};
}
describe('formatRemoteObject', () => {
it('formats Map and Set entries', () => {
const number = (value: string): Protocol.Runtime.ObjectPreview => ({
type: 'number',
description: value,
overflow: false,
properties: [],
});
const map = preview(
'map',
'Map(1)',
[{name: 'size', type: 'number', value: '1'}],
{
entries: [
{
key: number('1'),
value: {
type: 'object',
description: 'Object',
overflow: false,
properties: [{name: 'a', type: 'number', value: '2'}],
},
},
],
},
);
assert.strictEqual(formatRemoteObject(map), 'Map(1) {1 => {…}}');
const set = preview(
'set',
'Set(2)',
[{name: 'size', type: 'number', value: '2'}],
{
entries: [
{value: number('3')},
{
value: {
type: 'string',
description: 'x',
overflow: false,
properties: [],
},
},
],
},
);
assert.strictEqual(formatRemoteObject(set), "Set(2) {3, 'x'}");
});
it('formats objects without following cycles', () => {
assert.strictEqual(
formatRemoteObject(
preview(undefined, 'Object', [
{name: 'name', type: 'string', value: 'cyc'},
{name: 'self', type: 'object', value: 'Object'},
{name: 'fn', type: 'function', value: ''},
]),
),
"{name: 'cyc', self: {…}, fn: ƒ}",
);
});
it('formats arrays with holes and overflow', () => {
assert.strictEqual(
formatRemoteObject(
preview('array', 'Array(3)', [
{name: '0', type: 'number', value: '1'},
{name: '2', type: 'number', value: '3'},
]),
),
'(3) [1, empty, 3]',
);
assert.strictEqual(
formatRemoteObject(
preview(
'array',
'Array(120)',
[
{name: '0', type: 'number', value: '0'},
{name: '1', type: 'number', value: '1'},
],
{overflow: true},
),
),
'(120) [0, 1, …]',
);
assert.strictEqual(
formatRemoteObject(
preview('typedarray', 'Uint8Array(2)', [
{name: '0', type: 'number', value: '1'},
{name: '1', type: 'number', value: '2'},
]),
),
'Uint8Array(2) [1, 2]',
);
});
it('formats promise state', () => {
assert.strictEqual(
formatRemoteObject(
preview('promise', 'Promise', [
{name: '[[PromiseState]]', type: 'string', value: 'fulfilled'},
{name: '[[PromiseResult]]', type: 'number', value: '5'},
]),
),
'Promise {<fulfilled>: 5}',
);
});
it('uses the description for values without a property preview', () => {
assert.strictEqual(
formatRemoteObject(
preview('date', 'Thu Jan 01 1970 00:00:00 GMT+0000', []),
),
'Thu Jan 01 1970 00:00:00 GMT+0000',
);
assert.strictEqual(
formatRemoteObject(preview('node', 'body', [], {overflow: true})),
'body',
);
assert.strictEqual(
formatRemoteObject({
type: 'function',
className: 'Function',
description: 'function foo(){ return 1; }',
}),
'function foo(){ return 1; }',
);
assert.strictEqual(
formatRemoteObject({type: 'symbol', description: 'Symbol(s)'}),
'Symbol(s)',
);
assert.strictEqual(
formatRemoteObject({
type: 'bigint',
unserializableValue: '10n',
description: '10n',
}),
'10n',
);
});
});