146 lines
5.0 KiB
TypeScript
146 lines
5.0 KiB
TypeScript
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<ConsoleMethod, (...args: unknown[]) => void>;
|
||
|
||
const LEVEL_OF: Record<ConsoleMethod, LogLevel | 'open' | 'close'> = {
|
||
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;
|
||
};
|
||
}
|