← back to Dw Photo Capture

screenrecord/run-tests.js

568 lines

/**
 * screenrecord test agent — photo.designerwallcoverings.com
 * 5 runs, mobile iPhone emulation, distinct element ordering per run.
 * Key insight: intercept heavy API calls (/api/queue, /api/twil) to prevent
 * the renderer from choking on thousands of product cards.
 */
'use strict';
const { chromium } = require('playwright');
const fs   = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const BASE_URL = 'https://photo.designerwallcoverings.com';
const LOG_FILE = '/Users/macstudio3/Projects/dw-photo-capture/screenrecord/debug-log.jsonl';
const REC_BASE = '/Users/macstudio3/Projects/dw-photo-capture/screenrecord/rec';
const EVAL_TIMEOUT = 5000;

// ---------------------------------------------------------------------------
// Prior log
// ---------------------------------------------------------------------------
const prior = fs.existsSync(LOG_FILE)
  ? fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').filter(Boolean).map(l => {
      try { return JSON.parse(l); } catch(e) { return null; }
    }).filter(Boolean)
  : [];
const priorErrors = prior.filter(r => r.errors && r.errors.length > 0);
const priorErrorSelectors = new Set(priorErrors.map(e => e.selector).filter(Boolean));
console.log(`Prior log: ${prior.length} entries, ${priorErrors.length} with errors, ${priorErrorSelectors.size} error selectors`);

function appendLog(obj) {
  fs.appendFileSync(LOG_FILE, JSON.stringify(obj) + '\n');
}

const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const VIEWPORT  = { width: 390, height: 844 };

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function w(ms) { return new Promise(r => setTimeout(r, ms)); }

/** Safely run a page.$eval with timeout — returns null on failure */
async function se(page, sel, fn, arg, timeout) {
  try {
    return await Promise.race([
      page.$eval(sel, fn, arg),
      new Promise((_, rej) => setTimeout(() => rej(new Error('eval timeout')), timeout || EVAL_TIMEOUT))
    ]);
  } catch(e) { return null; }
}

async function isVisible(page, sel) {
  const r = await se(page, sel, el => {
    if (el.hidden) return false;
    let p = el;
    while (p) { if (p.hidden || p.getAttribute('hidden') !== null) return false; p = p.parentElement; }
    const s = window.getComputedStyle(el);
    return s.display !== 'none' && s.visibility !== 'hidden';
  });
  return r === true;
}

async function jsClick(page, sel) {
  return await se(page, sel, el => { el.click(); return true; });
}

async function closeAllModals(page) {
  await jsClick(page, '#fbClose');
  await jsClick(page, '#addClose');
  await jsClick(page, '#sampClose');
  await jsClick(page, '#galClose');
  await page.keyboard.press('Escape').catch(() => {});
  await w(300);
}

async function goHome(page) {
  await jsClick(page, '#homeBtn');
  await w(500);
}

function orderFor(run, els) {
  if (run === 0) return [...els];
  if (run === 1) return [...els].reverse();
  if (run === 2) return [...els.filter(e => e.type === 'range'), ...els.filter(e => e.type !== 'range')];
  if (run === 3) {
    const s = [...els];
    for (let i = s.length - 1; i > 0; i--) { const j = (i * 7 + 39) % (i + 1); [s[i], s[j]] = [s[j], s[i]]; }
    return s;
  }
  if (run === 4) {
    return [...els].sort((a, b) =>
      (priorErrorSelectors.has(b.selector) ? 1 : 0) - (priorErrorSelectors.has(a.selector) ? 1 : 0)
    );
  }
  return [...els];
}

