Files
feuerwerk/tools/lib/harness.mjs
T
rootandClaude Fable 5.1 1eb28314cf Refactoring: one file per node, consistent comments, shared helpers, split large modules
- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with
  shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry
  separated, writeTyped() replaces duplicated typed writes
- Comments: one style (JSDoc on non-obvious exports, // for decisions and
  traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars
- Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts,
  commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code
  removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe,
  Input.axis, setMode, titleText, World.worldPosition, unused ctor params)
- Editor: Inspector split into inspector/ sections, App.ts into Editor.ts +
  debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared
  "Add collider" action, unified type labels/swatches
- UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers,
  Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes
- Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib
  (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections
- Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles

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

241 lines
10 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.
/**
* Headless-Chromium harness for the check scripts (e2e.mjs, smoke-jumprun.mjs).
*
* Starts chromium with remote debugging, opens a CDP session and hands a set of helpers to
* the body function. Widgets are located through `window.__gm.locate(text)` (installed by
* the editor), so checks never depend on pixel coordinates.
*
* await runChecks({ projectId, baseUrl, port, shotPrefix }, async (t) => { ... });
*
* The harness always waits for chromium to actually exit (a SwiftShader GPU process kept
* rendering at full CPU once when only SIGTERM was sent) and exits with code 1 on failures.
*/
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Structural equality for the small plain values the checks compare (arrays of ids, selections). */
export const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
/** Keys the checks press, with the codes Chromium needs to route them like a real keyboard. */
const KEYS = {
Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 },
Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 },
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
a: { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65 },
z: { key: 'z', code: 'KeyZ', windowsVirtualKeyCode: 90 },
};
/** CDP modifier bitmask: 2 = Ctrl, 8 = Shift. */
const modifierBits = ({ ctrl = false, shift = false } = {}) => (ctrl ? 2 : 0) | (shift ? 8 : 0);
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);
}
}
async function waitForChrome(port) {
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');
}
/** The toolbox handed to a check body – everything a step needs to drive and inspect the editor. */
function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value;
/** Evaluates an expression in the page and returns it as plain JSON; `undefined` comes back as null. */
const json = async (expression) => JSON.parse(await evaluate(`JSON.stringify((${expression}) ?? null)`));
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/${shotPrefix}-${name}.png`, Buffer.from(data, 'base64'));
};
// --- mouse ---------------------------------------------------------------------------------
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', modifiers = {}) => {
const extra = { modifiers: modifierBits(modifiers) };
await mouse('mouseMoved', x, y);
await mouse('mousePressed', x, y, button, extra);
await mouse('mouseReleased', x, y, button, extra);
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);
};
// --- keyboard ------------------------------------------------------------------------------
const keyEvent = (type, name, modifiers) => {
const key = KEYS[name];
if (!key) throw new Error(`unknown key "${name}"`);
return cdp.send('Input.dispatchKeyEvent', { type, ...key, modifiers: modifierBits(modifiers) });
};
/** Press and release a key from `KEYS`, optionally with `{ ctrl, shift }`. */
const press = async (name, modifiers = {}) => {
await keyEvent('keyDown', name, modifiers);
await keyEvent('keyUp', name, modifiers);
};
/** Hold a key for `ms` milliseconds (arrow keys while playing). */
const holdKey = async (name, ms) => {
await keyEvent('keyDown', name);
await sleep(ms);
await keyEvent('keyUp', name);
};
const typeText = async (text) => {
for (const ch of text) await cdp.send('Input.insertText', { text: ch });
};
const undo = async () => {
await press('z', { ctrl: true });
await sleep(200);
};
const redo = async () => {
await press('z', { ctrl: true, shift: true });
await sleep(200);
};
// --- editor state --------------------------------------------------------------------------
/** Selection, mode, save status and notice – the facts most checks look at. */
const state = () => json('{ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice }');
const objects = () => json('__gm.state.activeScene.objects');
const object = (id) => json(`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(id)})`);
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})`);
const world = () => json('__gm.world()');
const entity = async (objectId) => (await world()).entities.find((e) => e.objectId === objectId);
const select = (selection) => evaluate(`__gm.state.select(${JSON.stringify(selection)})`);
/**
* Runs `body` (JavaScript source) against the page's model and previews the result, the way a
* scrub or a command would – for putting the scene into a known state between checks.
*/
const mutate = async (body) => {
await evaluate(`(() => { ${body}; __gm.state.preview(); })()`);
await sleep(200);
};
const play = async () => {
await clickText('▶ Play');
await sleep(300);
};
const stop = async () => {
await press('Escape');
await sleep(300);
};
// --- server ----------------------------------------------------------------------------------
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 exceptions = () => consoleLog.filter((l) => l.startsWith('EXCEPTION'));
/** Opens the project and asserts the page came up cleanly with the test hook installed. */
const navigate = async () => {
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
await sleep(4000);
check('page loaded without exceptions', exceptions().length === 0, exceptions().join(' | '));
check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)')));
};
return {
cdp, projectId, baseUrl, consoleLog, exceptions, check, sleep,
evaluate, json, locate, shot,
mouse, click, clickText, drag,
press, holdKey, typeText, undo, redo,
state, objects, object, asset, world, entity, select, mutate, play, stop,
api, sceneOf, navigate,
};
}
export async function runChecks(options, body) {
const { projectId, baseUrl, port = 9333, shotPrefix = 'gm-e2e', width = 1280, height = 800 } = options;
const chrome = spawn('chromium', [
'--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader',
`--remote-debugging-port=${port}`, `--window-size=${width},${height}`, '--hide-scrollbars', 'about:blank',
], { stdio: 'ignore' });
const consoleLog = [];
let failures = 0;
function check(name, ok, detail = '') {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`);
if (!ok) failures++;
}
try {
await waitForChrome(port);
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, height, 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));
});
await body(makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }));
ws.close();
} catch (error) {
console.error(error);
failures++;
} finally {
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'));
console.log(failures ? `${failures} FAILED` : 'ALL PASSED');
process.exit(failures ? 1 : 0);
}