diff --git a/src/formatters/ConsoleFormatter.ts b/src/formatters/ConsoleFormatter.ts index 98213230c..82b310e1e 100644 --- a/src/formatters/ConsoleFormatter.ts +++ b/src/formatters/ConsoleFormatter.ts @@ -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; @@ -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; @@ -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; @@ -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' ) { @@ -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, @@ -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) { diff --git a/src/utils/ConsoleFormat.ts b/src/utils/ConsoleFormat.ts new file mode 100644 index 000000000..6dfefe833 --- /dev/null +++ b/src/utils/ConsoleFormat.ts @@ -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); +}