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>
137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
/**
|
|
* The console panel's source: the ring buffer, the way `console.*` arguments become one line,
|
|
* and which notices are kept. The panel itself needs a browser and is covered by e2e step 27.
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
captureConsole,
|
|
ConsoleLog,
|
|
type ConsoleLike,
|
|
consoleLog,
|
|
formatArgs,
|
|
formatTime,
|
|
LOG_CAPACITY,
|
|
} from '../../src/editor/console/log';
|
|
import { EditorState } from '../../src/editor/state';
|
|
|
|
function fakeConsole(): ConsoleLike {
|
|
return { log: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
}
|
|
|
|
describe('the log buffer', () => {
|
|
it('keeps lines newest last, each stamped with the time of its call', () => {
|
|
let clock = new Date(2026, 8, 12, 9, 5, 3).getTime();
|
|
const log = new ConsoleLog(() => clock);
|
|
log.push('info', 'first');
|
|
clock += 61_000;
|
|
log.push('error', 'second');
|
|
expect(log.lines).toEqual([
|
|
{ level: 'info', text: 'first', time: new Date(2026, 8, 12, 9, 5, 3).getTime() },
|
|
{ level: 'error', text: 'second', time: new Date(2026, 8, 12, 9, 6, 4).getTime() },
|
|
]);
|
|
expect(log.lines.map((l) => formatTime(l.time))).toEqual(['09:05:03', '09:06:04']);
|
|
});
|
|
|
|
it('gives every line of a split message the one time it arrived', () => {
|
|
// A clock that moves on every read: asked once per line, the second would fall into the next day.
|
|
let reads = 0;
|
|
const log = new ConsoleLog(() => new Date(2026, 8, 12, 23, 59, 59).getTime() + reads++ * 1000);
|
|
log.push('error', 'boom\n at frame');
|
|
expect(log.lines.map((l) => formatTime(l.time))).toEqual(['23:59:59', '23:59:59']);
|
|
expect(reads).toBe(1);
|
|
});
|
|
|
|
it('drops the oldest lines past the capacity', () => {
|
|
const log = new ConsoleLog();
|
|
for (let i = 0; i < LOG_CAPACITY + 10; i += 1) log.push('info', `line ${i}`);
|
|
expect(log.lines).toHaveLength(LOG_CAPACITY);
|
|
expect(log.lines[0]?.text).toBe('line 10');
|
|
expect(log.lines.at(-1)?.text).toBe(`line ${LOG_CAPACITY + 9}`);
|
|
});
|
|
|
|
it('splits an embedded newline into its own line', () => {
|
|
const log = new ConsoleLog();
|
|
log.push('error', 'boom\n at frame');
|
|
expect(log.lines.map((l) => l.text)).toEqual(['boom', ' at frame']);
|
|
});
|
|
|
|
it('notifies on a push and on a clear, but not on an empty clear', () => {
|
|
const log = new ConsoleLog();
|
|
const seen = vi.fn();
|
|
log.onChange(seen);
|
|
log.push('info', 'one');
|
|
log.clear();
|
|
log.clear();
|
|
expect(seen).toHaveBeenCalledTimes(2);
|
|
expect(log.lines).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('formatting the arguments of a console call', () => {
|
|
it('joins them with a space and leaves strings alone', () => {
|
|
expect(formatArgs(['[behavior Crate]', 'hit', 3])).toBe('[behavior Crate] hit 3');
|
|
});
|
|
|
|
it('shows an Error as its first stack line, not the whole stack', () => {
|
|
const error = new Error('gone wrong');
|
|
error.stack = 'Error: gone wrong\n at one\n at two';
|
|
expect(formatArgs([error])).toBe('Error: gone wrong');
|
|
});
|
|
|
|
it('writes an object as JSON and a circular one as a placeholder', () => {
|
|
expect(formatArgs([{ x: 1, y: [2, 3] }])).toBe('{"x":1,"y":[2,3]}');
|
|
const circular: Record<string, unknown> = {};
|
|
circular.self = circular;
|
|
expect(formatArgs([circular])).toBe('[unserialisable]');
|
|
});
|
|
|
|
it('keeps null and undefined visible', () => {
|
|
expect(formatArgs([null, undefined, false])).toBe('null undefined false');
|
|
});
|
|
});
|
|
|
|
describe('capturing console', () => {
|
|
it('mirrors every level into the log and still calls the original', () => {
|
|
const target = fakeConsole();
|
|
const log = new ConsoleLog();
|
|
const undo = captureConsole(target, log);
|
|
target.log('a');
|
|
target.info('b');
|
|
target.debug('c');
|
|
target.warn('d');
|
|
target.error('e');
|
|
expect(log.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([
|
|
{ level: 'info', text: 'a' },
|
|
{ level: 'info', text: 'b' },
|
|
{ level: 'info', text: 'c' },
|
|
{ level: 'warn', text: 'd' },
|
|
{ level: 'error', text: 'e' },
|
|
]);
|
|
undo();
|
|
target.log('after');
|
|
expect(log.lines).toHaveLength(5);
|
|
});
|
|
|
|
it('leaves the original reachable, so devtools and the check scripts still see the call', () => {
|
|
const target = fakeConsole();
|
|
const original = target.warn;
|
|
captureConsole(target, new ConsoleLog())();
|
|
expect(target.warn).toBe(original);
|
|
});
|
|
});
|
|
|
|
describe('notices in the console', () => {
|
|
it('keeps a plain notice, marks a failure red and stays quiet for a status line', () => {
|
|
const state = new EditorState();
|
|
consoleLog.clear();
|
|
state.setNotice('Only image files can be dropped into the editor.');
|
|
state.setNotice('Saving failed: 500', 'error');
|
|
state.setNotice('Playing · edits are not saved', 'quiet');
|
|
state.setNotice('');
|
|
expect(consoleLog.lines.map((l) => ({ level: l.level, text: l.text }))).toEqual([
|
|
{ level: 'info', text: 'Only image files can be dropped into the editor.' },
|
|
{ level: 'error', text: 'Saving failed: 500' },
|
|
]);
|
|
});
|
|
});
|