← 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); });