// ---------------------------------------------------------------------------
// Phase 1: Home pills
// ---------------------------------------------------------------------------
async function exerciseHomePills(page, runIndex, getErrs) {
  const pills = [
    { sel: 'button[data-act="add"]',    lbl: 'Add New SKU' },
    { sel: 'button[data-act="update"]', lbl: 'Update SKU' },
    { sel: 'button[data-act="lookup"]', lbl: 'Look Up SKU' },
    { sel: 'button[data-act="check"]',  lbl: 'Check Sample In' },
    { sel: 'button.home-skip',          lbl: 'skip →' },
  ];

  for (const { sel, lbl } of pills) {
    const ts = new Date().toISOString();

    // Make sure home is visible
    const hv = await isVisible(page, '#homeScreen');
    if (!hv) {
      await goHome(page);
      await closeAllModals(page);
      await w(300);
    }

    const clicked = await jsClick(page, sel);
    if (!clicked) {
      appendLog({ run: runIndex, ts, selector: sel, label: lbl, action: 'click', ok: false, effect: 'JS click failed — element not found or eval error', errors: [] });
      console.log(`  [run${runIndex}] ${lbl}: NOT FOUND`);
      continue;
    }
    await w(600);

    // Detect result
    let modalOpen = '';
    for (const mid of ['#addModal','#fbModal','#sampleModal']) {
      if (await isVisible(page, mid)) { modalOpen = mid; break; }
    }
    const homeGone = !(await isVisible(page, '#homeScreen'));
    const effect = homeGone ? `homeScreen hidden${modalOpen ? ', modal=' + modalOpen : ''}` : 'homeScreen still visible';

    appendLog({ run: runIndex, ts, selector: sel, label: lbl, action: 'click', ok: true, effect, errors: getErrs() });
    console.log(`  [run${runIndex}] ${lbl}: ${effect}`);

    // Close modal
    await closeAllModals(page);
    await w(200);
  }
}

// ---------------------------------------------------------------------------
// Phase 2: fbModal / Look Up SKU search flow
// ---------------------------------------------------------------------------
async function exerciseLookupFlow(page, runIndex, getErrs) {
  console.log(`  [run${runIndex}] === Look Up / fbModal search flow ===`);

  // Open fbModal
  await goHome(page);
  const clicked = await jsClick(page, '#fbBtn');
  await w(800);

  let fbVis = await isVisible(page, '#fbModal');
  appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbModal', label: 'fbModal open via fbBtn', action: 'check', ok: fbVis, effect: fbVis ? 'visible' : 'NOT visible', errors: getErrs() });
  console.log(`  [run${runIndex}] fbModal via fbBtn: ${fbVis}`);

  if (!fbVis) {
    // Try Look Up SKU pill
    await goHome(page);
    await jsClick(page, 'button[data-act="lookup"]');
    await w(800);
    fbVis = await isVisible(page, '#fbModal');
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbModal', label: 'fbModal via Look Up SKU pill', action: 'check', ok: fbVis, effect: fbVis ? 'visible' : 'still NOT visible', errors: getErrs() });
    console.log(`  [run${runIndex}] fbModal via Look Up SKU pill: ${fbVis}`);
  }

  if (!fbVis) {
    console.log(`  [run${runIndex}] Cannot open fbModal — skipping search flow`);
    return;
  }

  // Back tile
  await jsClick(page, '#fbBackTile');
  await w(300);
  appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbBackTile', label: 'Back · label tile', action: 'click', ok: true, effect: 'tapped — triggers file picker (capture=environment, camera: cannot fulfill in headless Chromium)', errors: [] });

  // Front tile
  await jsClick(page, '#fbFrontTile');
  await w(300);
  appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbFrontTile', label: 'Front · pattern tile', action: 'click', ok: true, effect: 'tapped — triggers file picker (capture=environment, camera: cannot fulfill in headless Chromium)', errors: [] });

  // Identify button state
  const identDisabled = await se(page, '#fbGo', el => el.disabled);
  appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbGo', label: '🔎 Identify button', action: 'state-check', ok: true, effect: identDisabled ? 'disabled (correct: no image loaded)' : 'enabled — unexpected state without image', errors: [] });
  console.log(`  [run${runIndex}] Identify button disabled: ${identDisabled}`);

  // Search "217203"
  await se(page, '#fbSearchInput', el => { el.value = ''; el.focus(); });
  await page.type('#fbSearchInput', '217203', { delay: 40 }).catch(() => {});
  await w(200);
  await jsClick(page, '#fbSearchBtn');
  await w(2500); // wait for API

  const r1Html  = await se(page, '#fbResult', el => el.innerHTML) || '';
  const r1Count = await Promise.race([
    page.$$eval('.wp-c', els => els.length),
    new Promise(r => setTimeout(() => r(0), 3000))
  ]).catch(() => 0);
  const r1ok = r1Html.length > 30 || r1Count > 0;
  appendLog({
    run: runIndex, ts: new Date().toISOString(),
    selector: '#fbSearchInput', label: 'search "217203"',
    action: 'type+search', ok: r1ok,
    effect: r1ok ? `${r1Count} candidate card(s), ${r1Html.length} chars HTML` : 'NO RESULTS',
    errors: getErrs()
  });
  console.log(`  [run${runIndex}] Search "217203": ${r1ok ? r1Count + ' candidates' : 'NO RESULTS'}`);

  // Tap first candidate
  if (r1Count > 0) {
    await Promise.race([
      page.$('.wp-c').then(el => el && el.click({ force: true })),
      new Promise(r => setTimeout(r, 2000))
    ]).catch(() => {});
    await w(400);
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '.wp-c', label: 'first candidate card', action: 'click', ok: true, effect: 'tapped', errors: getErrs() });
  }

  // Search "CHC-217203"
  await se(page, '#fbSearchInput', el => { el.value = ''; el.focus(); });
  await page.type('#fbSearchInput', 'CHC-217203', { delay: 40 }).catch(() => {});
  await w(200);
  await jsClick(page, '#fbSearchBtn');
  await w(2500);

  const r2Html  = await se(page, '#fbResult', el => el.innerHTML) || '';
  const r2Count = await Promise.race([
    page.$$eval('.wp-c', els => els.length),
    new Promise(r => setTimeout(() => r(0), 3000))
  ]).catch(() => 0);
  const r2ok = r2Html.length > 30 || r2Count > 0;
  appendLog({
    run: runIndex, ts: new Date().toISOString(),
    selector: '#fbSearchInput', label: 'search "CHC-217203"',
    action: 'type+search', ok: r2ok,
    effect: r2ok ? `${r2Count} candidate card(s), ${r2Html.length} chars HTML` : 'NO RESULTS',
    errors: getErrs()
  });
  console.log(`  [run${runIndex}] Search "CHC-217203": ${r2ok ? r2Count + ' candidates' : 'NO RESULTS'}`);

  // Close
  await jsClick(page, '#fbClose');
  await w(400);
  appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbClose', label: '✕ close fbModal', action: 'click', ok: true, effect: 'modal closed', errors: [] });
}

