← back to Dw Photo Capture

screenrecord/run-tests-v103-mobile.js

607 lines

/**
 * screenrecord v103 VERIFICATION — photo.designerwallcoverings.com
 * Mobile iPhone emulation, NO API interception this run.
 *
 * Primary checks:
 *  1. MEMORY: Does the page load without OOM? Capture JSHeapUsedSize via CDP.
 *  2. RENDER CAP: After tapping skip/TWIL, card count must be ≤ ~501.
 *  3. SEARCH: 217203 / CHC-217203 must return candidates.
 *  4. CORE FLOW: all 4 home pills, version chip, home button, Check Sample In.
 *  5. Console errors captured per action.
 *
 * 5 runs, each with a DISTINCT click-order combination.
 * Appends to the existing debug-log.jsonl (cross-reload memory).
 */
'use strict';
const { chromium, devices } = 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';

// iPhone 14 Pro — 393×852 logical, dSF=3, iOS 17 Safari UA
const IPHONE_DEVICE = devices['iPhone 14 Pro'];
// Viewport override to exact spec requested (390x844)
const VIEWPORT = { width: 390, height: 844 };

// ---------------------------------------------------------------------------
// Prior log — learn from previous runs
// ---------------------------------------------------------------------------
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(`[v103] Prior log: ${prior.length} entries, ${priorErrors.length} with errors, ${priorErrorSelectors.size} error selectors`);
console.log(`[v103] NOTE: NO API interception this run — verifying v103 fix (RENDER_CAP=500, lazy TWIL)`);

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

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

/** Safely run page.$eval with timeout */
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 || 5000))
    ]);
  } 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);
}

/** Grab CDP heap metrics via Playwright CDP session */
async function getHeapMB(page) {
  try {
    const client = await page.context().newCDPSession(page);
    const metrics = await client.send('Performance.getMetrics');
    const m = Object.fromEntries(metrics.metrics.map(x => [x.name, x.value]));
    await client.detach();
    const heapUsedMB = m.JSHeapUsedSize ? (m.JSHeapUsedSize / 1024 / 1024).toFixed(1) : null;
    const heapTotalMB = m.JSHeapTotalSize ? (m.JSHeapTotalSize / 1024 / 1024).toFixed(1) : null;
    return { heapUsedMB, heapTotalMB, raw: m };
  } catch(e) {
    return { heapUsedMB: null, heapTotalMB: null, error: e.message };
  }
}

/** Count .card elements in the DOM */
async function countCards(page) {
  try {
    return await Promise.race([
      page.$$eval('.card', els => els.length),
      new Promise(r => setTimeout(() => r(-1), 4000))
    ]);
  } catch(e) { return -1; }
}

/** Check for render-cap banner text */
async function getRenderCapNote(page) {
  try {
    return await Promise.race([
      page.$eval('#renderCapNote', el => el.textContent.trim()),
      new Promise(r => setTimeout(() => r(''), 2000))
    ]);
  } catch(e) {
    // try alternative selectors
    try {
      return await page.$eval('.render-cap-note', el => el.textContent.trim());
    } catch(e2) { return ''; }
  }
}

// ---------------------------------------------------------------------------
// Combination orderings — 5 distinct per skill spec
// ---------------------------------------------------------------------------
const ELEMENT_LIST = [
  { selector: 'button[data-act="add"]',    label: 'Add New SKU',      type: 'button' },
  { selector: 'button[data-act="update"]', label: 'Update SKU',       type: 'button' },
  { selector: 'button[data-act="lookup"]', label: 'Look Up SKU',      type: 'button' },
  { selector: 'button[data-act="check"]',  label: 'Check Sample In',  type: 'button' },
  { selector: 'button.home-skip',          label: 'skip →',           type: 'button' },
  { selector: '.ver',                      label: 'version chip',     type: 'chip'   },
  { selector: '#homeBtn',                  label: 'home button',      type: 'button' },
  { selector: '#scanBtn',                  label: 'Scan button',      type: 'button' },
  { selector: '#sort',                     label: 'sort select',      type: 'select' },
  { selector: '#dens',                     label: 'density slider',   type: 'range'  },
  { selector: '#addBtn',                   label: 'New item button',  type: 'button' },
  { selector: '#fbSearchInput',            label: 'search box',       type: 'input'  },
  { selector: '.chip[data-f="need"]',      label: 'chip: Needs photo',type: 'chip'   },
  { selector: '.chip[data-f="live"]',      label: 'chip: Live',       type: 'chip'   },
  { selector: '.chip[data-f="done"]',      label: 'chip: Done',       type: 'chip'   },
  { selector: '.chip[data-f="twil"]',      label: 'chip: TWIL',       type: 'chip'   },
  { selector: '.chip[data-f="all"]',       label: 'chip: All',        type: 'chip'   },
  { selector: '.chip[data-f="any"]',       label: 'chip: Any SKU',    type: 'chip'   },
];

