Files
feuerwerk/tools/e2e.mjs
T
rootandClaude Fable 5.1 6ecb20cf02 Trigger colliders and Move and slide against solid colliders
- Collider property isTrigger (no blocking, still reports enter/stay/exit)
- Node 'Move and slide' with blocked x/y outputs, axis-wise resolution
- axisSeparation/axisCorrection geometry for box/box, circle/circle, box/circle
- Amber outline for trigger colliders in the viewport
- Demo mover uses Move and slide; E2E step 15b, 7 new unit tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:38:14 +02:00

634 lines
37 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.
#!/usr/bin/env node
/**
* Headless end-to-end smoke test driven over the Chrome DevTools Protocol.
* Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs <projectId> [baseUrl]
* Writes screenshots to /tmp/gm-e2e-*.png and prints PASS/FAIL checks. No dependencies.
*
* Widgets are located through `window.__gm.locate(text)` (installed by the editor), so
* the test does not depend on pixel coordinates.
*/
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const projectId = process.argv[2];
const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
if (!projectId) {
console.error('usage: node tools/e2e.mjs <projectId> [baseUrl]');
process.exit(2);
}
const PORT = 9333;
const W = 1280;
const H = 800;
const chrome = spawn('chromium', [
'--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader',
`--remote-debugging-port=${PORT}`, `--window-size=${W},${H}`, '--hide-scrollbars', 'about:blank',
], { stdio: 'ignore' });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitForChrome() {
for (let i = 0; i < 50; i++) {
try {
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (res.ok) return res.json();
} catch {}
await sleep(200);
}
throw new Error('chromium did not start');
}
class CDP {
constructor(ws) {
this.ws = ws;
this.id = 0;
this.pending = new Map();
this.listeners = [];
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
} else if (msg.method) {
for (const l of this.listeners) l(msg);
}
});
}
send(method, params = {}) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
}
on(fn) { this.listeners.push(fn); }
}
const consoleLog = [];
let failures = 0;
function check(name, ok, detail = '') {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`);
if (!ok) failures++;
}
try {
await waitForChrome();
const target = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r) => ws.addEventListener('open', r));
const cdp = new CDP(ws);
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false });
cdp.on((msg) => {
if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text));
});
const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
const locate = async (text, last = false) => {
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)}, ${last})`);
if (!p) throw new Error(`widget with text "${text}" not found`);
return p;
};
const shot = async (name) => {
const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' });
writeFileSync(`/tmp/gm-e2e-${name}.png`, Buffer.from(data, 'base64'));
};
const mouse = (type, x, y, button = 'left', extra = {}) =>
cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra });
const click = async (x, y, button = 'left') => {
await mouse('mouseMoved', x, y);
await mouse('mousePressed', x, y, button);
await mouse('mouseReleased', x, y, button);
await sleep(150);
};
const clickText = async (text, button = 'left') => {
const p = await locate(text);
await click(p.x, p.y, button);
};
const drag = async (x1, y1, x2, y2, steps = 12) => {
await mouse('mouseMoved', x1, y1);
await mouse('mousePressed', x1, y1);
for (let i = 1; i <= steps; i++) {
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 });
await sleep(16);
}
await mouse('mouseReleased', x2, y2);
await sleep(150);
};
const keyPress = async (key, code, opts = {}) => {
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, ...opts });
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, ...opts });
};
const typeText = async (text) => {
for (const ch of text) await cdp.send('Input.insertText', { text: ch });
};
const api = async (route) => (await fetch(`${baseUrl}api/index.php?r=${route}`)).json();
const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene');
const stateJson = () => evaluate('JSON.stringify({ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice })');
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
await sleep(4000);
await shot('01-loaded');
check('page loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | '));
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)')));
// 1. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = JSON.parse(await stateJson());
check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection));
await shot('02-object-selected');
// 2. Rename via inspector: the name field sits right of the "Name" label.
const nameLabel = await locate('Name');
await click(nameLabel.x + 160, nameLabel.y);
await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 }); // Ctrl+A in the bridge input
await typeText('Renamed crate');
await shot('03-typing');
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(200);
s = JSON.parse(await stateJson());
const nameNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`);
check('typing + Enter renamed the object', nameNow === 'Renamed crate', JSON.stringify(nameNow));
await shot('04-renamed');
// 3. Scrub the X field (right of the "X" label) by 100 px.
const xLabel = await locate('X');
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
const xNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
await shot('05-scrubbed');
// 3b. 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 evaluate('__gm.viewportCenter()');
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
await typeText('300');
await click(center.x, center.y + 250);
const xTyped = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
s = JSON.parse(await stateJson());
check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`);
// 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport widget bounds.
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
const o1Now = await evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === 'o1').position)`);
check('viewport drag moved the red crate', JSON.parse(o1Now).x > -150, o1Now);
await shot('06-dragged');
// 5. Autosave → server.
await sleep(1800);
const saved = await api(`projects/${projectId}`);
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
check('rename persisted', renamed.name === 'Renamed crate', renamed.name);
check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position));
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
check('save status is saved', (JSON.parse(await stateJson())).save === 'saved');
// 6. 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
for (let i = 0; i < 4; i++) {
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(80);
}
const afterUndo = JSON.parse(await evaluate(`JSON.stringify(__gm.state.activeScene.objects.map(o => ({ id: o.id, name: o.name, x: o.position.x })))`));
check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo));
await keyPress('z', 'KeyZ', { modifiers: 10, windowsVirtualKeyCode: 90 }); // Ctrl+Shift+Z
const afterRedo = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`);
check('redo restored the rename', afterRedo === 'Renamed crate', afterRedo);
await shot('07-undo-redo');
// 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the
// origin; the behavior symbol is editor-only). The "Mover" behavior owns the red crate:
// holding ArrowRight moves it in the world (Axis input × speed × dt), Esc stops without
// touching the model.
await clickText('Renamed crate');
const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
await clickText('▶ Play');
await sleep(300);
s = JSON.parse(await stateJson());
check('play mode entered', s.mode === 'play', JSON.stringify(s));
let info = await evaluate('JSON.stringify(__gm.viewportInfo())');
check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info);
await shot('08-playing');
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(400);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
const worldAfterRight = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
const movedCrate = worldAfterRight.entities.find((e) => e.objectId === 'o1');
check('behavior graph moved its owner while ArrowRight was held', movedCrate.x > -150 + 30, `world x=${movedCrate.x}`);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await sleep(250);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
const afterLeft = JSON.parse(await evaluate('JSON.stringify(__gm.world())')).entities.find((e) => e.objectId === 'o1');
check('ArrowLeft (axis x = −1) moved the owner back left', afterLeft.x < movedCrate.x, `world x=${afterLeft.x}`);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
s = JSON.parse(await stateJson());
check('escape stopped play', s.mode === 'edit', JSON.stringify(s));
await shot('09-stopped');
const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`);
const o1AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x`);
check('play did not write back to the model', o2AfterPlay === xBeforePlay && o1AfterPlay === -150, `${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`);
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`);
await clickText('▶ Play');
await sleep(300);
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
check('objects outside the camera frame are culled', JSON.parse(info).visibleItems === 1, info);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}; __gm.state.preview()`);
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(200);
check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 3);
await clickText('▶ Play');
await sleep(300);
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
s = JSON.parse(await stateJson());
check('play without camera renders nothing', s.mode === 'play' && !JSON.parse(info).rendering && JSON.parse(info).visibleItems === 0, info);
check('play without camera shows a notice', s.notice.includes('no camera'), s.notice);
await shot('10-play-no-camera');
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(300);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 4);
// 8. Context menu on the Crate asset → menu shows "Place in scene".
await clickText('Crate', 'right');
await sleep(200);
const menuVisible = await evaluate(`Boolean(__gm.locate('Place in scene'))`);
check('asset context menu opens', menuVisible);
await shot('11-context-menu');
await clickText('Place in scene');
await sleep(200);
const count = await evaluate('__gm.state.activeScene.objects.length');
check('placing from the menu adds an object', count === 5, `objects=${count}`);
// 9. Create a new asset through the + menu.
await clickText('+');
await sleep(150);
await clickText('Sprite');
await sleep(200);
const assets = await evaluate(`JSON.stringify(__gm.state.project.assets.map(a => a.name))`);
check('+ menu creates a sprite asset', JSON.parse(assets).includes('Sprite'), assets);
await shot('12-new-asset');
// 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
// right on top of its icon → the icon must still be drawn above the sprite.
await evaluate(`(() => {
const objs = __gm.state.activeScene.objects;
const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0];
objs.unshift(cam);
const crate = objs.find(o => o.id === 'o1');
crate.position.x = 0; crate.position.y = 0;
__gm.state.select(null);
__gm.state.preview();
})()`);
await sleep(200);
const layers = await evaluate(`JSON.stringify([__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')])`);
check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers);
await shot('13-symbol-on-top');
// 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
await evaluate(`(() => {
const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2');
o2.position.x = 0; o2.position.y = 0;
__gm.state.preview();
})()`);
await sleep(200);
const orderBefore = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`);
check('equal z keeps hierarchy order', orderBefore === '["o1","o2"]', orderBefore);
await evaluate(`__gm.state.select({ kind: 'object', sceneId: __gm.state.activeScene.id, objectId: 'o1' })`);
await sleep(200);
const zLabel = await locate('Z');
await click(zLabel.x + 160, zLabel.y); // plain click → text editing with everything selected
await typeText('5');
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(200);
const zNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`);
check('Z field sets position.z', zNow === 5, `z=${zNow}`);
const orderAfter = await evaluate(`JSON.stringify(__gm.drawOrder().filter(id => id === 'o1' || id === 'o2'))`);
check('higher z is drawn on top', orderAfter === '["o2","o1"]', orderAfter);
await shot('14-z-order');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
const zUndone = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.z`);
check('undo restores z', zUndone === 0, `z=${zUndone}`);
// 12. Asset defaults: the Crate asset has height 60 (schema default 100), width and
// color at their defaults → exactly one reset button is visible. Clicking it puts
// height back to 100 and hides the button; undo brings 60 back.
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'crate' })`);
await sleep(200);
const resetButton = await evaluate(`__gm.locate('↺')`);
check('asset inspector shows a reset for the non-default height', Boolean(resetButton));
await click(resetButton.x, resetButton.y);
await sleep(200);
const heightReset = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`);
check('reset puts the asset property back to its default', heightReset === 100, `height=${heightReset}`);
check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`)));
await shot('15-asset-reset');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(200);
const heightUndone = await evaluate(`__gm.state.project.assets.find(a => a.id === 'crate').props.height`);
check('undo restores the asset value', heightUndone === 60, `height=${heightUndone}`);
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
// 13. Behavior graph editor. Selecting the Mover asset binds the graph; the Graph tab shows it.
await clickText('Mover');
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
check('graph editor shows the selected behavior', (await evaluate(`__gm.graph.current()`)) === 'mover' && (await evaluate('__gm.tab()')) === 'graph');
await shot('16-graph');
const nodeOf = (id) => evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === ${JSON.stringify(id)}))`).then(JSON.parse);
const graphCounts = () => evaluate(`(() => { const g = __gm.state.project.assets.find(a => a.id === 'mover').graph; return JSON.stringify({ nodes: g.nodes.length, edges: g.edges.length }); })()`).then(JSON.parse);
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
const mulBefore = await nodeOf('n-axis');
const mulPos = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.nodeAt('n-axis'))`));
await drag(mulPos.x, mulPos.y, mulPos.x + 60, mulPos.y + 40);
s = JSON.parse(await stateJson());
const mulAfter = await nodeOf('n-axis');
check('dragging a node selects and moves it', s.selection?.kind === 'node' && s.selection.nodeId === 'n-axis' && mulAfter.x === mulBefore.x + 60 && mulAfter.y === mulBefore.y + 40, `${JSON.stringify(s.selection)} ${mulBefore.x},${mulBefore.y} → ${mulAfter.x},${mulAfter.y}`);
check('node inspector shows the node', Boolean(await evaluate(`__gm.locate('Axis input')`)) && Boolean(await evaluate(`__gm.locate('DATA NODE')`)));
// The Move node's unconnected `scale` shows its port default (1) once its edge is removed;
// with the edge present the inspector hides it. Port defaults are covered by unit tests.
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const mulUndone = await nodeOf('n-axis');
check('undo moves the node back', mulUndone.x === mulBefore.x && mulUndone.y === mulBefore.y, `${mulUndone.x},${mulUndone.y}`);
// 13a. Regression: typing a query and then *clicking* a row must add exactly one node.
// (Previously the search field's blur-commit picked the first match and the row click
// picked it again → two nodes.)
const gcenter = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.center())`));
const beforeClickPick = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await sleep(250);
check('node palette opens (row-click case)', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('stop');
await sleep(150);
const stopRow = await locate('Stop game'); // unique on screen (nothing else on screen shows this label)
await click(stopRow.x, stopRow.y);
await sleep(250);
const afterClickPick = await graphCounts();
check('clicking a palette row adds exactly one node', afterClickPick.nodes === beforeClickPick.nodes + 1, JSON.stringify({ beforeClickPick, afterClickPick }));
check('palette closed after row click', !(await evaluate(`__gm.locate('Search nodes…')`)));
// Undo so 13b finds the same empty spot and the expected graph.
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
// 13b. 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 sleep(250);
check('node palette opens', Boolean(await evaluate(`__gm.locate('Search nodes…')`)));
await typeText('log');
await sleep(100);
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(250);
const after13 = await graphCounts();
s = JSON.parse(await stateJson());
const newNode = s.selection?.kind === 'node' ? await nodeOf(s.selection.nodeId) : null;
check('palette adds a Log node and selects it', after13.nodes === before13.nodes + 1 && newNode?.type === 'Log', JSON.stringify({ after13, newNode }));
await shot('17-node-added');
// 13c. Connect: drag from the upper Move node's exec output onto the Log node's exec input.
const fromPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt('n-move', 'exec', true))`));
const toPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false))`));
await drag(fromPort.x, fromPort.y, toPort.x, toPort.y, 16);
const connected = await graphCounts();
check('dragging port to port connects the nodes', connected.edges === before13.edges + 1, JSON.stringify(connected));
// An exec output must not connect to a data input: dragging onto "message" does nothing.
const dataPort = JSON.parse(await evaluate(`JSON.stringify(__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'message', false))`));
await drag(fromPort.x, fromPort.y, dataPort.x, dataPort.y, 16);
check('incompatible ports are rejected', (await graphCounts()).edges === before13.edges + 1 && s.mode === 'edit');
await shot('18-connected');
// 13d. Delete removes the selected node together with its edge; undo brings both back.
await evaluate(`__gm.state.select({ kind: 'node', assetId: 'mover', nodeId: ${JSON.stringify(newNode.id)} })`);
await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
await sleep(150);
const deleted = await graphCounts();
check('Delete removes the node and its edge', deleted.nodes === before13.nodes && deleted.edges === before13.edges, JSON.stringify(deleted));
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const restored = await graphCounts();
check('undo restores node and edge', restored.nodes === before13.nodes + 1 && restored.edges === before13.edges + 1, JSON.stringify(restored));
// 13e. Play always starts on the Scene tab; switching back to Graph while playing shows
// the live highlight: hold ArrowRight → the exec edge is hot.
const fpsEdit = await evaluate('__gm.fps()');
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
// Single evaluate: the readout refreshes twice a second, so read value and label together.
const fpsLabel = await evaluate(`!!__gm.locate(__gm.fps() + ' fps')`);
check('fps label is visible in the toolbar', fpsLabel === true);
await clickText('▶ Play');
await sleep(200);
check('play switches to the scene tab', (await evaluate('__gm.tab()')) === 'scene');
await sleep(600);
const fpsPlay = await evaluate('__gm.fps()');
check('fps readout keeps running in play mode', typeof fpsPlay === 'number' && fpsPlay > 0, `fps=${fpsPlay}`);
await evaluate(`__gm.showTab('graph')`);
await sleep(100);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(300);
await shot('19-graph-live');
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
// 13f. Variables: "+ Variable ▾" → Number adds "var1" with default 0; undo removes it.
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`);
await sleep(150);
await clickText('+ Variable ▾');
await sleep(150);
await clickText('Number');
await sleep(200);
const vars = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
check('adding a variable declares it on the behavior', vars.join(',') === 'speed,var1', vars.join(','));
await shot('20-variable-added');
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
await sleep(150);
const varsUndone = JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === 'mover').variables.map(v => v.key))`));
check('undo removes the variable', varsUndone.join(',') === 'speed', varsUndone.join(','));
// 13g. The graph strictly mirrors the selection: nothing / a non-behavior → empty editor.
await evaluate(`__gm.state.select(null)`);
await sleep(100);
check('graph editor empties when nothing is selected', (await evaluate(`__gm.graph.current()`)) === null);
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'cam' })`);
await sleep(100);
check('graph editor stays empty for a non-behavior asset', (await evaluate(`__gm.graph.current()`)) === null);
await evaluate(`__gm.state.select({ kind: 'asset', assetId: 'mover' })`);
await sleep(100);
check('graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover');
await evaluate(`__gm.showTab('scene')`);
// 14. Hierarchy / parenting. Give both crates unique names, then drag the blue crate's row
// onto the red crate's row: it becomes a child, keeps its world position (local becomes
// the difference) and is listed right below its parent.
await evaluate(`(() => {
const objs = __gm.state.activeScene.objects;
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
o1.name = 'Red crate'; o1.position = { x: -150, y: 0, z: 0 };
o2.name = 'Blue crate'; o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
__gm.state.select(null);
__gm.state.preview();
})()`);
await sleep(200);
const redRow = await locate('Red crate');
const blueRow = await locate('Blue crate');
await drag(blueRow.x, blueRow.y, redRow.x, redRow.y);
await sleep(200);
const objectJson = (id) => evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === '${id}'))`);
let o2 = JSON.parse(await objectJson('o2'));
check('dropping a row onto another parents it, keeping the world position', o2.parentId === 'o1' && o2.position.x === 270 && o2.position.y === -80, JSON.stringify(o2));
const order = JSON.parse(await evaluate(`JSON.stringify(__gm.hierarchyOrder())`));
check('children are listed directly below their parent', order.indexOf('o2') === order.indexOf('o1') + 1 && order.indexOf('o4') === order.indexOf('o1') + 2, JSON.stringify(order));
await shot('20-parented');
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
await clickText('Red crate');
const xField = await locate('X');
await click(xField.x + 160, xField.y);
await keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 });
await typeText('-50');
await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 });
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
const worldOfO2 = await evaluate(`(() => { const s = __gm.state.activeScene; const child = s.objects.find(x => x.id === 'o2'); const parent = s.objects.find(x => x.id === child.parentId); return JSON.stringify({ x: child.position.x + parent.position.x, y: child.position.y + parent.position.y }); })()`);
check('moving the parent moves the child in world space (local unchanged)', o2.position.x === 270 && worldOfO2 === '{"x":220,"y":-80}', `${JSON.stringify(o2.position)} world=${worldOfO2}`);
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
await evaluate(`__gm.state.select(null)`);
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
await sleep(300);
const playWorld = await evaluate('JSON.stringify(__gm.world())');
const e2 = JSON.parse(playWorld).entities.find((e) => e.objectId === 'o2');
check('play instantiates the parent link and world positions', e2 && e2.parent === 'o1' && e2.worldX === 220 && e2.x === 270, JSON.stringify(e2));
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo move
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); // undo parent
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('undo restores top level and the original local position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
// 14c. Parent via the inspector menu, then "Unparent" from the hierarchy context menu:
// the world position is kept both ways.
await clickText('Blue crate');
await clickText('None ▾');
await sleep(150);
const menuEntry = await locate('Red crate', true); // the popup is drawn last; the hierarchy row has the same text
await click(menuEntry.x, menuEntry.y);
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('inspector Parent menu parents the object', o2.parentId === 'o1' && o2.position.x === 270, JSON.stringify(o2));
const blueRow2 = await locate('Blue crate');
await click(blueRow2.x, blueRow2.y, 'right');
await sleep(150);
await clickText('Unparent (move to top level)');
await sleep(200);
o2 = JSON.parse(await objectJson('o2'));
check('Unparent moves the object to the top level, keeping its world position', o2.parentId === '' && o2.position.x === 120 && o2.position.y === -80, JSON.stringify(o2));
// 15. Colliders. Right-click the red crate → "Add collider": a collider asset sized like the
// crate (100×60) is placed as its child at the centre, selected and drawn in the overlay.
const redRow3 = await locate('Red crate');
await click(redRow3.x, redRow3.y, 'right');
await sleep(150);
await clickText('Add collider');
await sleep(200);
s = JSON.parse(await stateJson());
const colObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null;
const colAsset = colObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${colObj.assetId}'))`)) : null;
check('Add collider creates a collider child sized like the sprite', colObj?.parentId === 'o1' && colAsset?.type === 'collider' && colAsset.props.width === 100 && colAsset.props.height === 60, JSON.stringify({ colObj, colAsset }));
check('collider is drawn in the overlay layer', colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay');
check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box ▾')`)));
await shot('21-collider');
// 15a. Give the blue crate the same collider, wire "On collision enter → Log(other)" into the
// Mover (owner = red crate) and play with the crates overlapping: the log names the blue
// crate exactly once (enter, not stay), and colliders are not rendered in play mode.
await evaluate(`(() => {
const scene = __gm.state.activeScene;
scene.objects.push({ id: 'oc2', assetId: '${colAsset.id}', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o2' });
const mover = __gm.state.project.assets.find(a => a.id === 'mover');
mover.graph.nodes.push({ id: 'n-hit', type: 'OnCollisionEnter', x: 0, y: 600, params: {} }, { id: 'n-hitlog', type: 'Log', x: 340, y: 600, params: {} });
mover.graph.edges.push(
{ id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } },
{ id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } });
scene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap
__gm.state.select(null);
__gm.state.preview();
})()`);
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
await sleep(500);
const collisionWorld = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
check('On collision enter fired once with the other body', collisionWorld && collisionWorld.log.filter((l) => l === 'Blue crate').length === 1, JSON.stringify(collisionWorld?.log));
const spriteCount = await evaluate(`__gm.state.activeScene.objects.filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`);
info = await evaluate('JSON.stringify(__gm.viewportInfo())');
check('colliders are not rendered in play mode', JSON.parse(info).visibleItems === spriteCount, `${info} sprites=${spriteCount}`);
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
// 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (both 100 wide →
// 50 of free travel) and hold ArrowRight: the Mover's "Move and slide" parks the red
// crate flush against the blue one at world x = −100. Making the blue collider a
// trigger lets it pass through instead (the collider is then drawn amber).
const holdRight = async (ms) => {
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await sleep(ms);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
};
const playAgainstBlue = async (trigger) => {
await evaluate(`(() => {
const scene = __gm.state.activeScene;
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ isTrigger: true }' : '{}'};
__gm.state.select(null);
__gm.state.preview();
})()`);
await keyPress('Enter', 'Enter', { modifiers: 2, windowsVirtualKeyCode: 13 });
await sleep(300);
await holdRight(700);
const w = JSON.parse(await evaluate('JSON.stringify(__gm.world())'));
await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 });
await sleep(200);
return w.entities.find((e) => e.objectId === 'o1');
};
const blocked = await playAgainstBlue(false);
check('Move and slide stops the red crate flush against the blue crate\'s solid collider', blocked && Math.abs(blocked.x - -100) < 0.01, `world x=${blocked?.x}`);
const passed = await playAgainstBlue(true);
check('a trigger collider does not block (the crate passes through)', passed && passed.x > -100 + 20, `world x=${passed?.x}`);
await evaluate(`__gm.state.select({ kind: 'object', objectId: 'oc2' })`);
await sleep(200);
await shot('22-trigger-collider');
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {
console.error(error);
failures++;
} finally {
// Wait for chromium to actually exit (a SwiftShader GPU process kept rendering at full CPU
// once when only SIGTERM was sent); escalate to SIGKILL if it does not stop in time.
await new Promise((resolve) => {
if (chrome.exitCode !== null) return resolve();
const timer = setTimeout(() => { chrome.kill('SIGKILL'); }, 5000);
chrome.once('exit', () => { clearTimeout(timer); resolve(); });
chrome.kill('SIGTERM');
});
}
if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n'));
process.exit(failures ? 1 : 0);