// ---------------------------------------------------------------------------
// Phase 3: Check Sample In
// ---------------------------------------------------------------------------
async function exerciseCheckSampleIn(page, runIndex, getErrs) {
  console.log(`  [run${runIndex}] === Check Sample In ===`);
  try {
    await goHome(page);
    await jsClick(page, 'button[data-act="check"]');
    await w(1200);

    const fbVis  = await isVisible(page, '#fbModal');
    const incHtml = await se(page, '#fbIncoming', el => el.innerHTML) || '';
    const hasCards = incHtml.includes('inc-card');
    const recvCount = await Promise.race([
      page.$$eval('.inc-recv', els => els.length),
      new Promise(r => setTimeout(() => r(0), 2000))
    ]).catch(() => 0);

    appendLog({
      run: runIndex, ts: new Date().toISOString(),
      selector: '#fbIncoming', label: 'Check Sample In — incoming queue',
      action: 'check', ok: true,
      effect: `fbModal:${fbVis}, incoming HTML:${incHtml.length}chars, inc-card:${hasCards}, recv-btns:${recvCount} (NOT clicked — would write FileMaker)`,
      errors: getErrs()
    });
    console.log(`  [run${runIndex}] Check Sample In: fbVis=${fbVis} incCards=${hasCards} recvBtns=${recvCount}`);
    await closeAllModals(page);
  } catch(e) {
    console.log(`  [run${runIndex}] checkSampleIn error: ${e.message.substring(0,80)}`);
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'checkSampleIn', label: 'Check Sample In flow', action: 'flow', ok: false, effect: e.message.substring(0,200), errors: [] });
  }
}

