← back to Dead Agentabrams

verification-reports/tk11100-localhead/run-localhead.js

235 lines

#!/usr/bin/env node
/*
 * TK-11100 — Local HEAD delta lane
 * Proves room/index.html from git commit 5bb689d (blob 6aaa35e, 17-show catalog)
 * served from a LOOPBACK-ONLY (127.0.0.1) ephemeral static server, exercised in
 * Playwright Chromium + WebKit at desktop + mobile.
 *
 * Owns only: verification/tk11100-localhead/**  (no source edits, no deploy).
 * Self-contained: starts + STOPS its own server (cleanup proof in results).
 */
const http = require('http');
const fs = require('fs');
const path = require('path');
const PW = '/Users/macstudio3/.npm-global/lib/node_modules/playwright';
const { chromium, webkit, devices } = require(PW);

const ROOT = __dirname;                    // verification/tk11100-localhead
const SRV = path.join(ROOT, 'srv');        // serving root (contains /room/index.html)
const SHOTS = path.join(ROOT, 'shots');
const RESULTS = path.join(ROOT, 'results');
for (const d of [SHOTS, RESULTS]) fs.mkdirSync(d, { recursive: true });

const TARGET_COMMIT = '5bb689d1d98eff41c32ea4cc6e51fed608d25f6a';
const TARGET_BLOB = '6aaa35ea775e2a338cb4b04eec5b0016f5c0d213';

const MIME = { '.html':'text/html', '.png':'image/png', '.js':'text/javascript',
  '.css':'text/css', '.svg':'image/svg+xml', '.json':'application/json' };

function startServer() {
  return new Promise((resolve) => {
    const server = http.createServer((req, res) => {
      let p = decodeURIComponent(req.url.split('?')[0]);
      if (p.endsWith('/')) p += 'index.html';
      const fp = path.join(SRV, p);
      if (!fp.startsWith(SRV)) { res.writeHead(403); return res.end('forbidden'); }
      fs.readFile(fp, (err, buf) => {
        if (err) { res.writeHead(404); return res.end('not found'); }
        res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
        res.end(buf);
      });
    });
    // LOOPBACK ONLY: bind explicitly to 127.0.0.1, ephemeral port 0
    server.listen(0, '127.0.0.1', () => resolve(server));
  });
}

// In-page inventory probe — reads the room's live JS globals + DOM controls.
const PROBE = () => {
  // indirect eval reaches top-level lexical `const` globals (CATALOG/DEFAULT_SHOW)
  const gEval = (expr) => { try { return (0, eval)(expr); } catch { return undefined; } };
  const qa = (s) => Array.from(document.querySelectorAll(s));
  const el = (id) => document.getElementById(id);

  const catalogLen = gEval("typeof CATALOG!=='undefined' && Array.isArray(CATALOG) ? CATALOG.length : null");
  const defaultShow = gEval("typeof DEFAULT_SHOW!=='undefined' ? DEFAULT_SHOW : null");
  const firstShow = gEval("typeof CATALOG!=='undefined'&&CATALOG[0] ? (CATALOG[0].date||CATALOG[0].id) : null");
  const lastShow  = gEval("typeof CATALOG!=='undefined'&&CATALOG.length ? (CATALOG[CATALOG.length-1].date||CATALOG[CATALOG.length-1].id) : null");

  // #mrShow <select> is populated one <option> per CATALOG entry -> runtime proof of catalog size.
  // (CATALOG/DEFAULT_SHOW are IIFE-scoped in the source, so read them via the rendered DOM.)
  const showSelect = el('mrShow');
  const showOptionCount = showSelect && showSelect.tagName === 'SELECT' ? showSelect.options.length : 0;
  const selectedShowIndex = showSelect ? showSelect.value : null; // loadShow(DEFAULT_SHOW) sets this
  const firstOptText = showSelect && showSelect.options[0] ? showSelect.options[0].textContent.trim() : null;
  const lastOptText = showSelect && showSelect.options.length ? showSelect.options[showSelect.options.length-1].textContent.trim() : null;

  // source selector: #mrSrc renders one <button data-src> per MR_SOURCES key (gd / dc)
  const srcButtons = qa('#mrSrc button[data-src]').map(b => ({
    src: b.dataset.src, label: (b.textContent || '').trim().replace(/\s+/g, ' ')
  }));
  const srcActive = qa('#mrSrc button[data-src].on, #mrSrc button[aria-selected="true"]').map(b => b.dataset.src);
  const MR = gEval('window.MR_SOURCES');
  const mrSourcesLabels = MR && typeof MR === 'object' ? Object.values(MR).map(v => (v && v.label) || '').filter(Boolean) : [];

  // year-tile grid: #mrMap holds one <button class="mr-yr"> per year (GD 1965-1995 => 31)
  const yearTiles = qa('#mrMap .mr-yr');
  const yearTileYears = yearTiles.map(b => b.dataset.y);
  const mapErr = !!document.querySelector('#mrMap .mr-map-err');

  // dock pills: #dock .btn — equal-height via flex align-items:stretch
  const pills = qa('#dock .btn');
  const pillHeights = pills.map(b => Math.round(b.getBoundingClientRect().height));
  const nonZero = pillHeights.filter(h => h > 0);
  const dockPillEqualHeight = nonZero.length > 1 ? (Math.max(...nonZero) - Math.min(...nonZero) <= 1) : null;
  // "lit / active" pills (aria-pressed=true => the gold-wash active state)
  const dockActive = qa('#dock .btn[aria-pressed="true"]').map(b => b.id);

  // range controls
  const rng = (id) => { const e = el(id); return e && e.tagName === 'INPUT' && e.type === 'range'
    ? { present: true, value: e.value, valuetext: e.getAttribute('aria-valuetext') } : { present: false }; };

  const ids = ['musicroom','scene','dock','lantern','tempo','reduceBtn','trailBtn','reactBtn',
    'pteroBtn','venueBtn','restartBtn','playBtn','mrPlay','mrPrev','mrNext','mrSeek','mrSearch',
    'mrSrc','mrShow','mrYear','mrTracks','mrToggle','labelBtn','crowd','animals','bears'];
  const present = {}; ids.forEach(i => present[i] = !!el(i));

  return {
    catalogLen, defaultShow, firstShow, lastShow,
    showOptionCount, selectedShowIndex, firstOptText, lastOptText,
    srcButtons, srcActive, mrSourcesLabels,
    yearTileCount: yearTiles.length, yearTileYears, mapErr,
    dockPillCount: pills.length, dockPillHeights: pillHeights, dockPillEqualHeight, dockActive,
    tempo: rng('tempo'), lantern: rng('lantern'), crowd: rng('crowd'),
    animals: rng('animals'), bears: rng('bears'),
    reduceBtnPressed: el('reduceBtn') ? el('reduceBtn').getAttribute('aria-pressed') : null,
    controlsPresent: present,
    docTitle: document.title,
    threePresent: gEval("typeof THREE!=='undefined' || typeof window.THREE!=='undefined'") === true,
  };
};

