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>
This commit is contained in:
1 parent
3234eb480f
commit
1eb28314cf
189 files changed
+6231
-5543
No files matched your search
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in new issue
Block a user