Files
feuerwerk/tools/e2e/27-console.mjs
T
rootandClaude Opus 5 c47fd5e4dc 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>
2026-09-12 19:35:40 +02:00

165 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { LANES, scaffoldBehavior } from './lib/scaffold.mjs';
/**
* The console panel: every `console.*` call lands in it with its level, a Log node's message
* arrives through the very same path, the play status line deliberately does not, and the
* divider above it resizes down to the bare header. The step removes everything it made.
*/
const SPOKEN = 'the crate says hello';
/** `On start → Log`: the one path a project itself writes a line on. */
const talkerGraph = {
nodes: [node('c-start', 'OnStart', 0, 0), node('c-log', 'Log', 340, 0, { message: SPOKEN })],
edges: [edge('c-e1', 'c-start', 'exec', 'c-log', 'exec')],
};
export async function consolePanel(t) {
const { check, sleep, json, evaluate, shot, drag, wheel, clickText, play, stop } = t;
const lines = () => json('__gm.console.lines()');
const texts = async () => (await lines()).map((l) => l.text);
const height = () => json('__gm.console.height()');
const colorOf = (text) => json(`__gm.textColor(${JSON.stringify(text)})`);
/** The panel rebuilds on the frame after a push, so every read waits one out. */
const settle = () => sleep(300);
await evaluate('__gm.console.clear()');
await settle();
// 27a. The captured path: each console method with its own level, and the level drawn as colour.
await evaluate(`console.log('plain line 27'); console.warn('warned 27'); console.error('failed 27')`);
await settle();
const captured = await lines();
check(
'every console level lands in the panel, newest last',
JSON.stringify(captured.map((l) => ({ level: l.level, text: l.text }))) ===
JSON.stringify([
{ level: 'info', text: 'plain line 27' },
{ level: 'warn', text: 'warned 27' },
{ level: 'error', text: 'failed 27' },
]),
captured,
);
const [info, warn, error] = await Promise.all([
colorOf('plain line 27'),
colorOf('warned 27'),
colorOf('failed 27'),
]);
check(
'a warning is drawn amber and an error red, a plain line in the text colour',
info === '#e6e8ee' && warn === '#f2c84b' && error === '#e0553f',
{ info, warn, error },
);
// 27a2. Every line carries the clock time of its call, drawn muted in its own column left of
// the message – so the message itself is still exactly what was logged.
const stamps = captured.map((l) => l.at);
const stampAt = await json(`__gm.locate(${JSON.stringify(captured[0].at)})`);
const messageAt = await json(`__gm.locate('plain line 27')`);
check(
'each line is stamped HH:MM:SS, drawn muted and left of the message',
stamps.every((at) => /^\d{2}:\d{2}:\d{2}$/.test(at)) &&
stampAt.x < messageAt.x &&
(await colorOf(captured[0].at)) === '#8a90a0',
JSON.stringify({ stamps, stampAt, messageAt }),
);
// 27b. Several arguments become one line, and an Error shows its message instead of [object].
await evaluate(`console.log('joined 27', 3, { a: 1 }); console.error(new Error('thrown 27'))`);
await settle();
const formatted = (await texts()).slice(-2);
check(
'arguments are joined and an Error reads as its message',
formatted[0] === 'joined 27 3 {"a":1}' && formatted[1]?.includes('thrown 27') === true,
formatted,
);
// 27c. The real path a project uses: a Log node during play writes through `console.log`.
const rig = await scaffoldBehavior(t, {
assetId: 'talker',
name: 'Talker',
graph: talkerGraph,
boxId: 'c-box',
boxName: 'Talker box',
logicId: 'c-logic',
y: LANES.console,
});
await evaluate('__gm.console.clear()');
await play();
await settle();
const played = await texts();
check(
'a Log node reaches the panel with the behavior it came from',
played.some((line) => line === `[behavior Talker] ${SPOKEN}`),
played,
);
// 27d. The status line is a mode, not an event: the toolbar says it, the console stays out of it.
const notice = await json('__gm.state.notice');
check(
'the play status line shows in the toolbar but is kept out of the console',
notice.startsWith('Playing') && !played.some((line) => line.startsWith('Playing')),
{ notice, played },
);
await stop();
await settle();
await shot('27-console-after-play');
// 27e. Clear empties it, and only it – the toolbar notice is not the console's to touch.
await clickText('Clear');
await settle();
check('the Clear button empties the panel', (await lines()).length === 0);
// 27f. The ring buffer keeps the tail, not the head.
await evaluate(`for (let i = 0; i < 520; i += 1) console.log('ring ' + i)`);
await sleep(800);
const ring = await texts();
check(
'past its capacity the panel keeps the newest 500 lines',
ring.length === 500 && ring[0] === 'ring 20' && ring.at(-1) === 'ring 519',
{ length: ring.length, first: ring[0], last: ring.at(-1) },
);
const tail = await json('__gm.console.scroll()');
check('it follows the tail, so the newest line is the one showing', tail.offset === tail.max, tail);
// 27g. Reading back means the tail must stop pulling, or the line under the pointer runs away.
// A full buffer would hide that: dropping a line as one arrives leaves the height unchanged.
await evaluate('__gm.console.clear()');
await settle();
await evaluate(`for (let i = 0; i < 60; i += 1) console.log('scroll ' + i)`);
await settle();
const panel = await json('__gm.console.centre()');
await wheel(panel.x, panel.y, -200);
await settle();
const scrolledUp = await json('__gm.console.scroll()');
await evaluate(`console.log('arrived while reading 27')`);
await settle();
const stayed = await json('__gm.console.scroll()');
check(
'a line arriving while you read further up does not yank the view down',
scrolledUp.offset === scrolledUp.max - 200 &&
stayed.max > scrolledUp.max &&
stayed.offset === scrolledUp.offset,
JSON.stringify({ scrolledUp, stayed }),
);
await evaluate('__gm.console.clear()');
await settle();
// 27h. The divider resizes the panel, and shutting it leaves the header to grab it by again.
const start = await height();
const bar = await json('__gm.console.splitterAt()');
await drag(bar.x, bar.y, bar.x, bar.y - 60, { steps: 6 });
const taller = await height();
check('dragging the divider up makes the console taller', taller === start + 60, { start, taller });
const bar2 = await json('__gm.console.splitterAt()');
await drag(bar2.x, bar2.y, bar2.x, bar2.y + 400, { steps: 10 });
const shut = await height();
check('dragged shut it stops at its own header instead of vanishing', shut === 30, shut);
const bar3 = await json('__gm.console.splitterAt()');
await drag(bar3.x, bar3.y, bar3.x, bar3.y - (start - 30), { steps: 8 });
check('and it comes back to the height it started at', (await height()) === start, await height());
const left = await rig.remove();
check('the step leaves nothing behind', left.objects === 0 && left.assets === 0, left);
}