← back to CelebritySignatures
screenrecord/run-debug.mjs
515 lines
/**
* screenrecord debug agent — CelebritySignatures /upload page
* 5 runs, each with a different click-order combination.
* Reads debug-log.jsonl first, appends after each action.
* Converts .webm -> .mp4 via ffmpeg.
*/
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { execSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// Playwright from global npm
process.env.NODE_PATH = process.env.HOME + '/.npm-global/lib/node_modules';
const { chromium } = require(process.env.HOME + '/.npm-global/lib/node_modules/playwright');
const URL_BASE = 'http://localhost:9920';
const PAGE_URL = URL_BASE + '/upload';
const LOG_FILE = path.join(__dirname, 'debug-log.jsonl');
const REC_DIR = path.join(__dirname, 'rec');
// Test credentials — one account for all runs (idempotent signup)
const TEST_EMAIL = 'screenrec-test@example.com';
const TEST_PW = 'secret123';
const TEST_NAME = 'Rec Test';
// ---- log helpers ----
const priorLines = fs.existsSync(LOG_FILE)
? fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l))
: [];
const priorErrors = priorLines.filter(r => r.errors && r.errors.length > 0);
function appendLog(obj) {
fs.appendFileSync(LOG_FILE, JSON.stringify(obj) + '\n');
}
// ---- order for each run ----
function orderFor(run, els) {
if (run === 0) return [...els];
if (run === 1) return [...els].reverse();
if (run === 2) {
// checkboxes and file inputs first, then text inputs, then buttons/links
const priority = el => {
if (el.type === 'checkbox') return 0;
if (el.type === 'file') return 1;
if (el.type === 'text' || el.type === 'email' || el.type === 'password') return 2;
if (el.type === 'button') return 3;
return 4;
};
return [...els].sort((a, b) => priority(a) - priority(b));
}
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 selectors first
const bad = new Set(priorErrors.map(e => e.selector));
return [...els].sort((a, b) => (bad.has(b.selector) ? 1 : 0) - (bad.has(a.selector) ? 1 : 0));
}
return [...els];
}
// ---- snapshot helper: grab relevant DOM state ----
async function domSnapshot(page) {
return page.evaluate(() => ({
authFormsVisible: document.getElementById('authForms')?.style.display !== 'none',
whoamiVisible: document.getElementById('whoami')?.style.display !== 'none',
uploadCardVisible: document.getElementById('uploadCard')?.style.display !== 'none',
dashCardVisible: document.getElementById('dashCard')?.style.display !== 'none',
authMsg: document.getElementById('authMsg')?.textContent || '',
upMsg: document.getElementById('upMsg')?.textContent || '',
whoEmail: document.getElementById('whoEmail')?.textContent || '',
totalEarned: document.getElementById('totalEarned')?.textContent || '',
dashRows: document.querySelector('#dashTable tbody')?.innerText?.trim() || '',
attestChecked: document.getElementById('attest')?.checked || false,
previewVisible: document.getElementById('preview')?.style.display === 'block',
}));
}
// ---- Convert webm to mp4 ----
function convertToMp4(webmPath) {
const mp4Path = webmPath.replace('.webm', '.mp4');
try {
execSync(`ffmpeg -y -i "${webmPath}" -c:v libx264 -preset fast -crf 22 "${mp4Path}" 2>/dev/null`);
return mp4Path;
} catch (e) {
console.warn(`ffmpeg convert failed for ${webmPath}: ${e.message}`);
return null;
}
}
// ---- MAIN ----
async function main() {
console.log(`\n=== screenrecord debug agent ===`);
console.log(`Prior log lines: ${priorLines.length}, prior errors: ${priorErrors.length}`);
console.log(`Target: ${PAGE_URL}`);
console.log(`Test account: ${TEST_EMAIL} / ${TEST_PW}`);
console.log(`Runs: 5 (0=dom-order, 1=reverse, 2=inputs-first, 3=shuffle, 4=errors-first)\n`);
const runSummaries = [];
for (let run = 0; run < 5; run++) {
console.log(`\n--- RUN ${run} ---`);
const runDir = path.join(REC_DIR, `run${run}`);
fs.mkdirSync(runDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 1600, height: 900 },
recordVideo: { dir: runDir, size: { width: 1600, height: 900 } },
});
const page = await ctx.newPage();
// Collect console errors and page errors
const errs = [];
page.on('console', msg => {
if (msg.type() === 'error') {
errs.push({ type: 'console.error', text: msg.text() });
}
});
page.on('pageerror', e => {
errs.push({ type: 'pageerror', text: String(e) });
});
// Navigate
try {
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.waitForTimeout(1200); // let refresh() / GA load
} catch (e) {
appendLog({ run, ts: new Date().toISOString(), selector: 'navigate', label: 'page load', action: 'goto', ok: false, effect: null, errors: [{ type: 'nav', text: String(e) }] });
console.log(` FAIL: page load — ${e.message}`);
await ctx.close();
await browser.close();
continue;
}
// ================================================================
// STEP 1: Check stat callout ($10 / 50%)
// ================================================================
{
const statText = await page.evaluate(() => document.querySelector('main .card')?.innerText || '');
const hasTen = statText.includes('$10');
const hasFifty = statText.includes('50%');
const errSnap = errs.splice(0);
appendLog({ run, ts: new Date().toISOString(), selector: '.card:first-child', label: 'stat callout $10/50%', action: 'check', ok: hasTen && hasFifty, effect: `$10=${hasTen} 50%=${hasFifty}`, errors: errSnap });
console.log(` stat callout $10=${hasTen} 50%=${hasFifty}`);
}
// ================================================================
// STEP 2: Auth initial state — should show form, not whoami
// ================================================================
{
const before = await domSnapshot(page);
const ok = before.authFormsVisible && !before.whoamiVisible;
const errSnap = errs.splice(0);
appendLog({ run, ts: new Date().toISOString(), selector: '#authForms', label: 'auth initial state', action: 'check', ok, effect: JSON.stringify(before), errors: errSnap });
console.log(` auth initial state ok=${ok} (forms=${before.authFormsVisible} whoami=${before.whoamiVisible})`);
}
// ================================================================
// STEP 3: Upload + dashboard cards should be HIDDEN when logged out
// ================================================================
{
const snap = await domSnapshot(page);
const ok = !snap.uploadCardVisible && !snap.dashCardVisible;
appendLog({ run, ts: new Date().toISOString(), selector: '#uploadCard,#dashCard', label: 'cards hidden when logged out', action: 'check', ok, effect: `upload=${snap.uploadCardVisible} dash=${snap.dashCardVisible}`, errors: [] });
console.log(` cards hidden when logged out: ok=${ok}`);
}
// ================================================================
// STEP 4: Fill auth form and CREATE ACCOUNT (run 0,2,4) or SIGN IN (1,3)
// Always ensure we end up signed in.
// ================================================================
{
// Fill in email + password + name
await page.locator('#aEmail').fill(TEST_EMAIL);
await page.locator('#aPw').fill(TEST_PW);
await page.locator('#aName').fill(TEST_NAME);
await page.waitForTimeout(300);
let signedIn = false;
if (run % 2 === 0) {
// Try signup (idempotent — server returns error if already exists)
console.log(` clicking Create Account...`);
const errsBefore = errs.splice(0);
await page.locator('#signupBtn').click();
await page.waitForTimeout(1500);
const afterMsg = await page.evaluate(() => document.getElementById('authMsg')?.textContent || '');
const afterSnap = await domSnapshot(page);
const errSnap = errs.splice(0);
// If "account created" or already-existing → then sign in
if (afterSnap.whoamiVisible) {
signedIn = true;
appendLog({ run, ts: new Date().toISOString(), selector: '#signupBtn', label: 'Create account', action: 'click', ok: true, effect: afterMsg, errors: errSnap });
console.log(` signup ok → signed in as ${afterSnap.whoEmail}`);
} else {
appendLog({ run, ts: new Date().toISOString(), selector: '#signupBtn', label: 'Create account', action: 'click', ok: false, effect: afterMsg, errors: [...errsBefore, ...errSnap] });
console.log(` signup result: "${afterMsg}" — trying login`);
// Fall through to login
await page.locator('#aEmail').fill(TEST_EMAIL);
await page.locator('#aPw').fill(TEST_PW);
await page.waitForTimeout(200);
await page.locator('#loginBtn').click();
await page.waitForTimeout(1500);
const loginSnap = await domSnapshot(page);
const loginMsg = await page.evaluate(() => document.getElementById('authMsg')?.textContent || '');
const loginErrs = errs.splice(0);
signedIn = loginSnap.whoamiVisible;
appendLog({ run, ts: new Date().toISOString(), selector: '#loginBtn', label: 'Sign in (fallback after signup already-exists)', action: 'click', ok: signedIn, effect: loginMsg, errors: loginErrs });
console.log(` login result: "${loginMsg}" signed_in=${signedIn}`);
}
} else {
// Try login directly
console.log(` clicking Sign in...`);
const errsBefore = errs.splice(0);
await page.locator('#loginBtn').click();
await page.waitForTimeout(1500);
const afterMsg = await page.evaluate(() => document.getElementById('authMsg')?.textContent || '');
const afterSnap = await domSnapshot(page);
const errSnap = errs.splice(0);
signedIn = afterSnap.whoamiVisible;
if (!signedIn) {
// Account might not exist yet on odd runs — try signup
appendLog({ run, ts: new Date().toISOString(), selector: '#loginBtn', label: 'Sign in', action: 'click', ok: false, effect: afterMsg, errors: [...errsBefore, ...errSnap] });
console.log(` login failed: "${afterMsg}" — trying signup`);
await page.locator('#aEmail').fill(TEST_EMAIL);
await page.locator('#aPw').fill(TEST_PW);
await page.locator('#aName').fill(TEST_NAME);
await page.waitForTimeout(200);
await page.locator('#signupBtn').click();
await page.waitForTimeout(1500);
const signupSnap = await domSnapshot(page);
const signupMsg = await page.evaluate(() => document.getElementById('authMsg')?.textContent || '');
const signupErrs = errs.splice(0);
signedIn = signupSnap.whoamiVisible;
appendLog({ run, ts: new Date().toISOString(), selector: '#signupBtn', label: 'Create account (fallback)', action: 'click', ok: signedIn, effect: signupMsg, errors: signupErrs });
console.log(` signup fallback: "${signupMsg}" signed_in=${signedIn}`);
} else {
appendLog({ run, ts: new Date().toISOString(), selector: '#loginBtn', label: 'Sign in', action: 'click', ok: true, effect: afterMsg, errors: [...errsBefore, ...errSnap] });
console.log(` login ok → signed in as ${afterSnap.whoEmail}`);
}
}
// ================================================================
// STEP 5: Verify upload + dashboard cards appear after sign in
// ================================================================
if (signedIn) {
const snap = await domSnapshot(page);
const ok = snap.uploadCardVisible && snap.dashCardVisible;
appendLog({ run, ts: new Date().toISOString(), selector: '#uploadCard,#dashCard', label: 'cards visible after sign in', action: 'check', ok, effect: `upload=${snap.uploadCardVisible} dash=${snap.dashCardVisible} whoEmail=${snap.whoEmail}`, errors: errs.splice(0) });
console.log(` cards after sign in: upload=${snap.uploadCardVisible} dash=${snap.dashCardVisible} ok=${ok}`);
// ================================================================
// STEP 6: Dashboard — check empty state / data
// ================================================================
{
const dbSnap = await domSnapshot(page);
const hasEmptyState = dbSnap.dashRows.includes('no uploads yet') || dbSnap.dashRows.length > 0;
appendLog({ run, ts: new Date().toISOString(), selector: '#dashTable', label: 'dashboard table state', action: 'check', ok: hasEmptyState, effect: `rows="${dbSnap.dashRows}" totalEarned="${dbSnap.totalEarned}"`, errors: errs.splice(0) });
console.log(` dashboard: rows="${dbSnap.dashRows}" earned="${dbSnap.totalEarned}"`);
}
// ================================================================
// STEP 7: Upload card — VALIDATION paths (run-order varied)
// ================================================================
const uploadCard = page.locator('#uploadCard');
await uploadCard.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
// Vary which validation we test first based on run
const validationOrder = orderFor(run, [
{ id: 'test-no-file-no-attest', label: 'submit with no file no attest' },
{ id: 'test-no-file-with-attest', label: 'submit with attest but no file' },
{ id: 'test-attest-checkbox', label: 'attest checkbox toggle' },
{ id: 'test-name-field', label: 'celebName field fill' },
]);
for (const v of validationOrder) {
if (v.id === 'test-no-file-no-attest') {
// Ensure attest unchecked, then click Submit
const attestState = await page.evaluate(() => document.getElementById('attest')?.checked);
if (attestState) await page.locator('#attest').click();
await page.evaluate(() => { const p = document.getElementById('preview'); if(p) p.style.display='none'; });
// Clear the dataUrl internal state
await page.evaluate(() => { window._testDataUrl = null; });
await page.locator('#uploadBtn').scrollIntoViewIfNeeded();
const beforeSnap = await domSnapshot(page);
const e0 = errs.splice(0);
await page.locator('#uploadBtn').click();
await page.waitForTimeout(500);
const afterMsg = await page.evaluate(() => document.getElementById('upMsg')?.textContent || '');
const e1 = errs.splice(0);
const ok = afterMsg === 'choose a signature file first';
appendLog({ run, ts: new Date().toISOString(), selector: '#uploadBtn', label: 'submit with no file (validation)', action: 'click', ok, effect: `upMsg="${afterMsg}"`, errors: [...e0, ...e1] });
console.log(` no-file validation: "${afterMsg}" ok=${ok}`);
}
if (v.id === 'test-no-file-with-attest') {
// Check attest, still no file
const attestState = await page.evaluate(() => document.getElementById('attest')?.checked);
if (!attestState) await page.locator('#attest').click();
await page.waitForTimeout(200);
const e0 = errs.splice(0);
await page.locator('#uploadBtn').click();
await page.waitForTimeout(500);
const afterMsg = await page.evaluate(() => document.getElementById('upMsg')?.textContent || '');
const e1 = errs.splice(0);
// With attest checked but no file, should still say "choose a signature file first"
const ok = afterMsg === 'choose a signature file first';
appendLog({ run, ts: new Date().toISOString(), selector: '#uploadBtn', label: 'submit with attest but no file', action: 'click', ok, effect: `upMsg="${afterMsg}"`, errors: [...e0, ...e1] });
console.log(` attest+no-file validation: "${afterMsg}" ok=${ok}`);
// Uncheck attest again for next test
if (await page.evaluate(() => document.getElementById('attest')?.checked)) {
await page.locator('#attest').click();
}
}
if (v.id === 'test-attest-checkbox') {
// Toggle attest checkbox and observe
const before = await page.evaluate(() => document.getElementById('attest')?.checked);
await page.locator('#attest').click();
await page.waitForTimeout(200);
const after = await page.evaluate(() => document.getElementById('attest')?.checked);
const ok = before !== after;
appendLog({ run, ts: new Date().toISOString(), selector: '#attest', label: 'attest checkbox toggle', action: 'click', ok, effect: `before=${before} after=${after}`, errors: errs.splice(0) });
console.log(` attest toggle: ${before} -> ${after} ok=${ok}`);
// Leave unchecked for next test
if (after) await page.locator('#attest').click();
}
if (v.id === 'test-name-field') {
// Fill celebrity name field
await page.locator('#celebName').fill('Test Celebrity Name');
await page.waitForTimeout(200);
const val = await page.evaluate(() => document.getElementById('celebName')?.value || '');
const ok = val === 'Test Celebrity Name';
appendLog({ run, ts: new Date().toISOString(), selector: '#celebName', label: 'celebName text input', action: 'fill', ok, effect: `value="${val}"`, errors: errs.splice(0) });
console.log(` celebName fill: "${val}" ok=${ok}`);
}
}
// ================================================================
// STEP 8: File input — headless can't attach real files, test detection
// ================================================================
{
// Test that the file input exists and is visible
const fileInputExists = await page.evaluate(() => !!document.getElementById('sigFile'));
const fileInputAccept = await page.evaluate(() => document.getElementById('sigFile')?.accept || '');
appendLog({ run, ts: new Date().toISOString(), selector: '#sigFile', label: 'file input existence + accept attr', action: 'check', ok: fileInputExists, effect: `accept="${fileInputAccept}"`, errors: errs.splice(0) });
console.log(` file input exists=${fileInputExists} accept="${fileInputAccept}"`);
// Simulate a file via page.setInputFiles (a 1-pixel PNG)
const fakePngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const pngBuf = Buffer.from(fakePngBase64, 'base64');
try {
await page.locator('#sigFile').setInputFiles({
name: 'test-sig.png',
mimeType: 'image/png',
buffer: pngBuf,
});
await page.waitForTimeout(800); // FileReader async
const previewVisible = await page.evaluate(() => document.getElementById('preview')?.style.display === 'block');
const dataUrlSet = await page.evaluate(() => {
// Check if the module-scoped dataUrl was set by looking at the preview src
const src = document.getElementById('preview')?.src || '';
return src.startsWith('data:image/') || src.startsWith('blob:');
});
appendLog({ run, ts: new Date().toISOString(), selector: '#sigFile', label: 'file input setInputFiles (fake PNG)', action: 'setFile', ok: previewVisible, effect: `preview=${previewVisible} dataUrlSet=${dataUrlSet}`, errors: errs.splice(0) });
console.log(` file input: preview=${previewVisible} dataUrl set=${dataUrlSet}`);
// Now test submit WITH file but WITHOUT attest
const attestChecked = await page.evaluate(() => document.getElementById('attest')?.checked);
if (attestChecked) await page.locator('#attest').click(); // uncheck
await page.locator('#uploadBtn').click();
await page.waitForTimeout(500);
const noAttestMsg = await page.evaluate(() => document.getElementById('upMsg')?.textContent || '');
const noAttestOk = noAttestMsg === 'the attestation checkbox is required';
appendLog({ run, ts: new Date().toISOString(), selector: '#uploadBtn', label: 'submit with file but no attest', action: 'click', ok: noAttestOk, effect: `upMsg="${noAttestMsg}"`, errors: errs.splice(0) });
console.log(` no-attest validation (file present): "${noAttestMsg}" ok=${noAttestOk}`);
// Now with BOTH file + attest — do a real submission
await page.locator('#attest').click(); // check it
await page.waitForTimeout(200);
await page.locator('#celebName').fill('Rec Test Celeb');
await page.waitForTimeout(200);
const e0 = errs.splice(0);
await page.locator('#uploadBtn').click();
await page.waitForTimeout(2000);
const submitMsg = await page.evaluate(() => document.getElementById('upMsg')?.textContent || '');
const submitOk = submitMsg.includes('submitted') || submitMsg.includes('review');
const e1 = errs.splice(0);
appendLog({ run, ts: new Date().toISOString(), selector: '#uploadBtn', label: 'submit with file + attest (real upload)', action: 'click', ok: submitOk, effect: `upMsg="${submitMsg}"`, errors: [...e0, ...e1] });
console.log(` full upload submit: "${submitMsg}" ok=${submitOk}`);
// Refresh dashboard to see new entry
await page.waitForTimeout(1000);
const dashAfterUpload = await domSnapshot(page);
appendLog({ run, ts: new Date().toISOString(), selector: '#dashTable', label: 'dashboard after upload', action: 'check', ok: true, effect: `rows="${dashAfterUpload.dashRows}" earned="${dashAfterUpload.totalEarned}"`, errors: errs.splice(0) });
console.log(` dashboard after upload: rows="${dashAfterUpload.dashRows}"`);
} catch (fileErr) {
appendLog({ run, ts: new Date().toISOString(), selector: '#sigFile', label: 'file input setInputFiles', action: 'setFile', ok: false, effect: null, errors: [{ type: 'script', text: String(fileErr) }] });
console.log(` file input error: ${fileErr.message}`);
}
}
// ================================================================
// STEP 9: Sign OUT
// ================================================================
{
await page.locator('#logoutLink').scrollIntoViewIfNeeded();
const e0 = errs.splice(0);
await page.locator('#logoutLink').click();
await page.waitForTimeout(1500);
const afterSnap = await domSnapshot(page);
const ok = afterSnap.authFormsVisible && !afterSnap.whoamiVisible;
const e1 = errs.splice(0);
appendLog({ run, ts: new Date().toISOString(), selector: '#logoutLink', label: 'sign out link', action: 'click', ok, effect: `forms=${afterSnap.authFormsVisible} whoami=${afterSnap.whoamiVisible}`, errors: [...e0, ...e1] });
console.log(` sign out: ok=${ok} forms=${afterSnap.authFormsVisible} whoami=${afterSnap.whoamiVisible}`);
}
// ================================================================
// STEP 10: Sign BACK IN
// ================================================================
{
await page.locator('#aEmail').fill(TEST_EMAIL);
await page.locator('#aPw').fill(TEST_PW);
const e0 = errs.splice(0);
await page.locator('#loginBtn').click();
await page.waitForTimeout(1500);
const afterSnap = await domSnapshot(page);
const loginMsg = await page.evaluate(() => document.getElementById('authMsg')?.textContent || '');
const ok = afterSnap.whoamiVisible;
const e1 = errs.splice(0);
appendLog({ run, ts: new Date().toISOString(), selector: '#loginBtn', label: 'sign back in after logout', action: 'click', ok, effect: `msg="${loginMsg}" whoEmail="${afterSnap.whoEmail}"`, errors: [...e0, ...e1] });
console.log(` sign back in: "${loginMsg}" ok=${ok} email="${afterSnap.whoEmail}"`);
}
} else {
appendLog({ run, ts: new Date().toISOString(), selector: 'auth', label: 'SKIPPED upload/dashboard tests — could not sign in', action: 'skip', ok: false, effect: null, errors: errs.splice(0) });
console.log(` SKIP: could not sign in — skipping upload/dash tests for run ${run}`);
}
}
// ================================================================
// STEP 11: "back to gallery" link
// ================================================================
{
const backLink = page.locator('h1 a');
await backLink.scrollIntoViewIfNeeded();
const href = await backLink.getAttribute('href');
const text = await backLink.innerText();
const ok = href === '/' && text.includes('back to the gallery');
appendLog({ run, ts: new Date().toISOString(), selector: 'h1 a', label: 'back to gallery link', action: 'check', ok, effect: `href="${href}" text="${text}"`, errors: errs.splice(0) });
console.log(` back link: href="${href}" text="${text}" ok=${ok}`);
// Don't click it — would navigate away; just verify
}
// ================================================================
// STEP 12: Check for any remaining console errors
// ================================================================
const remaining = errs.splice(0);
if (remaining.length > 0) {
appendLog({ run, ts: new Date().toISOString(), selector: 'page', label: 'end-of-run console errors', action: 'check', ok: false, effect: null, errors: remaining });
console.log(` end-of-run errors (${remaining.length}): ${remaining.map(e => e.text).join('; ')}`);
}
// Close context — triggers video save
await ctx.close();
await browser.close();
// Find the recorded webm file
const recFiles = fs.readdirSync(runDir).filter(f => f.endsWith('.webm'));
let mp4Path = null;
for (const wf of recFiles) {
const webmPath = path.join(runDir, wf);
console.log(` converting ${wf} -> mp4...`);
mp4Path = convertToMp4(webmPath);
if (mp4Path) console.log(` mp4: ${mp4Path}`);
else console.log(` (no ffmpeg conversion, left as .webm)`);
}
const summary = {
run,
webmFiles: recFiles.map(f => path.join(runDir, f)),
mp4: mp4Path,
errorCount: priorLines.filter(l => l.run === run && l.errors && l.errors.length > 0).length,
};
runSummaries.push(summary);
console.log(` run ${run} done — recording: ${mp4Path || recFiles[0] || 'none'}`);
}
console.log('\n=== all runs complete ===');
return runSummaries;
}
main().then(summaries => {
console.log('\nRun summaries:');
summaries.forEach(s => console.log(` run${s.run}: ${s.mp4 || (s.webmFiles[0] || 'no recording')}`));
console.log('\nDebug log:', LOG_FILE);
process.exit(0);
}).catch(e => {
console.error('Fatal error:', e);
process.exit(1);
});