const PROFILES = [
  { name: 'desktop', opts: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 } },
  { name: 'mobile',  opts: { ...devices['iPhone 13'] } },
];
const ENGINES = [ { name: 'chromium', launcher: chromium }, { name: 'webkit', launcher: webkit } ];

(async () => {
  const started = new Date().toISOString();
  const server = await startServer();
  const { port } = server.address();
  const base = `http://127.0.0.1:${port}`;
  const boundAddr = `${server.address().address}:${port}`;
  console.log(`[server] loopback-only serving ${SRV} at ${base} (bound ${boundAddr})`);

  const runs = [];
  for (const eng of ENGINES) {
    let browser;
    try { browser = await eng.launcher.launch({ headless: true }); }
    catch (e) { runs.push({ engine: eng.name, profile: '*', status: 'SKIP', reason: 'launch failed: '+e.message }); continue; }
    for (const prof of PROFILES) {
      const combo = `${eng.name}-${prof.name}`;
      const rec = { engine: eng.name, profile: prof.name, combo, url: base + '/room/', consoleErrors: [], pageErrors: [] };
      let ctx, page;
      try {
        ctx = await browser.newContext(prof.opts);
        // Harness sandboxing (NOT source edits): supply the external deps the page
        // requests so a loopback/offline box can exercise the feature deterministically.
        //  - three.js CDN  -> local r128 fixture (scene renders; tempo affects it)
        //  - archive.org advancedsearch -> deterministic per-year count stub (year grid heat-colors)
        const threeJs = fs.readFileSync(path.join(ROOT, 'fixtures', 'three.min.js'));
        await ctx.route(/three@0\.128\.0/, r => r.fulfill({ status: 200, contentType: 'text/javascript', body: threeJs }));
        await ctx.route(/archive\.org\/advancedsearch/, r => {
          const u = new URL(r.request().url());
          const q = u.searchParams.get('q') || '';
          const ym = q.match(/year:(\d{4})/); const year = ym ? +ym[1] : 1977;
          // plausible deterministic count so tiles persist + heat-color (77/89 spike)
          const n = [1977, 1989].includes(year) ? 90 : 20 + (year % 40);
          r.fulfill({ status: 200, contentType: 'application/json',
            body: JSON.stringify({ response: { numFound: n, docs: [] } }) });
        });
        page = await ctx.newPage();
        page.on('console', m => { if (m.type() === 'error') rec.consoleErrors.push(m.text().slice(0,300)); });
        page.on('pageerror', e => rec.pageErrors.push(String(e).slice(0,300)));
        const resp = await page.goto(base + '/room/', { waitUntil: 'domcontentloaded', timeout: 20000 });
        rec.httpStatus = resp ? resp.status() : null;
        // let the app initialize + the stubbed year-count promises settle
        await page.waitForTimeout(3500);
        // exercise the "slowed motion" tempo control: drive it and confirm the label reacts
        try {
          const before = await page.$eval('#tempoV', e => e.textContent).catch(()=>null);
          await page.$eval('#tempo', e => { e.value = '40'; e.dispatchEvent(new Event('input', {bubbles:true})); });
          await page.waitForTimeout(200);
          const after = await page.$eval('#tempoV', e => e.textContent).catch(()=>null);
          rec.tempoControl = { before, after, reacts: before !== null && after !== null && before !== after };
        } catch (e) { rec.tempoControl = { reacts: false, err: String(e).slice(0,120) }; }
        // Reveal the control chrome (room boots in immersive hide-chrome mode) so the dock lays out
        try {
          const wasHidden = await page.$eval('#stage', s => s.classList.contains('hide-chrome')).catch(()=>null);
          await page.click('#chromeBtn', { timeout: 3000 }).catch(()=>{});
          await page.waitForTimeout(400);
          const nowHidden = await page.$eval('#stage', s => s.classList.contains('hide-chrome')).catch(()=>null);
          rec.chromeToggle = { wasHidden, nowHidden, revealed: wasHidden === true && nowHidden === false };
        } catch (e) { rec.chromeToggle = { revealed: false, err: String(e).slice(0,120) }; }
        rec.inventory = await page.evaluate(PROBE);
        const shot = path.join(SHOTS, `${combo}.png`);
        await page.screenshot({ path: shot, fullPage: false });
        rec.screenshot = path.relative(ROOT, shot);
        // Per-combo acceptance checks
        const inv = rec.inventory;
        const srcBlob = JSON.stringify(inv.srcButtons) + JSON.stringify(inv.mrSourcesLabels);
        rec.checks = {
          catalog17: inv.showOptionCount === 17,           // runtime: #mrShow options == CATALOG.length
          defaultShow7: inv.selectedShowIndex === '7',      // loadShow(DEFAULT_SHOW) => #mrShow.value
          sourceSelector_GD_and_DeadCo:
              /grateful dead/i.test(srcBlob) && /dead\s*&?\s*(and\s*)?compan/i.test(srcBlob) &&
              inv.srcButtons.some(b => b.src === 'gd') && inv.srcButtons.some(b => b.src === 'dc'),
          yearGridPresent: inv.yearTileCount >= 30 && !inv.mapErr,
          dockEqualHeightPills: inv.dockPillCount >= 5 && inv.dockPillEqualHeight === true,
          activeLanternState: inv.lantern.present === true && inv.dockActive.length >= 1,
          slowedMotionControls: inv.tempo.present === true && inv.controlsPresent.reduceBtn === true
              && (rec.tempoControl ? rec.tempoControl.reacts === true : false),
          musicRoomControls: !!(inv.controlsPresent.mrPlay && inv.controlsPresent.mrPrev
              && inv.controlsPresent.mrNext && inv.controlsPresent.mrSeek && inv.controlsPresent.mrSearch),
        };
        rec.status = Object.values(rec.checks).every(Boolean) ? 'PASS' : 'FAIL';
      } catch (e) {
        rec.status = 'FAIL'; rec.error = String(e).slice(0, 400);
      } finally {
        if (page) await page.close().catch(()=>{});
        if (ctx) await ctx.close().catch(()=>{});
      }
      console.log(`[${combo}] ${rec.status}  catalog=${rec.inventory?rec.inventory.catalogLen:'?'} yearTiles=${rec.inventory?rec.inventory.yearTileCount:'?'}`);
      runs.push(rec);
    }
    await browser.close().catch(()=>{});
  }

  // Cleanup: STOP the temporary server
  await new Promise(r => server.close(r));
  const serverStopped = !server.listening;
  console.log(`[server] stopped=${serverStopped}`);

  // git blob hash of the served room file (proves it is byte-identical to 5bb689d:room/index.html)
  const cp = require('child_process');
  let servedBlob = null;
  try { servedBlob = cp.execSync(`git hash-object "${path.join(SRV,'room','index.html')}"`,
    { cwd: ROOT }).toString().trim(); } catch {}

  const summary = {
    ticket: 'TK-11100', lane: 'localhead', owner: 'iterm-tk11100-localhead',
    targetCommit: TARGET_COMMIT, targetBlob: TARGET_BLOB,
    servedBlob, servedBlobMatchesTarget: servedBlob === TARGET_BLOB,
    serverBoundAddr: boundAddr, loopbackOnly: boundAddr.startsWith('127.0.0.1'),
    startedAt: started, finishedAt: new Date().toISOString(),
    serverStopped,
    combos: runs.map(r => ({ combo: r.combo, status: r.status, checks: r.checks || null })),
    overall: runs.every(r => r.status === 'PASS') ? 'PASS' : (runs.some(r=>r.status==='PASS')?'PARTIAL':'FAIL'),
    runs,
  };
  fs.writeFileSync(path.join(RESULTS, 'localhead-results.json'), JSON.stringify(summary, null, 2));
  console.log('OVERALL:', summary.overall);
  process.exit(0);
})().catch(e => { console.error('FATAL', e); process.exit(1); });