← Commit history

Merge PR #172: Render view is read-only; slotSize only on own change; real render errors (fixes #57)

Colby Knox ·2c4ab95b66 ·28d ago ·parent cc5ba0c
4 files changed +126−6
package.json+2−1
@@ -64,7 +64,8 @@   "scripts": {     "install": "./install.sh",     "uninstall": "./uninstall.sh",-    "test:brand": "node test/brand.mjs"+    "test:brand": "node test/brand.mjs",+    "test:render": "node test/render-view.mjs"   },   "needs_sudo": false,   "hero": {
public/js/app.js+16−3
@@ -3,6 +3,13 @@    lands with M4, when the panel code entangled with render() is deleted. */  if (new URLSearchParams(location.search).has('render')) document.body.dataset.render = '1';+// The render view (?render=1) is what Generate and compose screenshot. It is a READ-ONLY consumer of+// the state it loaded: it must never POST state, and never subscribe to changes mid-screenshot.+// Before this it ran the full surface: its slotSize measurement (headless viewport, 1109x1269)+// disagreed with the human's open studio (1109x1227), each POST was broadcast to the other, and the+// two ping-ponged hundreds of times a second, so the renderer's networkidle2 wait never settled and+// every Generate died at the 30 s navigation timeout (2026-09-22, adom/adom-bluegreen).+const RENDER_VIEW = new URLSearchParams(location.search).has('render');  /* console relay: the AI reads GET /console instead of DevTools */ (function () {@@ -39,7 +46,9 @@ const $ = (id) => document.getElementById(id); // owning ai-thread (#39): the page IS that instance's surface, and the human // acting through it acts within that thread's session. const withThread = (o) => Object.assign({ aiThread: (state && state.aiThread) || 'hero-studio ui' }, o || {});-const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) });+const post = (path, body) => RENDER_VIEW+  ? Promise.resolve(new Response('{}', { headers: { 'Content-Type': 'application/json' } }))+  : fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(withThread(body)) }); const send = (patch) => post('state', patch).catch(() => {});  function canonType(t) { return t === 'hardware' ? 'component' : (t || 'app'); }@@ -374,8 +383,11 @@ function render() {     el.dataset.size = w + 'x' + h;     el.textContent = 'shoot the showcase image at ' + w + 'x' + h +       ' (aspect ' + (wb.width / wb.height).toFixed(2) + ') for this placement; click to copy';-    const cur = state.slotSize || {};-    if (cur.w !== w || cur.h !== h) send({ slotSize: { w: w, h: h } });+    // Report only when THIS surface's own measurement changes. Comparing against the server's value+    // made two surfaces at different widths (two tabs, or a tab and the render view) correct each+    // other forever: each POST is broadcast, the other sees a mismatch and posts back.+    const key = w + 'x' + h;+    if (window._slotSent !== key) { window._slotSent = key; send({ slotSize: { w: w, h: h } }); }   });    // showcase content: real screenshot in the window, or full-hero import@@ -1628,6 +1640,7 @@ Promise.all([     const el = document.querySelector(location.hash);     if (el) setTimeout(() => el.scrollIntoView({ block: 'start' }), 300);   }+  if (RENDER_VIEW) return;   // render view: the state it loaded is the state it renders   const es = new EventSource('events');   es.addEventListener('state', e => { state = JSON.parse(e.data); syncPagesIfStale(state); render(); });   es.addEventListener('fit', e => { let m = null; try { m = (JSON.parse(e.data) || {}).max; } catch {} setTimeout(() => runFit(m), 80); });
server.js+10−2
@@ -3,6 +3,14 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); const { spawn, execFile } = require('child_process');+// The useful line of a renderer's stderr. Node ends an uncaught exception with a blank line and+// "Node.js v18.19.1", so taking the LAST line reported the Node version as the error (2026-09-22:+// the human saw "Generate failed: local: Node.js v18.19.1"). Prefer the last line naming an error.+function renderError(buf) {+  const lines = String(buf || '').split('\n').map((l) => l.trim()).filter((l) => l && !/^Node\.js v\d/.test(l));+  const named = lines.filter((l) => /(^|\b)(\w*Error|error)\b[:\s]/.test(l) && !/^at /.test(l));+  return (named.pop() || lines.pop() || '').slice(0, 300);+} const crypto = require('crypto');  // Port: --port flag > HERO_STUDIO_PORT env > 8846 ('auto' = bind an OS-picked@@ -1116,7 +1124,7 @@ function route(req, res, url, p) {       clearTimeout(t);       if (code !== 0 || !fs.existsSync(tmp)) {         res.writeHead(500, { 'Content-Type': 'application/json' });-        return res.end(JSON.stringify({ ok: false, error: eb.trim().split('\n').pop() || 'compose render failed' }));+        return res.end(JSON.stringify({ ok: false, error: renderError(eb) || 'compose render failed' }));       }       // hero-studio#51: the headless render evaluated the SAME linter the       // surface runs; store its findings as fresh-by-construction, so the@@ -1527,7 +1535,7 @@ function route(req, res, url, p) {       child.on('close', (code) => {         clearTimeout(timer);         if (code === 0 && fs.existsSync(outFile)) return cb(null);-        cb(errBuf.trim().split('\n').pop() || a.label + ' render exited ' + code);+        cb(renderError(errBuf) || a.label + ' render exited ' + code);       });     };     const tryNext = () => {
test/render-view.mjsadded+98
@@ -0,0 +1,98 @@+#!/usr/bin/env node+// Render-view tests: node test/render-view.mjs+//+// Generate and compose screenshot /?render=1 with puppeteer, waiting for networkidle2. On+// 2026-09-22 every Generate died at the 30 s navigation timeout: the render view ran the full+// interactive surface, measured the showcase slot at its own headless viewport (1109x1269), and+// POSTed it; the human's open studio (1109x1227) saw a mismatch and POSTed back; each POST was+// broadcast; the two ping-ponged hundreds of times a second (poseRev reached 43,988) and the+// network never went idle. The error the human saw was "Node.js v18.19.1", the last stderr line.+//+// This starts a real server on a spare port, opens an interactive surface at one width, then+// loads the render view at another, and asserts:+//   - the render view makes no state-changing POST,+//   - it reaches networkidle2 while the other surface is open,+//   - no slotSize ping-pong: the server sees at most one slotSize POST per surface,+//   - renderError() extracts the real error line, not the Node.js version footer.+import { spawn } from 'child_process';+import net from 'net';+import fs from 'fs';+import path from 'path';+import { fileURLToPath } from 'url';+import { loadPuppeteer, findChrome } from '../doctor.mjs';++const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');+// A free port picked by the OS: a fixed one collides with whatever else runs on the box.+const PORT = Number(process.env.RV_PORT) || await new Promise((res) => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => res(p)); }); });+let pass = 0; const fails = [];+const ok = (name, cond, detail) => { if (cond) { pass++; console.log('  ok   ' + name); } else { fails.push(name); console.log('  FAIL ' + name + (detail ? ': ' + detail : '')); } };++// renderError, pure+const src = fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8');+const fn = src.match(/function renderError[\s\S]*?\n}\n/);+ok('server.js defines renderError', !!fn);+if (fn) {+  const renderError = new Function(fn[0] + '; return renderError;')();+  const eb = 'file:///x/Deferred.js:57\n  this.#timeoutError = new TimeoutError(opts.message);\n        ^\n\nTimeoutError: Navigation timeout of 30000 ms exceeded\n    at new Deferred (file:///x)\n\nNode.js v18.19.1\n';+  ok('renderError names the real error, not the Node version', renderError(eb) === 'TimeoutError: Navigation timeout of 30000 ms exceeded', renderError(eb));+  ok('renderError falls back to the last plain line', renderError('chrome exited\n\nNode.js v22.1.0\n') === 'chrome exited', renderError('chrome exited\n\nNode.js v22.1.0\n'));+}++const server = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', String(PORT), '--ai-thread', 'render-view test', '--ttl', '1'],+  { env: { ...process.env, HERO_STUDIO_PORT: String(PORT) }, stdio: ['ignore', 'pipe', 'pipe'] });+const base = 'http://localhost:' + PORT;+let up = false;+for (let i = 0; i < 50 && !up; i++) { try { const r = await fetch(base + '/state'); up = r.ok && 'slotSize' in (await r.json()); } catch {} if (!up) await new Promise(r => setTimeout(r, 200)); }+ok('a hero-studio server answers on :' + PORT, up);+if (!up) { server.kill(); console.log('\n' + pass + ' passed, ' + (fails.length) + ' failed'); process.exit(1); }++const puppeteer = await loadPuppeteer();+const browser = await puppeteer.launch({ executablePath: findChrome(), headless: 'new', args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });+try {+  // Another surface that measures the showcase slot differently (the human's studio tab, at its+  // own width), modeled as the old page logic: follow the event stream and POST its own slotSize+  // whenever the server's differs. Any render view that posts state will ping-pong with it.+  const OTHER = { w: 1109, h: 1227 };+  let otherPosts = 0; const ctl = new AbortController();+  (async () => {+    try {+      const r = await fetch(base + '/events', { signal: ctl.signal });+      const dec = new TextDecoder(); let buf = '';+      for await (const chunk of r.body) {+        buf += dec.decode(chunk, { stream: true });+        let k;+        while ((k = buf.indexOf('\n\n')) >= 0) {+          const ev = buf.slice(0, k); buf = buf.slice(k + 2);+          if (!/^event: state/m.test(ev)) continue;+          const st = JSON.parse((ev.match(/^data: (.*)$/m) || [])[1] || '{}');+          const cur = st.slotSize || {};+          if (cur.w !== OTHER.w || cur.h !== OTHER.h) {+            otherPosts++;+            fetch(base + '/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aiThread: 'render-view test', slotSize: OTHER }) }).catch(() => {});+          }+        }+      }+    } catch {}+  })();+  await new Promise(r => setTimeout(r, 800));++  // the render view at the renderer's own viewport+  const view = await browser.newPage();+  await view.setViewport({ width: 1680, height: 1080, deviceScaleFactor: 2 });+  const posts = [];+  view.on('request', (r) => { if (r.method() === 'POST' && !/\/console$/.test(r.url())) posts.push(r.url()); });+  const t0 = Date.now(); let idle = true;+  try { await view.goto(base + '/?render=1', { waitUntil: 'networkidle2', timeout: 10000 }); } catch (e) { idle = false; }+  ok('render view reaches networkidle2 with another surface open', idle, 'timed out after ' + (Date.now() - t0) + ' ms');+  await new Promise(r => setTimeout(r, 2000));+  ok('render view makes no state-changing POST', posts.length === 0, posts.slice(0, 3).join(', '));+  ok('render view still renders the hero', !!(await view.$('#hero')));++  ctl.abort();+  ok('no slotSize ping-pong with a surface that measures differently', otherPosts <= 2, otherPosts + ' corrective posts from the other surface');+} finally {+  await browser.close();+  server.kill();+}+console.log('\n' + pass + ' passed, ' + fails.length + ' failed');+process.exit(fails.length ? 1 : 0);