Console panel: capture console.*, timestamped lines, resizable (#50)
A log layer (src/editor/console/log.ts) keeps a 500-line ring buffer and
wraps console.log/info/debug/warn/error, still calling the original. Notices
and behaviour runtime errors go straight to the layer instead of through
console, so editor tests stay quiet and errors can render red. world.log is
untouched: it is per-run structured state that runtime tests and e2e read
directly, not a text stream.
The panel sits under the visible tab, resizable with Splitter('y'), with a
CONSOLE header and a clear button. Each line carries its wall-clock time in
its own dimmed column, so locate() still finds the message text.
The taller centre area drops the graph fit zoom to 0.60, which broke six e2e
checks that assumed one screen pixel is one graph unit and that a fixed
offset from the canvas centre is empty. They now ask __gm.emptySpot(),
__gm.graph.emptySpot() and __gm.graph.zoom() instead of guessing.
locate/locateAll added the unscaled Pixi text width to the global position,
so click points in the scaled graph canvas landed too far right; they use
getBounds() now.
check.sh: 831 tests. Full e2e: ALL PASSED - 671 checks. Smokes 24/19/20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7d82bd1a9
commit
c47fd5e4dc
25 files changed
+690
-42
No files matched your search
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/** One line out of the arguments of a `console.*` call, the way the browser joins them. */
|
||||
export function formatArgs(args: readonly unknown[]): string {
|
||||
return args.map(formatValue).join(' ');
|
||||
}
|
||||
|
||||
export class ConsoleLog {
|
||||
private buffer: LogLine[] = [];
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
/** Appends `text`; an embedded newline starts a new line, so a stack stays readable. */
|
||||
push(level: LogLevel, text: string): void {
|
||||
const time = this.now();
|
||||
for (const part of text.split('\n')) this.buffer.push({ level, text: part, time });
|
||||
if (this.buffer.length > LOG_CAPACITY) this.buffer = this.buffer.slice(-LOG_CAPACITY);
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
get lines(): readonly LogLine[] {
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.buffer.length === 0) return;
|
||||
this.buffer = [];
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
onChange(listener: () => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export const consoleLog = new ConsoleLog();
|
||||
|
||||
const METHODS = ['log', 'info', 'debug', 'warn', 'error'] as const;
|
||||
type ConsoleMethod = (typeof METHODS)[number];
|
||||
|
||||
export type ConsoleLike = Record<ConsoleMethod, (...args: unknown[]) => void>;
|
||||
|
||||
const LEVEL_OF: Record<ConsoleMethod, LogLevel> = {
|
||||
log: 'info',
|
||||
info: 'info',
|
||||
debug: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors every `console.*` call into `log` and keeps calling the original, so devtools and the
|
||||
* check scripts still see it. 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) {
|
||||
target[method] = (...args: unknown[]) => {
|
||||
original.apply(target, args);
|
||||
log.push(LEVEL_OF[method], formatArgs(args));
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
for (const [method, original] of originals) target[method] = original;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user