← back to Dot Palette

screenrecord/run.js

169 lines

#!/usr/bin/env node
// screenrecord agent for dot-palette (TK-11877, empirical test gate cycle 1)
// Records 5 passes over http://127.0.0.1:9791/, each a distinct click-order
// combination, appending every action + console/pageerror to debug-log.jsonl.
'use strict';
const { chromium } = require('playwright');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');

const ROOT = __dirname;
// SAFE BY DEFAULT: this harness clicks every colour chip, and against the LIVE
// palette each click repaints whatever iTerm2 tab is focused (via the real engine).
// So by default we boot our OWN isolated dot-palette server (PORT=0) with the test
// seams — a mock engine + a fake tty — so no real tab is ever touched. Pass --live
// ONLY to drive the real :9791 (it WILL repaint your focused tab). See selftest.js
// for the deterministic (no-browser) gate.
const LIVE = process.argv.includes('--live');
const LOG = path.join(ROOT, 'debug-log.jsonl');
let URL = 'http://127.0.0.1:9791/';   // set to the isolated instance below unless --live
let _srv = null, _tmp = null;

async function bootIsolated() {
  _tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dotpalette-rec-'));
  const mock = path.join(_tmp, 'mock-engine.py');
  fs.writeFileSync(mock, 'import sys\nsys.stdout.write(" ".join(sys.argv[1:]))\n');
  const portFile = path.join(_tmp, 'port');
  _srv = spawn(process.execPath, [path.join(ROOT, '..', 'server.js')], {
    stdio: 'ignore',
    env: { ...process.env, PORT: '0', DOTPALETTE_ENGINE: mock, DOTPALETTE_ENGINE_BIN: 'python3',
      DOTPALETTE_TTY_OVERRIDE: '/dev/ttys123', DOTPALETTE_PORT_FILE: portFile },
  });
  for (let i = 0; i < 60; i++) {
    await new Promise(r => setTimeout(r, 100));
    try { const p = parseInt(fs.readFileSync(portFile, 'utf8'), 10); if (p) { URL = `http://127.0.0.1:${p}/`; return; } } catch {}
  }
  throw new Error('isolated dot-palette server never came up');
}
function teardownIsolated() {
  try { if (_srv) _srv.kill(); } catch {}
  try { if (_tmp) fs.rmSync(_tmp, { recursive: true, force: true }); } catch {}
}

