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