#!/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 [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 [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; 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')) === 3); 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')) === 4); // 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 === 5, `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. await clickText('▶ Play'); await sleep(200); check('play switches to the scene tab', (await evaluate('__gm.tab()')) === 'scene'); 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')`); 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);