642 lines
37 KiB
JavaScript
642 lines
37 KiB
JavaScript
#!/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')) === 5);
|
||
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')) === 6);
|
||
|
||
// 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 === 7, `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())`));
|
||
// o1's subtree: o2 (list order before o4) with its collider child, then o4, then o1's collider.
|
||
const o1At = order.indexOf('o1');
|
||
check('children are listed directly below their parent', JSON.stringify(order.slice(o1At + 1, o1At + 5)) === JSON.stringify(['o2', 'oc2', 'o4', 'oc1']), 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.
|
||
// The seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one
|
||
// is undone again afterwards to keep exactly one collider per body for 15a/15b.
|
||
const objectsBeforeCollider = await evaluate('__gm.state.activeScene.objects.length');
|
||
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');
|
||
await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
|
||
await sleep(200);
|
||
const colliderAssetGone = colAsset ? !(await evaluate(`__gm.state.project.assets.some(a => a.id === '${colAsset.id}')`)) : false;
|
||
check('undo removes the added collider object and its asset', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeCollider && colliderAssetGone);
|
||
|
||
// 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
|
||
// the crates overlapping (each carries its seeded collider): 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;
|
||
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 (red 100 wide, the
|
||
// blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move
|
||
// (Collide with solids) parks the red crate flush against the blue one at world x = −120.
|
||
// Making the blue collider a trigger lets it pass through instead (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 ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'};
|
||
__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 with "Collide with solids" stops the red crate flush against the blue crate\'s solid collider', blocked && Math.abs(blocked.x - -120) < 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 > -120 + 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);
|