const prior = fs.existsSync(LOG)
  ? fs.readFileSync(LOG, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
  : [];
const priorErrors = prior.filter(r => r.errors && r.errors.length);
console.log(`[log] read ${prior.length} prior actions, ${priorErrors.length} with errors`);

function append(o) { fs.appendFileSync(LOG, JSON.stringify(o) + '\n'); }

// The full interactive surface, DOM order, with a stable selector + label.
const BASE_ELS = [
  { sel: '#grip', label: 'grip (drag handle)', kind: 'drag' },
  { sel: '.chip >> nth=0', label: 'chip green (WORKING)', kind: 'chip', color: 'green' },
  { sel: '.chip >> nth=1', label: 'chip yellow (DIRECTION?)', kind: 'chip', color: 'yellow' },
  { sel: '.chip >> nth=2', label: 'chip orange (PASTE waiting)', kind: 'chip', color: 'orange' },
  { sel: '.chip >> nth=3', label: 'chip purple (GATED)', kind: 'chip', color: 'purple' },
  { sel: '.chip >> nth=4', label: 'chip lightblue (NEEDS STEVE)', kind: 'chip', color: 'lightblue' },
  { sel: '.chip >> nth=5', label: 'chip pink (PARKED)', kind: 'chip', color: 'pink' },
  { sel: '#x', label: 'x (close palette)', kind: 'close' },
];

function orderFor(run, els) {
  if (run === 0) return els; // DOM order
  if (run === 1) return [...els].reverse(); // reverse order
  if (run === 2) {
    // "chips-first" combination (no <input type=range> exists in this app;
    // substitute: exercise all colour chips before the drag/close controls)
    return [...els].sort((a, b) => (a.kind === 'chip' ? 0 : 1) - (b.kind === 'chip' ? 0 : 1));
  }
  if (run === 3) {
    // seeded shuffle
    const s = [...els];
    for (let i = s.length - 1; i > 0; i--) {
      const j = (i * 7 + run * 13) % (i + 1);
      [s[i], s[j]] = [s[j], s[i]];
    }
    return s;
  }
  if (run === 4) {
    // errored-first: re-hit anything that produced console/page errors in runs 0-3 first
    const badSels = new Set(priorErrors.map(e => e.selector));
    return [...els].sort((a, b) => (badSels.has(b.sel) ? 1 : 0) - (badSels.has(a.sel) ? 1 : 0));
  }
  return els;
}

async function main() {
  for (let run = 0; run < 5; run++) {
    console.log(`\n=== RUN ${run} ===`);
    const browser = await chromium.launch();
    const dir = path.join(ROOT, 'rec', `run${run}`);
    fs.mkdirSync(dir, { recursive: true });
    const ctx = await browser.newContext({
      viewport: { width: 500, height: 300 },
      recordVideo: { dir, size: { width: 500, height: 300 } },
    });
    const page = await ctx.newPage();
    let pending = [];
    page.on('console', m => { if (m.type() === 'error') pending.push('console.error: ' + m.text()); });
    page.on('pageerror', e => pending.push('pageerror: ' + String(e)));
    await page.goto(URL, { waitUntil: 'domcontentloaded' });
    await page.waitForTimeout(300);

    const order = orderFor(run, BASE_ELS);
    console.log('order:', order.map(e => e.label).join(' -> '));

    for (const el of order) {
      const errsBefore = pending.splice(0); // errors accumulated just from page load / prior settle, flush stale
      let ok = true, effect = '', errors = [];
      try {
        const handle = page.locator(el.sel).first();
        await handle.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
        const beforeClass = await handle.getAttribute('class').catch(() => null);
        const beforeTitle = await handle.getAttribute('title').catch(() => null);

        if (el.kind === 'drag') {
          const box = await handle.boundingBox();
          if (box) {
            await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
            await page.mouse.down();
            await page.mouse.move(box.x + box.width / 2 + 15, box.y + box.height / 2 + 8, { steps: 5 });
            await page.mouse.up();
            effect = 'dragged +15,+8 via pointer events';
          } else {
            ok = false; effect = 'no bounding box (not visible)';
          }
        } else {
          await handle.click({ timeout: 3000 });
          await page.waitForTimeout(250);
          const afterClass = await handle.getAttribute('class').catch(() => null);
          const afterTitle = await handle.getAttribute('title').catch(() => null);
          effect = `class: "${beforeClass}" -> "${afterClass}"; title: "${beforeTitle}" -> "${afterTitle}"`;
          if (el.kind === 'chip' && beforeClass === afterClass) {
            // class resets after 650ms so this is expected if we polled late; not auto-fail
          }
        }
        await page.waitForTimeout(400); // let async fetch/class settle
      } catch (e) {
        ok = false;
        effect = 'EXCEPTION: ' + String(e).slice(0, 300);
      }
      errors = pending.splice(0);
      const rec = {
        run, ts: new Date().toISOString(), selector: el.sel, label: el.label,
        action: el.kind === 'drag' ? 'drag' : 'click', ok, effect, errors,
      };
      append(rec);
      console.log(`  [${ok ? 'ok' : 'FAIL'}] ${el.label} :: ${effect}${errors.length ? ' :: ERRORS=' + JSON.stringify(errors) : ''}`);
    }

    await ctx.close();
    await browser.close();
  }
  console.log('\nAll 5 runs complete.');
}

(async () => {
  if (LIVE) {
    console.warn('[screenrecord] --live: driving the REAL :9791 palette — clicks WILL repaint your focused iTerm2 tab.');
  } else {
    await bootIsolated();
    console.log(`[screenrecord] isolated instance at ${URL} (mock engine, fake tty — no real tab touched)`);
  }
  try { await main(); } finally { teardownIsolated(); }
})().catch(e => { console.error('FATAL', e); teardownIsolated(); process.exit(1); });