#!/usr/bin/env node // Venue gate loop driver — headless Chromium (SwiftShader WebGL2) against vite dev. // Renders as little as possible per step; every wait has a heartbeat log so a // hang is visible in seconds, not after a 15-minute timeout. // // Usage: // node tools/venue-check.js --quick fast gates, phases 9+12 only // node tools/venue-check.js --quick --phase=9 fast gates, one phase // node tools/venue-check.js --full + slow phase 12 + variety/songs/decompose (small N) // node tools/venue-check.js --filmstrip venue=1 filmstrip, 2 songs default // node tools/venue-check.js --filmstrip --song=centre one song // node tools/venue-check.js --filmstrip --venue=0 no-venue baseline for comparison // node tools/venue-check.js --gallery venue gallery, 2x2 default // node tools/venue-check.js --gallery --venue-sel=Chamber --viz=Kaleido\ Tunnel // node tools/venue-check.js --all everything, small defaults // // Flags: // --phase=9,12 phase filter for the checks steps (default quick: 9,12; full: 12) // --venue=1 filmstrip venue flag (default 1; use 0 for the no-venue baseline) // --venue-sel=NAME pin one venue in every gallery cell (default: census) // --viz=NAME pin one viz in every gallery cell (default: census) // --song=a,b filmstrip song subset (default: centre,drone) // --rows=N --cols=N gallery grid (default: 2x2) // --seeds=N variety/songs/decompose counts (default: 4) // --timeout=N seconds per wait (default: 150) // Exits non-zero if any gate fails. Screenshots to logs/venue/. import { chromium } from 'playwright-core'; import { mkdirSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, '..'); const BASE = process.env.VENUE_BASE || 'http://127.0.0.1:5180'; const OUT = resolve(root, 'logs/venue'); mkdirSync(OUT, { recursive: true }); const argv = process.argv.slice(2); const has = (n) => argv.includes(n); const arg = (n, fb) => { // Supports both `--flag value` and `--flag=value`. const eq = argv.find((a) => a.startsWith(`${n}=`)); if (eq) return eq.slice(n.length + 1); const i = argv.indexOf(n); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fb; }; const TIMEOUT_MS = Number(arg('--timeout', 150)) * 1000; const PHASE_QUICK = arg('--phase', '9,12'); const PHASE_FULL = arg('--phase', '12'); const SONGS = arg('--song', 'centre,drone'); const VENUE_FLAG = arg('--venue', '1'); const VENUE_SEL = arg('--venue-sel', ''); const VIZ_SEL = arg('--viz', ''); const ROWS = Number(arg('--rows', 2)); const COLS = Number(arg('--cols', 2)); const SEEDS = Number(arg('--seeds', 4)); const want = { quick: has('--quick') || has('--all') || (!has('--full') && !has('--filmstrip') && !has('--gallery')), full: has('--full') || has('--all'), filmstrip: has('--filmstrip') || has('--all'), gallery: has('--gallery') || has('--all'), }; const t0 = Date.now(); const ts = () => `${((Date.now() - t0) / 1000).toFixed(0).padStart(4)}s`; const log = (...a) => console.log(`[${ts()}]`, ...a); const results = { ok: true, steps: [] }; function step(name, ok, detail) { results.steps.push({ name, ok, detail }); if (!ok) results.ok = false; log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${String(detail).slice(0, 300)}` : ''}`); } // Fail fast when the dev server is unreachable instead of hanging in goto. async function preflight() { const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 10000); try { const r = await fetch(`${BASE}/checks.html`, { signal: ctl.signal }); log(`preflight ${BASE} -> ${r.status}`); return r.ok; } catch (e) { log(`preflight ${BASE} UNREACHABLE: ${String(e && e.message || e).slice(0, 120)}`); return false; } finally { clearTimeout(timer); } } // Poll `probe` (runs in page) until it reports {done:true}; heartbeat every 10s. async function waitDone(page, probe, label, timeoutMs = TIMEOUT_MS) { const start = Date.now(); let lastBeat = 0; for (;;) { const st = await page.evaluate(`(${probe})()`).catch((e) => ({ evalFail: String(e).slice(0, 80) })); if (st && st.done) return st; const elapsed = Date.now() - start; if (elapsed > timeoutMs) { await page.screenshot({ path: resolve(OUT, `fail-${label}-${Date.now()}.png`) }).catch(() => {}); throw new Error(`${label}: timeout after ${Math.round(elapsed / 1000)}s (last: ${JSON.stringify(st).slice(0, 200)})`); } if (elapsed - lastBeat > 10000) { lastBeat = elapsed; log(`${label}: +${Math.round(elapsed / 1000)}s`, JSON.stringify(st).slice(0, 160)); } await new Promise((r) => setTimeout(r, 2000)); } } const checksProbe = `() => ({ done: window.__CHECKS_DONE__ === true, info: (document.getElementById('summary')?.textContent || '').slice(0, 100) + ' | rows=' + document.querySelectorAll('#results .row').length, })`; const filmProbe = `() => ({ done: window.__FILMSTRIP_DONE__ === true, info: 'rows=' + document.querySelectorAll('#out .row').length + ' ' + (document.getElementById('status')?.textContent || '').slice(0, 80), })`; const galleryProbe = `() => { const s = document.getElementById('status'); const t = s ? s.textContent : ''; return { done: /^done/.test(t), info: t.slice(0, 120) + ' | cards=' + document.querySelectorAll('#out .card').length }; }`; let browser = null; try { log(`args phases=${PHASE_QUICK}/${PHASE_FULL} songs=${SONGS} venue=${VENUE_FLAG} grid=${ROWS}x${COLS} seeds=${SEEDS} timeout=${TIMEOUT_MS / 1000}s base=${BASE}${VENUE_SEL ? ` venueSel=${VENUE_SEL}` : ''}${VIZ_SEL ? ` viz=${VIZ_SEL}` : ''}`); if (!await preflight()) throw new Error('dev server unreachable — start `npm run dev` first'); log('launching chromium (headless shell, swiftshader, --no-sandbox)…'); browser = await chromium.launch({ args: ['--no-sandbox', '--disable-gpu', '--enable-unsafe-swiftshader'] }); const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); page.setDefaultTimeout(TIMEOUT_MS); page.setDefaultNavigationTimeout(30000); page.on('pageerror', (e) => log('[pageerror]', String(e && e.message || e).slice(0, 200))); page.on('console', (m) => { if (m.type() === 'error') log('[console.error]', m.text().slice(0, 200)); else if (m.type() === 'warning' && /WebGL/i.test(m.text())) log('[console.webgl]', m.text().slice(0, 160)); }); page.on('requestfailed', (r) => log('[reqfail]', r.url().slice(0, 100), r.failure()?.errorText || '')); async function gotoChecks(query, label) { const url = `${BASE}/checks.html?${query}`; log(`${label}: goto ${query}`); await page.goto(url, { waitUntil: 'commit' }); // 'load' never fires while GL contexts are held const st = await waitDone(page, checksProbe, label); log(`${label}: finished — ${st.info}`); return page.evaluate(() => window.__CHECKS__); } if (want.quick) { const label = 'quick'; const c = await gotoChecks(`slow=0&phase=${PHASE_QUICK}`, label); const failed = (c.results || []).filter((r) => !r.pass && !r.manual); step(`fast gates (phases ${PHASE_QUICK})`, failed.length === 0, `${c.summary.passed}/${c.summary.total} passed` + (failed.length ? ` · FAIL: ${failed.map((r) => `P${r.phase}/${r.name} — ${(r.detail || '').slice(0, 120)}`).join(' · ')}` : '')); } if (want.full) { const c = await gotoChecks(`phase=${PHASE_FULL}&slow=1`, 'full-p12'); const failed = (c.results || []).filter((r) => !r.pass && !r.manual); step(`slow phase ${PHASE_FULL}`, failed.length === 0, failed.length ? `FAIL: ${failed.map((r) => `${r.name}: ${(r.detail || '').slice(0, 150)}`).join(' · ')}` : `${c.summary.passed}/${c.summary.total} passed`); for (const q of [`variety=1&seeds=${SEEDS}&library=0`, `songs=1&count=${SEEDS}`, `decompose=1&count=${SEEDS}`]) { const r = await gotoChecks(q, `full-${q.split('&')[0].split('=')[0]}`); step(`checks ${q.split('&')[0]}`, !!r.ok, (r.headline || r.lines?.slice(-3).join(' | ') || '').slice(0, 200)); } } if (want.filmstrip) { const label = `filmstrip-v${VENUE_FLAG}`; const EVERY = arg('--every', '30'); const DUR = arg('--duration', '180'); const url = `${BASE}/filmstrip.html?venue=${VENUE_FLAG}&song=${encodeURIComponent(SONGS)}&every=${EVERY}&duration=${DUR}`; log(`${label}: goto venue=${VENUE_FLAG} song=${SONGS} every=${EVERY} duration=${DUR}`); await page.goto(url, { waitUntil: 'commit' }); await waitDone(page, filmProbe, label); const rows = await page.evaluate(() => window.__FILMSTRIP__.rows.map((r) => ({ name: r.name, direction: +r.direction?.toFixed(3), drift: +r.drift?.toFixed(3), agitation: +r.agitation?.toFixed(3), error: r.error || null, venue: r.look?.venue ? `${r.look.venue.name}(${r.look.venue.archetype})` : 'none', director: r.director || '?', // Per-probe story: what was on screen when each frame was taken. probes: (r.shots || r.frames || []).map((f) => { const t = Math.round(f.time); const mm = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`; return `${mm} ${f.kind}/${f.scene}${f.moment ? `:${f.moment}` : ''} j${(+f.journey || 0).toFixed(2)}`; }), }))); const flat = rows.filter((r) => !r.error && r.direction < 0.15); const agitated = rows.filter((r) => (r.agitation ?? 0) >= 0.70); const broken = rows.filter((r) => r.error); step(`filmstrip venue=${VENUE_FLAG} (${SONGS})`, flat.length === 0 && broken.length === 0, `${rows.length} songs · flat: ${flat.map((r) => r.name).join(',') || 'none'}` + ` · agitated: ${agitated.map((r) => r.name).join(',') || 'none'}` + (broken.length ? ` · BROKEN: ${broken.map((r) => r.name).join(',')}` : '')); for (const r of rows) { log(` ${r.name.padEnd(8)} dir ${r.direction} drift ${r.drift} agit ${r.agitation} [${r.venue} · ${r.director}]${r.error ? ' ERR ' + r.error : ''}`); for (const p of r.probes || []) log(` ${r.name.padEnd(8)} ${p}`); } } if (want.gallery) { const label = 'gallery'; const qp = new URLSearchParams({ rows: String(ROWS), cols: String(COLS) }); if (VENUE_SEL) qp.set('venueSel', VENUE_SEL); if (VIZ_SEL) qp.set('viz', VIZ_SEL); const url = `${BASE}/venue-gallery.html?${qp}`; log(`${label}: goto rows=${ROWS} cols=${COLS}`); await page.goto(url, { waitUntil: 'commit' }); const st = await waitDone(page, galleryProbe, label); log(`${label}: finished — ${st.info}`); const shot = resolve(OUT, `venue-gallery-${Date.now()}.png`); await page.screenshot({ path: shot, fullPage: true }); const meta = await page.evaluate(() => [...document.querySelectorAll('#out .card .meta')].map((e) => e.textContent)); const venuesSeen = [...new Set(meta.map((m) => m.split('·')[0]?.trim()))]; step(`venue gallery ${ROWS}x${COLS} census`, venuesSeen.length >= 2, `${meta.length} cells · venues: ${venuesSeen.join(', ')} · shot ${shot}`); } } catch (err) { step('harness', false, String(err && err.stack || err).slice(0, 300)); } finally { await browser?.close().catch(() => {}); log('browser closed'); } log(JSON.stringify({ ok: results.ok, steps: results.steps.map((s) => ({ name: s.name, ok: s.ok })) })); process.exit(results.ok ? 0 : 1);