export type LogLevel = 'info' | 'warn' | 'error'; export interface LogLine { readonly level: LogLevel; readonly text: string; /** Wall clock of the call, in ms; the panel draws it as `HH:MM:SS`. */ readonly time: number; } /** Local time of `ms` as `HH:MM:SS` – a console tail is read within the session, not across days. */ export function formatTime(ms: number): string { const at = new Date(ms); const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`; } /** Oldest lines are dropped past this; the panel is a tail, not an archive. */ export const LOG_CAPACITY = 500; const MAX_VALUE = 200; function formatValue(value: unknown): string { if (typeof value === 'string') return value; // An Error prints its whole stack, which would bury the message under frames nobody reads here. if (value instanceof Error) return value.stack?.split('\n')[0] ?? `${value.name}: ${value.message}`; if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`; if (typeof value === 'object' && value !== null) { let json: string; try { json = JSON.stringify(value); } catch { return '[unserialisable]'; } return json.length > MAX_VALUE ? `${json.slice(0, MAX_VALUE)}…` : json; } return String(value); } const HAS_DIRECTIVE = /%[sdifoOc%]/; const DIRECTIVE = /%([sdifoOc%])/g; /** * One line out of the arguments of a `console.*` call, the way the browser joins them: a leading * format string consumes one argument per directive, `%c` swallows its style, the rest follow. */ export function formatArgs(args: readonly unknown[]): string { const [first, ...rest] = args; if (typeof first !== 'string' || !HAS_DIRECTIVE.test(first)) return args.map(formatValue).join(' '); const text = first.replace(DIRECTIVE, (match, spec: string) => { if (spec === '%') return '%'; if (rest.length === 0) return match; const value = rest.shift(); if (spec === 'c') return ''; if (spec === 'd' || spec === 'i') return String(Math.trunc(Number(value))); if (spec === 'f') return String(Number(value)); return formatValue(value); }); return [text, ...rest.map(formatValue)].join(' '); } export class ConsoleLog { private buffer: LogLine[] = []; private readonly listeners = new Set<() => void>(); private depth = 0; constructor(private readonly now: () => number = Date.now) {} /** * Appends `text`; an embedded newline starts a new line, so a stack stays readable. Lines inside * a group are indented under its head. */ push(level: LogLevel, text: string): void { const time = this.now(); const indent = ' '.repeat(this.depth); for (const part of text.split('\n')) this.buffer.push({ level, text: indent + part, time }); if (this.buffer.length > LOG_CAPACITY) this.buffer = this.buffer.slice(-LOG_CAPACITY); for (const listener of this.listeners) listener(); } /** A group's head is a line like any other; what follows until `closeGroup` sits under it. */ openGroup(text: string): void { this.push('info', text); this.depth += 1; } closeGroup(): void { this.depth = Math.max(0, this.depth - 1); } get lines(): readonly LogLine[] { return this.buffer; } clear(): void { this.depth = 0; if (this.buffer.length === 0) return; this.buffer = []; for (const listener of this.listeners) listener(); } /** Returns the call that takes the listener away again – a panel destroyed on sign-out (#177). */ onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } } export const consoleLog = new ConsoleLog(); const METHODS = ['log', 'info', 'debug', 'warn', 'error', 'group', 'groupCollapsed', 'groupEnd'] as const; type ConsoleMethod = (typeof METHODS)[number]; export type ConsoleLike = Record void>; const LEVEL_OF: Record = { log: 'info', info: 'info', debug: 'info', warn: 'warn', error: 'error', group: 'open', groupCollapsed: 'open', groupEnd: 'close', }; /** * Mirrors every `console.*` call into `log` and keeps calling the original, so devtools and the * check scripts still see it. Groups are mirrored too: Pixi's deprecation warnings put the message * into `groupCollapsed` and only the stack into `warn`. Returns the undo, which only the tests use. */ export function captureConsole(target: ConsoleLike = console, log: ConsoleLog = consoleLog): () => void { const originals = METHODS.map((method) => [method, target[method]] as const); for (const [method, original] of originals) { const level = LEVEL_OF[method]; target[method] = (...args: unknown[]) => { original.apply(target, args); if (level === 'close') log.closeGroup(); else if (level === 'open') log.openGroup(formatArgs(args)); else log.push(level, formatArgs(args)); }; } return () => { for (const [method, original] of originals) target[method] = original; }; }