app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
#!/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')));
const booted = await view.evaluate(() => typeof $ === 'function' && typeof render === 'function');
// A pose turns the review tour on, and the tour spotlights the first
// unchecked item (the title) with an outline. With nothing ticked, that
// outline used to be drawn in the render view and baked into the PNG.
await fetch(base + '/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aiThread: 'render-view test', tour: true, verify: true, confirm: {} }) });
const tv = await browser.newPage();
await tv.setViewport({ width: 1680, height: 1080, deviceScaleFactor: 1 });
await tv.goto(base + '/?render=1', { waitUntil: 'networkidle2', timeout: 15000 }).catch(() => {});
await new Promise(r => setTimeout(r, 1200));
const ring = await tv.evaluate(() => {
const lit = [...document.querySelectorAll('.tour-glow')].map(e => e.id || e.className);
const t = document.getElementById('el-title');
const cs = t && getComputedStyle(t);
return { lit, outline: cs ? cs.outlineStyle + ' ' + cs.outlineWidth : 'no title', shadow: cs ? cs.boxShadow : '' };
});
ok('the review-tour spotlight never appears in the render view (tour on, nothing ticked)', ring.lit.length === 0, JSON.stringify(ring));
ok('the render view draws no outline around the title', !/solid/.test(ring.outline), JSON.stringify(ring));
ok('the page scripts parse and boot (app.js defines $ and render)', booted);
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);