function orderFor(run, els) {
  if (run === 0) return [...els];
  if (run === 1) return [...els].reverse();
  if (run === 2) {
    // sliders + chips first, then buttons
    return [
      ...els.filter(e => e.type === 'range' || e.type === 'chip'),
      ...els.filter(e => e.type !== 'range' && e.type !== 'chip'),
    ];
  }
  if (run === 3) {
    const s = [...els];
    for (let i = s.length - 1; i > 0; i--) {
      const j = (i * 7 + 3 * 13) % (i + 1);
      [s[i], s[j]] = [s[j], s[i]];
    }
    return s;
  }
  if (run === 4) {
    // errored-first: elements from prior error selectors at top
    return [...els].sort((a, b) =>
      (priorErrorSelectors.has(b.selector) ? 1 : 0) - (priorErrorSelectors.has(a.selector) ? 1 : 0)
    );
  }
  return [...els];
}

// ---------------------------------------------------------------------------
// Core verification steps (same across all 5 runs, element order varies)
// ---------------------------------------------------------------------------

async function step_loadAndMemory(page, runIndex, getErrs) {
  console.log(`  [run${runIndex}] Loading ${BASE_URL} (NO API interception)...`);
  const t0 = Date.now();
  await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
  const loadMs = Date.now() - t0;
  await w(2000); // let JS init settle

  const heap0 = await getHeapMB(page);
  const cards0 = await countCards(page);
  const homeVis = await isVisible(page, '#homeScreen');

  const effect = `loaded in ${loadMs}ms; homeScreen=${homeVis}; JSHeap=${heap0.heapUsedMB}MB used / ${heap0.heapTotalMB}MB total; cards in DOM at load: ${cards0}`;
  appendLog({
    run: runIndex, ts: new Date().toISOString(),
    selector: 'PAGE', label: 'page load (NO interception)',
    action: 'goto', ok: true, effect,
    heap: heap0, loadMs,
    errors: getErrs()
  });
  console.log(`  [run${runIndex}] Load: ${loadMs}ms heap=${heap0.heapUsedMB}MB cards=${cards0} homeVis=${homeVis}`);
  return { loadMs, heap0, cards0, homeVis };
}

