Skip to content

Commit 4050ef3

Browse files
committed
feat(consoleFormat): resolve console template specifiers in messages
1 parent 8d765c0 commit 4050ef3

2 files changed

Lines changed: 187 additions & 12 deletions

File tree

src/formatters/ConsoleFormatter.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {UncaughtError} from '../PageCollector.js';
1313
import * as DevTools from '../third_party/index.js';
1414
import type {ConsoleMessage} from '../third_party/index.js';
15+
import {formatConsoleMessage} from '../utils/ConsoleFormat.js';
1516

1617
export interface ConsoleFormatterOptions {
1718
fetchDetailedData?: boolean;
@@ -36,6 +37,7 @@ export class ConsoleFormatter {
3637

3738
readonly #argCount: number;
3839
readonly #resolvedArgs: unknown[];
40+
readonly #remainingArgs: unknown[];
3941

4042
readonly #stack?: DevTools.DevTools.StackTrace.StackTrace.StackTrace;
4143
readonly #cause?: SymbolizedError;
@@ -57,6 +59,17 @@ export class ConsoleFormatter {
5759
this.#text = params.text;
5860
this.#argCount = params.argCount ?? 0;
5961
this.#resolvedArgs = params.resolvedArgs ?? [];
62+
63+
// Calculate remaining arguments after template substitution
64+
if (this.#resolvedArgs.length > 0 && this.#text) {
65+
const {remainingArgs} = formatConsoleMessage(this.#text, this.#resolvedArgs);
66+
this.#remainingArgs = remainingArgs;
67+
} else if (!this.#text && this.#resolvedArgs.length > 0) {
68+
this.#remainingArgs = this.#resolvedArgs.slice(1);
69+
} else {
70+
this.#remainingArgs = this.#resolvedArgs;
71+
}
72+
6073
this.#stack = params.stack;
6174
this.#cause = params.cause;
6275
this.#isIgnored = params.isIgnored;
@@ -112,12 +125,14 @@ export class ConsoleFormatter {
112125
let resolvedArgs: unknown[] = [];
113126
if (options.resolvedArgsForTesting) {
114127
resolvedArgs = options.resolvedArgsForTesting;
115-
} else if (options.fetchDetailedData) {
128+
} else {
116129
resolvedArgs = await Promise.all(
117130
msg.args().map(async (arg, i) => {
118131
try {
119132
const remoteObject = arg.remoteObject();
120133
if (
134+
options.fetchDetailedData &&
135+
options.devTools &&
121136
remoteObject.type === 'object' &&
122137
remoteObject.subtype === 'error'
123138
) {
@@ -160,14 +175,14 @@ export class ConsoleFormatter {
160175

161176
// The short format for a console message.
162177
toString(): string {
163-
return `msgid=${this.#id} [${this.#type}] ${this.#text} (${this.#argCount} args)`;
178+
return `msgid=${this.#id} [${this.#type}] ${this.#getFormattedText()} (${this.#argCount} args)`;
164179
}
165180

166181
// The verbose format for a console message, including all details.
167182
toStringDetailed(): string {
168183
const result = [
169184
`ID: ${this.#id}`,
170-
`Message: ${this.#type}> ${this.#text}`,
185+
`Message: ${this.#type}> ${this.#getFormattedText()}`,
171186
this.#formatArgs(),
172187
this.#formatStackTrace(this.#stack, this.#cause, {
173188
includeHeading: true,
@@ -176,16 +191,22 @@ export class ConsoleFormatter {
176191
return result.join('\n');
177192
}
178193

179-
#getArgs(): unknown[] {
180-
if (this.#resolvedArgs.length > 0) {
181-
const args = [...this.#resolvedArgs];
182-
// If there is no text, the first argument serves as text (see formatMessage).
183-
if (!this.#text) {
184-
args.shift();
185-
}
186-
return args;
194+
// Gets the formatted message text, applying template string substitutions if arguments are available.
195+
#getFormattedText(): string {
196+
if (this.#resolvedArgs.length > 0 && this.#text) {
197+
// apply template formatting
198+
const {formattedText} = formatConsoleMessage(this.#text, this.#resolvedArgs);
199+
return formattedText;
200+
} else if (!this.#text && this.#resolvedArgs.length > 0) {
201+
return this.#formatArg(this.#resolvedArgs[0]);
187202
}
188-
return [];
203+
// return the raw text
204+
return this.#text;
205+
}
206+
207+
#getArgs(): unknown[] {
208+
// Return the remaining arguments after template substitution
209+
return this.#remainingArgs;
189210
}
190211

191212
#formatArg(arg: unknown) {

src/utils/ConsoleFormat.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* This utility formats console messages with template strings, reusing Chrome DevTools
9+
* Based on the Console Standard (https://console.spec.whatwg.org/#formatter).
10+
*/
11+
12+
// Formats a console message text with its arguments, resolving format specifiers.
13+
export function formatConsoleMessage(
14+
text: string,
15+
args: unknown[],
16+
): {formattedText: string; remainingArgs: unknown[]} {
17+
if (!text) {
18+
return {formattedText: text, remainingArgs: args};
19+
}
20+
21+
let result = '';
22+
let argIndex = 0;
23+
24+
// eslint-disable-next-line no-control-regex
25+
const re = /%([%_Oocsdfi])|\x1B\[([\d;]*)m/g;
26+
27+
let lastIndex = 0;
28+
let match: RegExpExecArray | null;
29+
30+
while ((match = re.exec(text)) !== null) {
31+
result += text.substring(lastIndex, match.index);
32+
lastIndex = re.lastIndex;
33+
34+
const specifier = match[1];
35+
36+
if (specifier !== undefined) {
37+
switch (specifier) {
38+
case '%':
39+
// Escaped percent sign
40+
result += '%';
41+
break;
42+
43+
case 's':
44+
// String substitution
45+
if (argIndex < args.length) {
46+
result += formatArg(args[argIndex++], 'string');
47+
} else {
48+
result += match[0]; // Keep the specifier if no arg available
49+
}
50+
break;
51+
52+
case 'c':
53+
// Style substitution
54+
if (argIndex < args.length) {
55+
argIndex++;
56+
} else {
57+
result += match[0];
58+
}
59+
break;
60+
61+
case 'o':
62+
case 'O':
63+
// Object substitution
64+
if (argIndex < args.length) {
65+
result += formatArg(args[argIndex++], 'object');
66+
} else {
67+
result += match[0];
68+
}
69+
break;
70+
71+
case '_':
72+
// Ignore substitution
73+
if (argIndex < args.length) {
74+
argIndex++;
75+
} else {
76+
result += match[0];
77+
}
78+
break;
79+
80+
case 'd':
81+
case 'i':
82+
// Integer substitution
83+
if (argIndex < args.length) {
84+
const value = args[argIndex++];
85+
const numValue =
86+
typeof value === 'number' ? value : Number(value);
87+
result += isNaN(numValue)
88+
? 'NaN'
89+
: Math.floor(numValue).toString();
90+
} else {
91+
result += match[0];
92+
}
93+
break;
94+
95+
case 'f':
96+
// Float substitution
97+
if (argIndex < args.length) {
98+
const value = args[argIndex++];
99+
const numValue =
100+
typeof value === 'number' ? value : Number(value);
101+
result += isNaN(numValue) ? 'NaN' : numValue.toString();
102+
} else {
103+
result += match[0];
104+
}
105+
break;
106+
107+
default:
108+
// Unknown specifier, keep it as is
109+
result += match[0];
110+
break;
111+
}
112+
} else {
113+
// Handle ANSI escape codes - we ignore them in the formatted output
114+
}
115+
}
116+
117+
// Add any remaining text after the last match
118+
result += text.substring(lastIndex);
119+
120+
// Return formatted text and unused arguments
121+
return {
122+
formattedText: result,
123+
remainingArgs: args.slice(argIndex),
124+
};
125+
}
126+
127+
// Formats an argument value for display.
128+
function formatArg(arg: unknown, _hint: 'string' | 'object'): string {
129+
if (arg === null) {
130+
return 'null';
131+
}
132+
133+
if (arg === undefined) {
134+
return 'undefined';
135+
}
136+
137+
if (typeof arg === 'string') {
138+
return arg;
139+
}
140+
141+
if (typeof arg === 'number' || typeof arg === 'boolean') {
142+
return String(arg);
143+
}
144+
145+
if (typeof arg === 'object') {
146+
try {
147+
return JSON.stringify(arg);
148+
} catch {
149+
return String(arg);
150+
}
151+
}
152+
153+
return String(arg);
154+
}

0 commit comments

Comments
 (0)