Files
feuerwerk/tools/e2e.mjs
T
rootandClaude Fable 5.1 aaa8154adf Camera view frame, viewport clipping, overlay layer for editor symbols
- Camera assets get width/height properties; play mode renders only the
  camera frame (letterboxed, masked, culled) and nothing without a camera
- Remove demo arrow-key/camera-follow systems from play mode
- Clip world and gizmos to the viewport (selection frame no longer leaks
  over the left panel)
- Blur focused widget on pointerdown outside it so inspector edits commit
  before selection changes
- Separate objects/overlay layers so camera symbols always render on top
- Add tools/seed-demo.mjs for reproducible E2E runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 11:13:08 +02:00

297 lines
15 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) => {
const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)})`);
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), arrow keys do nothing, 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(300);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
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`);
check('play did not write back to the model', o2AfterPlay === xBeforePlay, `${o2AfterPlay} vs ${xBeforePlay}`);
// 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')) === 2);
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')) === 3);
// 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 === 4, `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');
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 {
chrome.kill('SIGTERM');
}
if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n'));
process.exit(failures ? 1 : 0);