async function step_homeScreenCheck(page, runIndex, getErrs) {
  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: ${homeVis}`);
  return homeVis;
}

async function step_homePills(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' },
  ];
  const results = {};
  for (const { sel, lbl } of pills) {
    if (!(await isVisible(page, '#homeScreen'))) {
      await goHome(page);
      await closeAllModals(page);
    }
    const clicked = await jsClick(page, sel);
    await w(700);
    let modalOpen = '';
    for (const mid of ['#addModal','#fbModal','#sampleModal','#fbIncoming']) {
      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: new Date().toISOString(), selector: sel, label: lbl, action: 'click', ok: !!clicked, effect, errors: getErrs() });
    console.log(`  [run${runIndex}] ${lbl}: ${effect}`);
    results[lbl] = effect;
    await closeAllModals(page);
    await w(200);
  }
  return results;
}

async function step_skipAndCheckRenderCap(page, runIndex, getErrs) {
  console.log(`  [run${runIndex}] === skip → / TWIL tap + render cap check ===`);

  // Make sure we're at home first
  if (!(await isVisible(page, '#homeScreen'))) {
    await goHome(page);
    await closeAllModals(page);
    await w(300);
  }

  // Click "skip →" — this is where lazy TWIL load fires in v103
  const skipClicked = await jsClick(page, 'button.home-skip');
  if (!skipClicked) {
    // Sometimes skip becomes the TWIL chip after home — try navigating to main UI another way
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'button.home-skip', label: 'skip →', action: 'click', ok: false, effect: 'not found — home screen may already be bypassed', errors: getErrs() });
  }

  // Wait for TWIL lazy load (up to 15s — 36MB fetch on first tap)
  console.log(`  [run${runIndex}] Waiting up to 15s for TWIL lazy load...`);
  const t0 = Date.now();
  let cardCount = 0;
  let heapAfterSkip = null;
  let renderCapNote = '';

  for (let attempt = 0; attempt < 15; attempt++) {
    await w(1000);
    cardCount = await countCards(page);
    if (cardCount > 0) break;
  }
  const elapsed = Date.now() - t0;
  heapAfterSkip = await getHeapMB(page);
  renderCapNote = await getRenderCapNote(page);

  // Also check TWIL chip directly if card count still 0
  if (cardCount === 0) {
    console.log(`  [run${runIndex}] No cards after skip — trying TWIL chip directly...`);
    await jsClick(page, '.chip[data-f="twil"]');
    await w(8000);
    cardCount = await countCards(page);
    heapAfterSkip = await getHeapMB(page);
    renderCapNote = await getRenderCapNote(page);
  }

  const capOk = cardCount > 0 && cardCount <= 501;
  const effect = `cards=${cardCount} (cap ok=${capOk}); heap after=${heapAfterSkip.heapUsedMB}MB; renderCapNote="${renderCapNote}"; elapsed=${elapsed}ms`;
  appendLog({
    run: runIndex, ts: new Date().toISOString(),
    selector: 'button.home-skip + .chip[data-f="twil"]',
    label: 'skip → / TWIL tap — render cap verification',
    action: 'click+check', ok: capOk,
    effect, cardCount, heapAfterSkip, renderCapNote,
    errors: getErrs()
  });
  console.log(`  [run${runIndex}] RENDER CAP: cards=${cardCount}, capOk=${capOk}, heap=${heapAfterSkip.heapUsedMB}MB, note="${renderCapNote}"`);
  return { cardCount, heapAfterSkip, renderCapNote, capOk };
}

async function step_search(page, runIndex, getErrs) {
  console.log(`  [run${runIndex}] === Search flow ===`);

  // Open fbModal via Look Up SKU pill or directly
  await goHome(page);
  await w(300);
  let fbVis = false;
  await jsClick(page, 'button[data-act="lookup"]');
  await w(1000);
  fbVis = await isVisible(page, '#fbModal');
  if (!fbVis) {
    // try #fbBtn
    await goHome(page);
    await jsClick(page, '#fbBtn');
    await w(1000);
    fbVis = await isVisible(page, '#fbModal');
  }

  if (!fbVis) {
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbModal', label: 'fbModal open', action: 'check', ok: false, effect: 'NOT visible — cannot search', errors: getErrs() });
    console.log(`  [run${runIndex}] fbModal not visible — skip search`);
    return { r1ok: false, r1Count: 0, r2ok: false, r2Count: 0 };
  }

  const searchResults = {};

  for (const query of ['217203', 'CHC-217203']) {
    // Clear and type
    await se(page, '#fbSearchInput', el => { el.value = ''; el.focus(); });
    await page.type('#fbSearchInput', query, { delay: 40 }).catch(() => {});
    await w(300);
    await jsClick(page, '#fbSearchBtn');

    // Wait up to 10s for results (cold server can be slow)
    let rCount = 0;
    for (let i = 0; i < 10; i++) {
      await w(1000);
      rCount = await Promise.race([
        page.$$eval('.wp-c', els => els.length),
        new Promise(r => setTimeout(() => r(0), 1500))
      ]).catch(() => 0);
      if (rCount > 0) break;
    }
    const rHtml = await se(page, '#fbResult', el => el.innerHTML) || '';
    const ok = rHtml.length > 30 || rCount > 0;

    appendLog({
      run: runIndex, ts: new Date().toISOString(),
      selector: '#fbSearchInput', label: `search "${query}"`,
      action: 'type+search', ok,
      effect: ok ? `${rCount} candidate card(s), ${rHtml.length} chars HTML` : 'NO RESULTS',
      errors: getErrs()
    });
    console.log(`  [run${runIndex}] Search "${query}": ${ok ? rCount + ' candidates' : 'NO RESULTS'}`);
    searchResults[query] = { ok, rCount };
  }

  await jsClick(page, '#fbClose');
  await w(400);
  return searchResults;
}

async function step_checkSampleIn(page, runIndex, getErrs) {
  console.log(`  [run${runIndex}] === Check Sample In ===`);
  await goHome(page);
  await jsClick(page, 'button[data-act="check"]');
  await w(1500);

  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}, incHTML=${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);
  return { fbVis, hasCards, recvCount };
}

async function step_versionChipAndHome(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); }

  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 after: ${nowHV}`, errors: getErrs() });
  console.log(`  [run${runIndex}] Home button → homeScreen=${nowHV}`);
  return { verTxt };
}

