Engine additions that the example game needed: - `enabled` as a shared property of every placeable asset (default on, an override on the scene object). A switched-off object is not drawn, does not collide and its behavior does not run - and neither does anything parented below it. Disabled objects are now hidden in the scene view as well, so the editor shows what the game shows; they stay in the hierarchy panel, which is where they are selected and switched back on. - Asset type `text` (text, fontSize, color, align), rendered in scene and game. - Pointer input in the game view: `On click`, `Mouse position`, `Mouse button`, `Is hovered`, hit-tested against colliders, else sprite/text footprints. - New nodes: Set/Is enabled, Set text, Is asset, Name of, Equals, To text, Join text, Floor/Ceil/Round, Modulo, Random, Time, Restart game. Tooling: shared CDP harness (tools/harness.mjs), central project registry with a guard that keeps destructive scripts off the playground (tools/projects.mjs), plus smoke suites for the demo scene and the jump-and-run game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
6.2 KiB
JavaScript
145 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Shared headless-Chromium harness for the check scripts (e2e.mjs, smoke-demo.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));
|
||
|
||
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');
|
||
}
|
||
|
||
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));
|
||
});
|
||
|
||
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/${shotPrefix}-${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 body({
|
||
cdp, ws, projectId, baseUrl, consoleLog, check, sleep,
|
||
evaluate, locate, shot, mouse, click, clickText, drag, keyPress, typeText, api, sceneOf, stateJson,
|
||
});
|
||
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'));
|
||
process.exit(failures ? 1 : 0);
|
||
}
|