master
Colby Knox 0.22.0: desktop and Windows support (#60-#66) a2dcc31 19d ago
#!/usr/bin/env node
// Renderer-status tests (hero-studio#58): node test/renderer.mjs
//
// A package update replaced the package directory, node_modules included, so
// puppeteer-core vanished under a running studio and the human's Generate
// walked through two failing legs with nothing at the gate saying why. Now:
//   - puppeteer-core is looked for under ~/.cache/hero-studio first (where
//     install.sh puts it, outside the package dir),
//   - the pin follows this Node (24.x below 22.12),
//   - the server carries state.renderer, refreshed before every Generate,
//   - Generate refuses with RENDERER_NOT_READY and the fix when nothing can
//     render, and stops refusing the moment the pieces are back, no restart.
import { spawn } from 'child_process';
import net from 'net';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { fileURLToPath } from 'url';
import { createRequire } from 'module';

const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const RP = createRequire(import.meta.url)(path.join(ROOT, 'renderer-paths.cjs'));
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 : '')); } };

// the pin follows Node
ok('pin: Node 18 gets puppeteer-core 24', RP.puppeteerPin('18.19.1') === '24');
ok('pin: Node 22.11 gets 24', RP.puppeteerPin('22.11.9') === '24');
ok('pin: Node 22.12 gets latest', RP.puppeteerPin('22.12.0') === 'latest');
ok('pin: Node 24 gets latest', RP.puppeteerPin('24.1.0') === 'latest');
ok('install command targets the cache dir, not the package dir', RP.puppeteerInstallCommand().includes('--prefix "' + RP.RENDERER_HOME + '"') && !RP.puppeteerInstallCommand().includes(ROOT));

// an isolated box: nothing resolves, then the cache install appears
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-renderer-'));
const RH = path.join(tmp, 'rh');
const chromeStub = path.join(tmp, 'chrome');
const env = { ...process.env, HOME: tmp, HERO_STUDIO_RENDERER_HOME: RH, HERO_STUDIO_RENDERER_ISOLATED: '1', PUPPETEER_CORE: '', CHROME: chromeStub, VSCODE_PROXY_URI: '', HERO_RENDERER: '' };
delete env.PUPPETEER_CORE; delete env.VSCODE_PROXY_URI; delete env.HERO_RENDERER;
const probe = (code) => new Promise((res) => {
  const c = spawn(process.execPath, ['-e', code], { env, cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
  let out = ''; c.stdout.on('data', (d) => { out += d; }); c.on('close', () => res(out.trim()));
});
const st0 = JSON.parse(await probe("console.log(JSON.stringify(require('./renderer-paths.cjs').quickStatus()))"));
ok('isolated: local renderer not ok, names puppeteer-core', !st0.local.ok && /puppeteer-core/.test(st0.local.why), JSON.stringify(st0.local));
ok('isolated: pup not ok without a proxy URL', !st0.pup.ok);
const entry = path.join(RH, 'node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(entry, 'export default {};\n');
fs.writeFileSync(path.join(RH, 'node_modules/puppeteer-core/package.json'), JSON.stringify({ name: 'puppeteer-core', version: '24.0.0', type: 'module', main: 'lib/puppeteer/puppeteer-core.js' }));
fs.writeFileSync(chromeStub, '#!/bin/sh\n');
const st1 = JSON.parse(await probe("console.log(JSON.stringify(require('./renderer-paths.cjs').quickStatus()))"));
ok('the cache install is found first, local ok', st1.local.ok && st1.local.puppeteer === entry, JSON.stringify(st1.local));

// the server: state.renderer, refusal, recovery without restart
fs.rmSync(entry); fs.rmSync(chromeStub);
const PORT = await new Promise((res) => { const s = net.createServer().listen(0, () => { const p = s.address().port; s.close(() => res(p)); }); });
const server = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', String(PORT), '--ai-thread', 'renderer test', '--ttl', '1'],
  { env: { ...env, HERO_STUDIO_PORT: String(PORT) }, cwd: ROOT, 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; } catch {} if (!up) await new Promise(r => setTimeout(r, 200)); }
ok('a hero-studio server answers on :' + PORT, up);
try {
  const st = await (await fetch(base + '/state')).json();
  ok('state.renderer is carried and reads not ready', st.renderer && !st.renderer.local.ok && !st.renderer.pup.ok, JSON.stringify(st.renderer));
  ok('state.renderer names the running version', st.renderer && st.renderer.running === JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version);
  const gen = async () => (await fetch(base + '/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ aiThread: 'renderer test', fromGate: true }) })).json();
  const g0 = await gen();
  ok('Generate refuses with RENDERER_NOT_READY before any render leg', g0.ok === false && g0.errorCode === 'RENDERER_NOT_READY', JSON.stringify(g0).slice(0, 200));
  ok('the refusal carries the install command', /npm install --prefix/.test(g0.hint || ''), g0.hint);
  // the pieces come back (what install.sh or the human's npm install does); no restart
  fs.writeFileSync(entry, 'export default {};\n');
  fs.writeFileSync(chromeStub, '#!/bin/sh\n');
  const g1 = await gen();
  ok('Generate stops refusing once the pieces are back, without a restart', g1.errorCode !== 'RENDERER_NOT_READY', JSON.stringify(g1).slice(0, 200));
  const st2 = await (await fetch(base + '/state')).json();
  ok('state.renderer flipped to local ok', st2.renderer && st2.renderer.local.ok, JSON.stringify(st2.renderer));
} finally {
  server.kill();
  fs.rmSync(tmp, { recursive: true, force: true });
}
console.log('\n' + pass + ' passed, ' + fails.length + ' failed');
process.exit(fails.length ? 1 : 0);