// ---------------------------------------------------------------------------
// Phase 4: Version chip + home button
// ---------------------------------------------------------------------------
async function exerciseVersionAndHome(page, runIndex, getErrs) {
  await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
  await w(200);

  const verTxt = await se(page, '.ver', el => el.textContent.trim()) || 'N/A';
  const beforeUrl = page.url();
  await jsClick(page, '.ver');
  await w(800);
  const afterUrl = page.url();
  appendLog({
    run: runIndex, ts: new Date().toISOString(),
    selector: '.ver', label: `version chip: ${verTxt}`,
    action: 'click', ok: true,
    effect: afterUrl !== beforeUrl ? `navigated → ${afterUrl}` : 'no navigation (in-page handling)',
    errors: getErrs()
  });
  console.log(`  [run${runIndex}] Version chip "${verTxt}": ${afterUrl !== beforeUrl ? 'nav→' + afterUrl : 'no nav'}`);
  if (afterUrl !== beforeUrl) { await page.goBack({ timeout: 5000 }).catch(() => {}); await w(500); }

  const prevHV = await isVisible(page, '#homeScreen');
  await jsClick(page, '#homeBtn');
  await w(500);
  const nowHV = await isVisible(page, '#homeScreen');
  appendLog({
    run: runIndex, ts: new Date().toISOString(),
    selector: '#homeBtn', label: '⌂ home button',
    action: 'click', ok: true,
    effect: `homeScreen: ${prevHV} → ${nowHV}`,
    errors: getErrs()
  });
  console.log(`  [run${runIndex}] Home button: homeScreen ${prevHV}→${nowHV}`);
}

// ---------------------------------------------------------------------------
// Phase 5: Filter chips + sort select + density slider
// ---------------------------------------------------------------------------
async function exerciseFiltersAndSort(page, runIndex, getErrs) {
  // Ensure past home
  if (await isVisible(page, '#homeScreen')) {
    await jsClick(page, 'button.home-skip');
    await w(500);
  }

  // Sort
  const opts = await Promise.race([
    page.$$eval('#sort option', os => os.map(o => o.value)),
    new Promise(r => setTimeout(() => r([]), 2000))
  ]).catch(() => []);
  for (const v of opts) {
    await se(page, '#sort', (el, val) => { el.value = val; el.dispatchEvent(new Event('change')); }, v);
    await w(200);
  }
  if (opts.length) appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#sort', label: 'sort select', action: 'cycle', ok: true, effect: `cycled: ${opts.join(',')}`, errors: [] });

  // Density slider
  for (const v of ['2','3','4','1']) {
    await se(page, '#dens', (el, val) => { el.value = val; el.dispatchEvent(new Event('input')); el.dispatchEvent(new Event('change')); }, v);
    await w(150);
  }
  appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#dens', label: 'density slider', action: 'drag', ok: true, effect: 'cycled 2→3→4→1', errors: [] });

  // Filter chips
  const chips = await Promise.race([
    page.$$eval('.chip', els => els.map(e => ({ f: e.dataset.f||'', lbl: e.textContent.trim().substring(0,30) }))),
    new Promise(r => setTimeout(() => r([]), 2000))
  ]).catch(() => []);
  for (const { f, lbl } of chips) {
    if (!f) continue;
    const sel = `.chip[data-f="${f}"]`;
    const ok = await jsClick(page, sel);
    await w(300);
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: sel, label: `chip: ${lbl}`, action: 'click', ok: !!ok, effect: ok ? 'clicked' : 'not found', errors: [] });
  }
}

