/** * 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 = {}; 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' }, ]); }); });