Skip to content
Closed
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
45 changes: 33 additions & 12 deletions src/formatters/ConsoleFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import {UncaughtError} from '../PageCollector.js';
import * as DevTools from '../third_party/index.js';
import type {ConsoleMessage} from '../third_party/index.js';
import {formatConsoleMessage} from '../utils/ConsoleFormat.js';

export interface ConsoleFormatterOptions {
fetchDetailedData?: boolean;
Expand All @@ -36,6 +37,7 @@ export class ConsoleFormatter {

readonly #argCount: number;
readonly #resolvedArgs: unknown[];
readonly #remainingArgs: unknown[];

readonly #stack?: DevTools.DevTools.StackTrace.StackTrace.StackTrace;
readonly #cause?: SymbolizedError;
Expand All @@ -57,6 +59,17 @@ export class ConsoleFormatter {
this.#text = params.text;
this.#argCount = params.argCount ?? 0;
this.#resolvedArgs = params.resolvedArgs ?? [];

// Calculate remaining arguments after template substitution
if (this.#resolvedArgs.length > 0 && this.#text) {
const {remainingArgs} = formatConsoleMessage(this.#text, this.#resolvedArgs);
this.#remainingArgs = remainingArgs;
} else if (!this.#text && this.#resolvedArgs.length > 0) {
this.#remainingArgs = this.#resolvedArgs.slice(1);
} else {
this.#remainingArgs = this.#resolvedArgs;
}

this.#stack = params.stack;
this.#cause = params.cause;
this.#isIgnored = params.isIgnored;
Expand Down Expand Up @@ -112,12 +125,14 @@ export class ConsoleFormatter {
let resolvedArgs: unknown[] = [];
if (options.resolvedArgsForTesting) {
resolvedArgs = options.resolvedArgsForTesting;
} else if (options.fetchDetailedData) {
} else {
resolvedArgs = await Promise.all(
msg.args().map(async (arg, i) => {
try {
const remoteObject = arg.remoteObject();
if (
options.fetchDetailedData &&
options.devTools &&
remoteObject.type === 'object' &&
remoteObject.subtype === 'error'
) {
Expand Down Expand Up @@ -160,14 +175,14 @@ export class ConsoleFormatter {

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

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

#getArgs(): unknown[] {
if (this.#resolvedArgs.length > 0) {
const args = [...this.#resolvedArgs];
// If there is no text, the first argument serves as text (see formatMessage).
if (!this.#text) {
args.shift();
}
return args;
// Gets the formatted message text, applying template string substitutions if arguments are available.
#getFormattedText(): string {
if (this.#resolvedArgs.length > 0 && this.#text) {
// apply template formatting
const {formattedText} = formatConsoleMessage(this.#text, this.#resolvedArgs);
return formattedText;
} else if (!this.#text && this.#resolvedArgs.length > 0) {
return this.#formatArg(this.#resolvedArgs[0]);
}
return [];
// return the raw text
return this.#text;
}

#getArgs(): unknown[] {
// Return the remaining arguments after template substitution
return this.#remainingArgs;
}

#formatArg(arg: unknown) {
Expand Down
154 changes: 154 additions & 0 deletions src/utils/ConsoleFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

/**
* This utility formats console messages with template strings, reusing Chrome DevTools
* Based on the Console Standard (https://console.spec.whatwg.org/#formatter).
*/

// Formats a console message text with its arguments, resolving format specifiers.
export function formatConsoleMessage(
text: string,
args: unknown[],
): {formattedText: string; remainingArgs: unknown[]} {
if (!text) {
return {formattedText: text, remainingArgs: args};
}

let result = '';
let argIndex = 0;

// eslint-disable-next-line no-control-regex
const re = /%([%_Oocsdfi])|\x1B\[([\d;]*)m/g;

let lastIndex = 0;
let match: RegExpExecArray | null;

while ((match = re.exec(text)) !== null) {
result += text.substring(lastIndex, match.index);
lastIndex = re.lastIndex;

const specifier = match[1];

if (specifier !== undefined) {
switch (specifier) {
case '%':
// Escaped percent sign
result += '%';
break;

case 's':
// String substitution
if (argIndex < args.length) {
result += formatArg(args[argIndex++], 'string');
} else {
result += match[0]; // Keep the specifier if no arg available
}
break;

case 'c':
// Style substitution
if (argIndex < args.length) {
argIndex++;
} else {
result += match[0];
}
break;

case 'o':
case 'O':
// Object substitution
if (argIndex < args.length) {
result += formatArg(args[argIndex++], 'object');
} else {
result += match[0];
}
break;

case '_':
// Ignore substitution
if (argIndex < args.length) {
argIndex++;
} else {
result += match[0];
}
break;

case 'd':
case 'i':
// Integer substitution
if (argIndex < args.length) {
const value = args[argIndex++];
const numValue =
typeof value === 'number' ? value : Number(value);
result += isNaN(numValue)
? 'NaN'
: Math.floor(numValue).toString();
} else {
result += match[0];
}
break;

case 'f':
// Float substitution
if (argIndex < args.length) {
const value = args[argIndex++];
const numValue =
typeof value === 'number' ? value : Number(value);
result += isNaN(numValue) ? 'NaN' : numValue.toString();
} else {
result += match[0];
}
break;

default:
// Unknown specifier, keep it as is
result += match[0];
break;
}
} else {
// Handle ANSI escape codes - we ignore them in the formatted output
}
}

// Add any remaining text after the last match
result += text.substring(lastIndex);

// Return formatted text and unused arguments
return {
formattedText: result,
remainingArgs: args.slice(argIndex),
};
}

// Formats an argument value for display.
function formatArg(arg: unknown, _hint: 'string' | 'object'): string {
if (arg === null) {
return 'null';
}

if (arg === undefined) {
return 'undefined';
}

if (typeof arg === 'string') {
return arg;
}

if (typeof arg === 'number' || typeof arg === 'boolean') {
return String(arg);
}

if (typeof arg === 'object') {
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}

return String(arg);
}