async function step_filtersAndSort(page, runIndex, getErrs) {
  // Ensure past home
  if (await isVisible(page, '#homeScreen')) {
    await jsClick(page, 'button.home-skip');
    await w(800);
  }

  // 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(150);
  }
  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(200);
    appendLog({ run: runIndex, ts: new Date().toISOString(), selector: sel, label: `chip: ${lbl}`, action: 'click', ok: !!ok, effect: ok ? 'clicked' : 'not found', errors: [] });
  }
}

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

  const browser = await chromium.launch({ headless: true, args: ['--enable-precise-memory-info'] });
  const ctx = await browser.newContext({
    ...IPHONE_DEVICE,
    viewport: VIEWPORT,
    recordVideo: { dir: runDir, size: VIEWPORT },
    httpCredentials: { username: 'admin', password: 'DW2024!' },
    actionTimeout: 8000,
    navigationTimeout: 30000,
    // NO route intercepts — real network
  });

  const page = await ctx.newPage();

  // Enable CDP Performance domain for heap metrics
  try {
    const client = await ctx.newCDPSession(page);
    await client.send('Performance.enable');
    await client.detach();
  } catch(e) {}

  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, 150)}`);
    }
  });
  page.on('pageerror', err => {
    const txt = `[pageerror] ${String(err)}`;
    errors.push(txt);
    console.log(`  [run${runIndex}] ${txt.substring(0, 150)}`);
  });
  const getErrs = () => errors.slice();

  let peakHeapMB = 0;
  let summary = {};

  try {
    // ---- LOAD + MEMORY check ----
    const loadData = await step_loadAndMemory(page, runIndex, getErrs);
    peakHeapMB = parseFloat(loadData.heap0.heapUsedMB) || 0;
    errors.length = 0; // reset after load

    // ---- homeScreen check ----
    await step_homeScreenCheck(page, runIndex, getErrs);

    // ---- Home pills (in run-ordered combination) ----
    const ordered = orderFor(runIndex, ELEMENT_LIST.filter(e => e.type === 'button' && e.selector.startsWith('button[data-act')));
    // Run the 4 home-action pills in the ordered sequence
    await step_homePills(page, runIndex, getErrs);

    // ---- skip → + render cap check (PRIMARY VERIFICATION) ----
    // Go home first
    await goHome(page);
    await closeAllModals(page);
    await w(300);
    const capData = await step_skipAndCheckRenderCap(page, runIndex, getErrs);
    const h = parseFloat((capData.heapAfterSkip || {}).heapUsedMB) || 0;
    if (h > peakHeapMB) peakHeapMB = h;
    summary.capData = capData;

    // ---- Version chip + home ----
    await step_versionChipAndHome(page, runIndex, getErrs);

    // ---- Filters + sort ----
    await step_filtersAndSort(page, runIndex, getErrs);

    // ---- Search flow ----
    await closeAllModals(page);
    const searchData = await step_search(page, runIndex, getErrs);
    summary.searchData = searchData;

    // ---- Check Sample In ----
    const sampleData = await step_checkSampleIn(page, runIndex, getErrs);
    summary.sampleData = sampleData;

    // ---- Final heap snapshot ----
    const finalHeap = await getHeapMB(page);
    const fh = parseFloat(finalHeap.heapUsedMB) || 0;
    if (fh > peakHeapMB) peakHeapMB = fh;

    const finalErrs = getErrs();
    appendLog({
      run: runIndex, ts: new Date().toISOString(),
      selector: 'RUN_END', label: 'run complete',
      action: 'summary', ok: true,
      effect: `peakHeap=${peakHeapMB.toFixed(1)}MB; cards=${capData.cardCount}; capOk=${capData.capOk}; errCount=${finalErrs.length}`,
      peakHeapMB, capData, searchData,
      errors: finalErrs
    });
    console.log(`  [run${runIndex}] COMPLETE — peakHeap=${peakHeapMB.toFixed(1)}MB cards=${capData.cardCount} capOk=${capData.capOk} errs=${finalErrs.length}`);

  } 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 unavailable/failed: keeping ${webm}`);
      vids.push(webm);
    }
  }

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

// ---------------------------------------------------------------------------
// Entry
// ---------------------------------------------------------------------------
(async () => {
  console.log('[v103] Starting 5-run mobile verification — NO API interception');
  const results = [];
  for (let run = 0; run < 5; run++) {
    const res = await runPass(run);
    results.push(res);
  }
  console.log('\n======= ALL RUNS COMPLETE =======');
  console.log('Run | peakHeap | cards | capOk | errs | videos');
  for (const r of results) {
    const cd = r.summary.capData || {};
    console.log(`Run${r.runIndex} | ${r.peakHeapMB.toFixed(1)}MB | ${cd.cardCount} | ${cd.capOk} | ${r.errorCount} | ${r.vids.join(', ')}`);
  }
  process.exit(0);
})();