/** * 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(), group: vi.fn(), groupCollapsed: vi.fn(), groupEnd: 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'); }); it('fills the directives of a leading format string the way the browser does', () => { // Pixi's deprecation head: two `%c` styles that the panel cannot show, then the message. expect( formatArgs([ '%cPixiJS Deprecation Warning: %c%s', 'color:#614108', 'font-weight:normal', 'addChild: no', ]), ).toBe('PixiJS Deprecation Warning: addChild: no'); expect(formatArgs(['%d of %i at %f%%', 7.9, '3', 0.5, 'left'])).toBe('7 of 3 at 0.5% left'); expect(formatArgs(['%o and %O', { a: 1 }, [2]])).toBe('{"a":1} and [2]'); expect(formatArgs(['%s only', 'one'])).toBe('one only'); }); it('leaves a directive standing that has no argument, and a % elsewhere alone', () => { expect(formatArgs(['%s and %s', 'one'])).toBe('one and %s'); expect(formatArgs(['100% done', 'x'])).toBe('100% done x'); }); }); 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('keeps a group head and indents what follows until the group ends', () => { const target = fakeConsole(); const log = new ConsoleLog(); captureConsole(target, log); target.groupCollapsed( '%cPixiJS Deprecation Warning: %c%s', 'css', 'css', 'addChild: no\nDeprecated since v8', ); target.warn(' at one\n at two'); target.group('inner'); target.log('deep'); target.groupEnd(); target.groupEnd(); target.groupEnd(); target.log('after'); expect(log.lines.map((l) => `${l.level}|${l.text}`)).toEqual([ 'info|PixiJS Deprecation Warning: addChild: no', 'info|Deprecated since v8', 'warn| at one', 'warn| at two', 'info| inner', 'info| deep', 'info|after', ]); }); it('forgets an open group on clear', () => { const log = new ConsoleLog(); log.openGroup('head'); log.clear(); log.push('info', 'flat'); expect(log.lines.map((l) => l.text)).toEqual(['flat']); }); 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' }, ]); }); });