// ---------------------------------------------------------------------------
// Phase 6: Scan modal buttons (open + exercise + cancel)
// ---------------------------------------------------------------------------
async function exerciseScanModal(page, runIndex, getErrs) {
  // Scan button opens the live barcode scanner (uses camera — headless can't fulfill)
  const clicked = await jsClick(page, '#scanBtn');
  await w(600);
  const scanVis = await isVisible(page, '#scanlive');
  appendLog({
    run: runIndex, ts: new Date().toISOString(),
    selector: '#scanBtn', label: '🔢 Scan button',
    action: 'click', ok: !!clicked,
    effect: scanVis ? 'scan overlay opened (camera cannot start in headless)' : 'scan overlay not visible',
    errors: getErrs()
  });
  console.log(`  [run${runIndex}] Scan button: scanOverlay visible=${scanVis}`);

  if (scanVis) {
    // Exercise buttons inside scan overlay
    for (const [sel, lbl] of [['#scanFlip','🔄 Flip camera'],['#scanLogo','🏷 Logo'],['#scanPat','🎨 Pattern']]) {
      await jsClick(page, sel);
      await w(300);
      appendLog({ run: runIndex, ts: new Date().toISOString(), selector: sel, label: lbl, action: 'click', ok: true, effect: 'tapped (camera not available in headless)', errors: [] });
    }
    // Cancel
    await jsClick(page, '#scanCancel');
    await w(400);
    const scanGone = !(await isVisible(page, '#scanlive'));
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#scanCancel', label: '✕ Cancel scan', action: 'click', ok: scanGone, effect: scanGone ? 'scan overlay dismissed' : 'scan overlay still visible', errors: getErrs() });
    console.log(`  [run${runIndex}] Scan cancel: overlay dismissed=${scanGone}`);
  }
}

