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:
rootandClaude Opus 5 committed 2026-09-12 19:35:40 +02:00
1 parent e7d82bd1a9
commit c47fd5e4dc
25 files changed
+690 -42

No files matched your search

+5 -2
View File
@@ -103,9 +103,12 @@ export async function inspector(t) {
// 2f. Type into X, then click straight into the empty viewport: the typed value must be
// committed (not reverted) even though the click deselects and rebuilds the inspector.
const center = await json('__gm.viewportCenter()');
// Searched, not guessed: with the console panel below, a fixed offset from the centre can
// land on an object instead of empty space.
const emptySpot = await json('__gm.emptySpot()');
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
await typeText('300');
await click(center.x, center.y + 250);
await click(emptySpot.x, emptySpot.y);
const xTyped = (await object('o2')).position.x;
s = await state();
check(
@@ -130,7 +133,7 @@ export async function inspector(t) {
check('save status is saved', (await state()).save === 'saved');
// 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
await click(emptySpot.x, emptySpot.y); // click empty viewport → deselect, drop focus
for (let i = 0; i < 4; i++) await undo();
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
check(
+33 -17
View File
@@ -47,6 +47,8 @@ export async function graph(t) {
// 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
const axisBefore = await nodeOf('n-axis');
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
// The fit zoom follows the canvas size, so 60 screen pixels are 60/zoom graph units, not 60.
const fitZoom = Number(await evaluate('__gm.graph.zoom()'));
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
let s = await state();
const axisAfter = await nodeOf('n-axis');
@@ -54,8 +56,8 @@ export async function graph(t) {
'dragging a node selects and moves it',
s.selection?.kind === 'node' &&
s.selection.nodeId === 'n-axis' &&
axisAfter.x === axisBefore.x + 60 &&
axisAfter.y === axisBefore.y + 40,
Math.abs(axisAfter.x - (axisBefore.x + 60 / fitZoom)) <= 2 &&
Math.abs(axisAfter.y - (axisBefore.y + 40 / fitZoom)) <= 2,
`${JSON.stringify(s.selection)} ${axisBefore.x},${axisBefore.y} → ${axisAfter.x},${axisAfter.y}`,
);
check(
@@ -73,9 +75,10 @@ export async function graph(t) {
// 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's
// blur-commit must not pick the first match as well.
const gcenter = await json('__gm.graph.center()');
// Bare canvas, searched rather than guessed: the console panel below shortens the canvas.
const bare = await json('__gm.graph.emptySpot()');
const beforeClickPick = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('stop');
@@ -95,7 +98,7 @@ export async function graph(t) {
// 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await click(bare.x, bare.y, 'right');
await sleep(100);
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('log');
@@ -167,7 +170,7 @@ export async function graph(t) {
// 13d3. Dropped into empty space, the drag opens the palette filtered to what it could reach,
// and the picked node arrives already wired – as one undo step.
const empty = { x: gcenter.x - 250, y: gcenter.y + 250 };
const empty = await json('__gm.graph.emptySpot()');
const beforeDrop = await graphCounts();
await mouse('mouseMoved', axisX.x, axisX.y);
await mouse('mousePressed', axisX.x, axisX.y);
@@ -362,6 +365,9 @@ export async function graph(t) {
await positions(),
);
// Fit again: the pan just now pushed a node out of view, and a band takes only what it sees.
await clickText('Fit');
await sleep(250);
// An empty spot: after a fit every node sits at least a margin away from the corner.
const corner = { x: area.x + 6, y: area.y + 6 };
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 };
@@ -389,18 +395,21 @@ export async function graph(t) {
// 6l. The whole selection moves as one, and one undo takes all of it back.
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
// Screen pixels become graph units through the zoom, which the fit picks from the canvas size.
const units = async (px) => px / Number(await evaluate('__gm.graph.zoom()'));
const [mx, my] = [await units(40), await units(30)];
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
const afterMove = await asset('mover');
const movedTogether = beforeMove.every((n) => {
const now = afterMove.graph.nodes.find((m) => m.id === n.id);
return now.x === n.x + 40 && now.y === n.y + 30;
return Math.abs(now.x - (n.x + mx)) <= 2 && Math.abs(now.y - (n.y + my)) <= 2;
});
check('dragging one of them moves the whole selection', movedTogether, await positions());
await undo();
check('one undo takes the group move back', (await positions()) === before13i, await positions());
// 13k2. Grid snapping. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30),
// so a screen delta is a graph delta.
// 13k2. Grid snapping. Distances are stated in graph units, which `units()` reads off the
// current zoom – the canvas is not wide enough here for a zoom of 1.
const GRID = 24;
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0;
check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false);
@@ -419,13 +428,14 @@ export async function graph(t) {
await sleep(150);
const snapPre = await nodeOf('n-axis');
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const snapBy = await units(50);
await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50);
const snapped = await nodeOf('n-axis');
check(
'a dragged node lands on the grid, not where the pointer let go',
onGrid(snapped) &&
Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 &&
Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2,
Math.abs(snapped.x - (snapPre.x + snapBy)) <= GRID / 2 &&
Math.abs(snapped.y - (snapPre.y + snapBy)) <= GRID / 2,
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
);
const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis');
@@ -445,6 +455,7 @@ export async function graph(t) {
JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)),
);
const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const groupBy = await units(30);
await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30);
const groupPost = (await asset('mover')).graph.nodes;
check(
@@ -452,7 +463,7 @@ export async function graph(t) {
groupPost.every(onGrid) &&
groupPre.every((n) => {
const now = groupPost.find((m) => m.id === n.id);
return Math.abs(now.x - (n.x + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= GRID / 2;
return Math.abs(now.x - (n.x + groupBy)) <= GRID / 2 && Math.abs(now.y - (n.y + groupBy)) <= GRID / 2;
}),
await positions(),
);
@@ -467,13 +478,15 @@ export async function graph(t) {
await sleep(150);
const offPre = await nodeOf('n-axis');
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
const offBy = await units(50);
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
const offPost = await nodeOf('n-axis');
check(
'switched off, a node lands exactly where it was dropped again',
'switched off, a node lands where it was dropped again, off the grid',
(await json('__gm.graph.snapping()')) === false &&
offPost.x === offPre.x + 50 &&
offPost.y === offPre.y + 50,
!onGrid(offPost) &&
Math.abs(offPost.x - (offPre.x + offBy)) <= 2 &&
Math.abs(offPost.y - (offPre.y + offBy)) <= 2,
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
);
await undo();
@@ -522,8 +535,11 @@ export async function graph(t) {
// 6o. Fit really fits: proven against a node parked far outside, and against the fact that the
// graph is then wider than the canvas – otherwise centring alone would pass this check.
// The distance follows the canvas: a fixed 2500 units needs a zoom below the 0.25 floor once
// the canvas is short, and then Fit cannot fit anything however correct it is.
const park = Math.round(area.w);
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += ${park}; __gm.state.preview(); })()`,
);
await sleep(250);
await clickText('Fit');
@@ -547,7 +563,7 @@ export async function graph(t) {
);
await shot('22-graph-fitted');
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= 2500; __gm.state.preview(); })()`,
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= ${park}; __gm.state.preview(); })()`,
);
await clickText('Fit');
await sleep(250);
+2 -2
View File
@@ -178,8 +178,8 @@ export async function nestedBehavior(t) {
// in the list on the left, so the proof is the node that appears, not the row.)
const nodeIds = async () => (await asset('nb-caller')).graph.nodes.map((n) => n.id);
const idsBefore = await nodeIds();
const centre = await json('__gm.graph.center()');
await click(centre.x + 250, centre.y + 250, 'right');
const bare = await json('__gm.graph.emptySpot()');
await click(bare.x, bare.y, 'right');
await sleep(150);
check('node palette opens', Boolean(await locate('Search nodes…')));
await typeText('Double');
+3 -3
View File
@@ -308,9 +308,9 @@ export async function variableNodes(t) {
// it runs `vz = vz + 1` once a frame – its own output feeding its input via Add.
const nodeIds = async () => (await asset('vp-logic')).graph.nodes.map((n) => n.id);
const idsBefore = await nodeIds();
const centre = await json('__gm.graph.center()');
// Below the Owner node; centre + (250, 250) would land on the Pack.
await click(centre.x - 250, centre.y + 250, 'right');
// Bare canvas below every node, so the click cannot land on the Pack.
const bareSpot = await json('__gm.graph.emptySpot()');
await click(bareSpot.x, bareSpot.y, 'right');
await sleep(150);
// Enter takes the first hit in category order, so search by a phrase only its description has.
await typeText('switched to Set');
+164
View File
@@ -0,0 +1,164 @@
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);
}
+2
View File
@@ -29,6 +29,7 @@ import { variableNodes } from './23-variable-nodes.mjs';
import { propertyNodes } from './24-property-nodes.mjs';
import { animation } from './25-animation.mjs';
import { stateMachine } from './26-state-machine.mjs';
import { consolePanel } from './27-console.mjs';
export const STEPS = [
hierarchy,
@@ -57,4 +58,5 @@ export const STEPS = [
propertyNodes,
animation,
stateMachine,
consolePanel,
];
+1
View File
@@ -15,6 +15,7 @@ export const LANES = {
nested: 700,
keyRepeat: -300,
stateMachine: -700,
console: -1100,
};
/**