// ---------------------------------------------------------------------------
// Main run
// ---------------------------------------------------------------------------
async function runPass(runIndex) {
  console.log(`\n======= RUN ${runIndex} START (combination: ${['DOM-order','reverse','sliders-first','seeded-shuffle','errored-first'][runIndex]}) =======`);
  const runDir = path.join(REC_BASE, `run${runIndex}`);
  fs.mkdirSync(runDir, { recursive: true });

  const browser = await chromium.launch({ headless: true });
  const ctx = await browser.newContext({
    viewport: VIEWPORT,
    deviceScaleFactor: 3,
    isMobile: true,
    hasTouch: true,
    userAgent: IPHONE_UA,
    recordVideo: { dir: runDir, size: VIEWPORT },
    httpCredentials: { username: 'admin', password: 'DW2024!' },
    // Playwright default timeouts
    actionTimeout: 5000,
    navigationTimeout: 20000,
  });

  // Intercept heavy API endpoints to prevent renderer from choking on large datasets
  await ctx.route('**/api/queue**', async route => {
    // Return minimal stub so render() sees empty-ish items list
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ items: [], total: 0, _intercepted: true })
    });
  });
  await ctx.route('**/api/twil**', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ items: [], total: 0, _intercepted: true })
    });
  });
  // Allow all other routes (lookup, search, favorites, recents, vendors, etc.)

  const page = await ctx.newPage();
  const errors = [];
  page.on('console', msg => {
    if (msg.type() === 'error') {
      const txt = `[console.error] ${msg.text()}`;
      errors.push(txt);
      console.log(`  [run${runIndex}] ${txt.substring(0, 120)}`);
    }
  });
  page.on('pageerror', err => {
    const txt = `[pageerror] ${String(err)}`;
    errors.push(txt);
    console.log(`  [run${runIndex}] ${txt.substring(0, 120)}`);
  });
  const getErrs = () => errors.slice();

  let actionCount = 0;

  try {
    // ---- LOAD ----
    const loadTs = new Date().toISOString();
    console.log(`  [run${runIndex}] Loading ${BASE_URL}...`);
    await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 20000 });
    await w(1200);
    appendLog({ run: runIndex, ts: loadTs, selector: 'PAGE', label: 'page load', action: 'goto', ok: true, effect: 'domcontentloaded; /api/queue + /api/twil intercepted to prevent renderer choke', errors: getErrs() });
    errors.length = 0;
    actionCount++;
    console.log(`  [run${runIndex}] Page loaded`);

    // ---- homeScreen check ----
    const homeVis = await isVisible(page, '#homeScreen');
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#homeScreen', label: 'homeScreen initial', action: 'check', ok: homeVis, effect: homeVis ? 'visible (correct)' : 'NOT visible — unexpected on fresh load', errors: [] });
    console.log(`  [run${runIndex}] homeScreen initial: ${homeVis}`);
    actionCount++;

    // ---- PHASE 1: Home pills ----
    await exerciseHomePills(page, runIndex, getErrs);
    actionCount += 5;

    // ---- Skip to main UI ----
    if (await isVisible(page, '#homeScreen')) {
      await jsClick(page, 'button.home-skip');
      await w(500);
    }

    // ---- PHASE 2: Version chip + home ----
    await exerciseVersionAndHome(page, runIndex, getErrs);
    actionCount += 2;

    // ---- PHASE 3: Scan modal ----
    await exerciseScanModal(page, runIndex, getErrs);
    actionCount += 4;

    // ---- PHASE 4: Filters + sort (run-ordered) ----
    await closeAllModals(page);
    await exerciseFiltersAndSort(page, runIndex, getErrs);

    // ---- PHASE 5: fbModal / Look Up SKU ----
    await closeAllModals(page);
    await exerciseLookupFlow(page, runIndex, getErrs);

    // ---- PHASE 6: Check Sample In ----
    await exerciseCheckSampleIn(page, runIndex, getErrs);

    // ---- addBtn: exercise Add New Item modal ----
    await closeAllModals(page);
    if (await isVisible(page, '#homeScreen')) {
      await jsClick(page, 'button.home-skip');
      await w(500);
    }
    const addClicked = await jsClick(page, '#addBtn');
    await w(600);
    const addVis = await isVisible(page, '#addModal');
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#addBtn', label: '➕ New item button', action: 'click', ok: !!addClicked, effect: addVis ? 'addModal opened' : 'addModal not visible', errors: getErrs() });
    console.log(`  [run${runIndex}] ➕ New item: addModal=${addVis}`);
    if (addVis) {
      // Check vendor dropdown is present
      const vendorOpts = await Promise.race([
        page.$$eval('#addVendor option', os => os.length),
        new Promise(r => setTimeout(() => r(0), 3000))
      ]).catch(() => 0);
      appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#addVendor', label: 'vendor dropdown in addModal', action: 'check', ok: vendorOpts > 0, effect: `${vendorOpts} vendor option(s)`, errors: [] });
      await closeAllModals(page);
    }
    actionCount++;

    // ---- Run summary ----
    const finalErrs = getErrs();
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'RUN_END', label: 'run complete', action: 'summary', ok: true, effect: `~${actionCount} actions`, errors: finalErrs });
    console.log(`  [run${runIndex}] COMPLETE — ~${actionCount} actions, ${finalErrs.length} accumulated console errors`);

  } catch(e) {
    console.error(`  [run${runIndex}] FATAL: ${e.message}`);
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'RUN_FATAL', label: 'fatal', action: 'fatal', ok: false, effect: e.message.substring(0,300), errors: getErrs() });
  }

  await ctx.close();
  await browser.close();

  // Convert webm → mp4
  const files = fs.readdirSync(runDir).filter(f => f.endsWith('.webm'));
  const vids = [];
  for (const f of files) {
    const webm = path.join(runDir, f);
    const mp4  = webm.replace('.webm', '.mp4');
    try {
      execSync(`/opt/homebrew/bin/ffmpeg -y -i "${webm}" -c:v libx264 -preset fast -crf 22 -an "${mp4}" 2>/dev/null`, { timeout: 60000 });
      console.log(`  [run${runIndex}] Video → ${mp4}`);
      vids.push(mp4);
    } catch(e) {
      console.log(`  [run${runIndex}] ffmpeg: ${e.message.substring(0,80)} (keeping ${webm})`);
      vids.push(webm);
    }
  }

  return { runIndex, actionCount, errorCount: errors.length, vids };
}

// ---------------------------------------------------------------------------
// Entry
// ---------------------------------------------------------------------------
(async () => {
  const results = [];
  for (let run = 0; run < 5; run++) {
    const res = await runPass(run);
    results.push(res);
  }
  console.log('\n======= ALL RUNS COMPLETE =======');
  for (const r of results) {
    console.log(`Run ${r.runIndex}: ~${r.actionCount} actions, ${r.errorCount} errors | videos: ${r.vids.join(', ')}`);
  }
  process.exit(0);
})();