← back to CelebritySignatures

scripts/screenrecord-murals.mjs

591 lines

/**
 * screenrecord-murals.mjs
 * 5-pass Playwright screen recorder for http://localhost:9920/murals
 * Each run uses a distinct element-ordering combination.
 * Appends JSONL to screenrecord/debug-log.jsonl after every action.
 */
import { chromium } from '/Users/macstudio3/.npm-global/lib/node_modules/playwright/index.mjs';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE = path.resolve(__dirname, '..');
const LOG  = path.join(BASE, 'screenrecord/debug-log.jsonl');
const RECBASE = path.join(BASE, 'screenrecord/rec');
const URL  = 'http://localhost:9920/murals';

// ── read prior runs ──
const prior = fs.existsSync(LOG)
  ? fs.readFileSync(LOG,'utf8').trim().split('\n').filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean)
  : [];
const priorErrors = prior.filter(r => r.errors && r.errors.length > 0);
const priorErrSelectors = new Set(priorErrors.map(e => e.selector));
console.log(`Prior log lines: ${prior.length}, prior errors: ${priorErrors.length}`);
if (priorErrors.length) {
  console.log('Prior errored selectors:', [...priorErrSelectors].slice(0,10));
}

const append = o => fs.appendFileSync(LOG, JSON.stringify(o)+'\n');

// ── ordering strategies ──
function orderFor(run, els) {
  if (run === 0) return [...els];   // DOM order
  if (run === 1) return [...els].reverse();  // reverse
  if (run === 2) {
    // sliders / range inputs first, then number inputs, then buttons/links
    const priority = el => el.type === 'range' ? 0 : el.type === 'number' ? 1 : el.type === 'search' ? 2 : 3;
    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 + 13*3) % (i+1);
      [s[i],s[j]] = [s[j],s[i]];
    }
    return s;
  }
  if (run === 4) {
    // errored-first from prior runs
    return [...els].sort((a,b) => (priorErrSelectors.has(b.sel)?1:0) - (priorErrSelectors.has(a.sel)?1:0));
  }
  return [...els];
}

// ── define the interactive surface ──
// This is the complete list of elements to exercise.
// We discover dynamic elements inside the page after boot.

const STATIC_ELEMENTS = [
  // NAV LINKS (hash links are safe; / link navigates away so we observe then come back)
  { sel: 'header nav a[href="#studio"]',  label: 'Nav: Wall studio',        action: 'click', type: 'link' },
  { sel: 'header nav a[href="#order"]',   label: 'Nav: Order',              action: 'click', type: 'link' },
  { sel: 'header nav a[href="/"]',        label: 'Nav: Signature archive',  action: 'click-navigate', type: 'link' },

  // NAME FINDER
  { sel: '#finderInput',  label: 'Finder: search input',  action: 'type:lincoln', type: 'search' },
  { sel: '#finderInput',  label: 'Finder: clear + type babe ruth', action: 'clear+type:babe ruth', type: 'search' },
  { sel: '#finderInput',  label: 'Finder: clear + type einstein', action: 'clear+type:einstein', type: 'search' },

  // STUDIO: MURAL SELECT
  { sel: '#selMural',  label: 'Studio: Mural select - declaration', action: 'select-option:d',  type: 'select' },
  { sel: '#selMural',  label: 'Studio: Mural select - politics',    action: 'select-option:p',  type: 'select' },
  { sel: '#selMural',  label: 'Studio: Mural select - composers',   action: 'select-option:co', type: 'select' },

  // STUDIO: CUSTOM SIZE INPUTS
  { sel: '#murW',  label: 'Studio: Mural width - set 30',   action: 'set-value:30',  type: 'number' },
  { sel: '#murH',  label: 'Studio: Mural height - set 10',  action: 'set-value:10',  type: 'number' },
  { sel: '#murW',  label: 'Studio: Mural width - set 16',   action: 'set-value:16',  type: 'number' },
  { sel: '#murH',  label: 'Studio: Mural height - set 8',   action: 'set-value:8',   type: 'number' },
  { sel: '#murW',  label: 'Studio: Mural width - set 40',   action: 'set-value:40',  type: 'number' },
  { sel: '#murH',  label: 'Studio: Mural height - set 16',  action: 'set-value:16',  type: 'number' },

  // STUDIO: WALL SLIDERS
  { sel: '#wallW',  label: 'Studio: Wall width slider - 40',  action: 'set-slider:40',  type: 'range' },
  { sel: '#wallH',  label: 'Studio: Wall height slider - 16', action: 'set-slider:16',  type: 'range' },
  { sel: '#wallW',  label: 'Studio: Wall width slider - 60',  action: 'set-slider:60',  type: 'range' },
  { sel: '#wallH',  label: 'Studio: Wall height slider - 20', action: 'set-slider:20',  type: 'range' },
  { sel: '#wallW',  label: 'Studio: Wall width slider - 24',  action: 'set-slider:24',  type: 'range' },
  { sel: '#wallH',  label: 'Studio: Wall height slider - 12', action: 'set-slider:12',  type: 'range' },

  // STUDIO: CENTER BUTTON
  { sel: '#btnCenter',  label: 'Studio: Center on wall',  action: 'click', type: 'button' },

  // STUDIO: SCENE PRESETS (will be discovered dynamically after boot)
  { sel: '#scenePresets button[data-scene="study"]',    label: 'Scene preset: Study',   action: 'click', type: 'button' },
  { sel: '#scenePresets button[data-scene="lounge"]',   label: 'Scene preset: Lounge',  action: 'click', type: 'button' },
  { sel: '#scenePresets button[data-scene="gallery"]',  label: 'Scene preset: Gallery', action: 'click', type: 'button' },
  { sel: '#scenePresets button[data-scene="plain"]',    label: 'Scene preset: Plain',   action: 'click', type: 'button' },

  // ORDER FORM INPUTS
  { sel: 'input[name="name"]',   label: 'Order: Name input',        action: 'type:John Test',         type: 'input' },
  { sel: 'input[name="email"]',  label: 'Order: Email input',       action: 'type:test@example.com',  type: 'input' },
  { sel: 'input[name="wall_w"]', label: 'Order: Wall width input',  action: 'set-value:24',           type: 'number' },
  { sel: 'input[name="wall_h"]', label: 'Order: Wall height input', action: 'set-value:12',           type: 'number' },
  { sel: 'textarea[name="notes"]', label: 'Order: Notes textarea', action: 'type:Test order for master bedroom. Target install October.',  type: 'input' },
  { sel: '#orderForm button[type="submit"]', label: 'Order: Submit form', action: 'click', type: 'button' },
];

// Dynamic elements to discover per-page-load (gallery cards)
const GALLERY_CARD_ELEMENTS = [
  // Show names toggles (first 4 cards)
  { sel: '.m-card:nth-child(1) .name-toggle',  label: 'Gallery card 1: Show names toggle',   action: 'click', type: 'button' },
  { sel: '.m-card:nth-child(1) .name-toggle',  label: 'Gallery card 1: Hide names toggle',   action: 'click', type: 'button' },
  { sel: '.m-card:nth-child(2) .name-toggle',  label: 'Gallery card 2: Show names toggle',   action: 'click', type: 'button' },
  { sel: '.m-card:nth-child(3) .name-toggle',  label: 'Gallery card 3: Show names toggle',   action: 'click', type: 'button' },

  // Roster drawers
  { sel: '.m-card:nth-child(1) .roster-trig',  label: 'Gallery card 1: Roster drawer open',  action: 'click', type: 'button' },
  { sel: '.m-card:nth-child(2) .roster-trig',  label: 'Gallery card 2: Roster drawer open',  action: 'click', type: 'button' },
  { sel: '.m-card:nth-child(3) .roster-trig',  label: 'Gallery card 3: Roster drawer open',  action: 'click', type: 'button' },

  // Place on wall buttons
  { sel: '.m-card:nth-child(1) [data-place]',  label: 'Gallery card 1: Place on wall',  action: 'click', type: 'button' },
  { sel: '.m-card:nth-child(2) [data-place]',  label: 'Gallery card 2: Place on wall',  action: 'click', type: 'button' },
  { sel: '.m-card:nth-child(3) [data-place]',  label: 'Gallery card 3: Place on wall',  action: 'click', type: 'button' },
];

// Roster filter search (only after a roster is open)
const ROSTER_FILTER = [
  { sel: '.m-card:nth-child(1) .r-search',  label: 'Roster filter search: type adams',  action: 'type:adams',  type: 'search' },
  { sel: '.m-card:nth-child(2) .r-search',  label: 'Roster filter search: type bach',   action: 'type:bach',   type: 'search' },
];

// Mural drag action (special handling)
const DRAG_ACTION = { sel: '#muralEl', label: 'Studio: Drag mural element', action: 'drag', type: 'drag' };

const ALL_ELEMENTS = [...GALLERY_CARD_ELEMENTS, ...STATIC_ELEMENTS];

async function doAction(page, el, errs, run) {
  const ts = new Date().toISOString();
  const result = { run, ts, selector: el.sel, label: el.label, action: el.action, ok: false, effect: '', errors: [] };

  try {
    // Clear per-action error buffer
    errs.splice(0);

    // Locate element
    const handle = page.locator(el.sel).first();
    const exists = await handle.count() > 0;
    if (!exists) {
      result.effect = 'ELEMENT_NOT_FOUND';
      result.errors = [`Element not found: ${el.sel}`];
      append(result);
      return result;
    }

    // scrollIntoView
    await handle.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
    await page.waitForTimeout(200);

    // snapshot before
    const before = await page.evaluate((sel) => {
      const el = document.querySelector(sel);
      if (!el) return null;
      return {
        text: el.textContent?.trim().slice(0, 120),
        value: el.value || null,
        class: el.className?.slice(0, 80),
        display: getComputedStyle(el).display,
        visible: !!(el.offsetWidth || el.offsetHeight),
      };
    }, el.sel).catch(() => null);

    // perform action
    if (el.action === 'click-navigate') {
      // Click a link that navigates away, then go back
      await handle.click({ timeout: 8000, force: true });
      await page.waitForTimeout(1500);
      await page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => {});
      await page.waitForTimeout(500);
      result.ok = true;
      result.effect = 'CLICKED_NAVIGATE_WENT_BACK';
      append(result);
      return result;

    } else if (el.action === 'click') {
      // Scroll into view with extra top offset to clear sticky header (~80px)
      await page.evaluate((sel) => {
        const el = document.querySelector(sel);
        if (el) {
          const rect = el.getBoundingClientRect();
          if (rect.top < 90) window.scrollBy(0, rect.top - 100);
        }
      }, el.sel).catch(() => {});
      await page.waitForTimeout(150);
      await handle.click({ timeout: 8000, force: true });

    } else if (el.action.startsWith('type:')) {
      const text = el.action.slice(5);
      await handle.fill(text, { timeout: 5000 });
      await handle.dispatchEvent('input');

    } else if (el.action.startsWith('clear+type:')) {
      const text = el.action.slice(11);
      await handle.fill('', { timeout: 5000 });
      await handle.fill(text, { timeout: 5000 });
      await handle.dispatchEvent('input');

    } else if (el.action.startsWith('set-value:')) {
      const val = el.action.slice(10);
      await handle.fill(val, { timeout: 5000 });
      await handle.dispatchEvent('input');
      await handle.dispatchEvent('change');

    } else if (el.action.startsWith('set-slider:')) {
      const val = el.action.slice(11);
      await handle.fill(val, { timeout: 5000 });
      await handle.dispatchEvent('input');

    } else if (el.action.startsWith('select-option:')) {
      const val = el.action.slice(14);
      await handle.selectOption(val, { timeout: 5000 });

    } else if (el.action === 'drag') {
      // Drag mural 100px right then back
      const box = await handle.boundingBox();
      if (box) {
        const cx = box.x + box.width/2;
        const cy = box.y + box.height/2;
        await page.mouse.move(cx, cy);
        await page.mouse.down();
        await page.waitForTimeout(100);
        await page.mouse.move(cx + 80, cy + 20, { steps: 10 });
        await page.waitForTimeout(200);
        await page.mouse.move(cx + 150, cy, { steps: 10 });
        await page.waitForTimeout(200);
        await page.mouse.up();
      }
    }

    await page.waitForTimeout(400);

    // snapshot after
    const after = await page.evaluate((sel) => {
      const el = document.querySelector(sel);
      if (!el) return null;
      return {
        text: el.textContent?.trim().slice(0, 120),
        value: el.value || null,
        class: el.className?.slice(0, 80),
        display: getComputedStyle(el).display,
        visible: !!(el.offsetWidth || el.offsetHeight),
      };
    }, el.sel).catch(() => null);

    // capture readout state for studio actions
    let studioReadout = null;
    if (el.sel.includes('mur') || el.sel.includes('wall') || el.sel === '#btnCenter' || el.action === 'drag') {
      studioReadout = await page.evaluate(() => {
        const r = document.querySelector('#readout');
        return r ? r.textContent?.trim().slice(0, 300) : null;
      }).catch(() => null);
    }

    // check sig count change for mural size changes
    let sigCount = null;
    if (el.sel.includes('mur') || el.sel.includes('selMural')) {
      sigCount = await page.evaluate(() => {
        const r = document.querySelector('#readout');
        if (!r) return null;
        const m = r.textContent.match(/Signatures shown:\s*(\d+)/);
        return m ? parseInt(m[1]) : null;
      }).catch(() => null);
    }

    // check order summary
    let orderSummary = null;
    if (el.sel.includes('mur') || el.sel.includes('wall') || el.sel.includes('selMural')) {
      orderSummary = await page.evaluate(() => {
        const s = document.querySelector('#orderSummary');
        return s ? s.textContent?.trim().slice(0, 200) : null;
      }).catch(() => null);
    }

    result.ok = true;
    result.effect = JSON.stringify({
      before: before?.text?.slice(0,60) || before?.value,
      after: after?.text?.slice(0,60) || after?.value,
      studioReadout: studioReadout?.slice(0,200),
      sigCount,
      orderSummary: orderSummary?.slice(0,100),
    });

  } catch(err) {
    result.ok = false;
    result.effect = 'ACTION_FAILED';
    result.errors = [String(err)];
  }

  // attach any console errors that fired
  if (errs.length) {
    result.errors = [...result.errors, ...errs.slice()];
    errs.splice(0);
  }

  append(result);
  return result;
}

async function runRosterFilter(page, errs, run, cardN, filterText) {
  // Only works if roster is open
  const sel = `.m-card:nth-child(${cardN}) .r-search`;
  const el = { sel, label: `Roster card ${cardN}: filter "${filterText}"`, action: `type:${filterText}`, type: 'search' };
  const handle = page.locator(sel).first();
  const visible = await handle.count() > 0;
  if (!visible) {
    append({ run, ts: new Date().toISOString(), selector: sel, label: el.label, action: el.action, ok: false, effect: 'ROSTER_NOT_OPEN', errors: [] });
    return;
  }
  await doAction(page, el, errs, run);
}

async function doRosterItemClick(page, errs, run, cardN) {
  // Click first roster item to trigger provenance modal
  const sel = `.m-card:nth-child(${cardN}) .r-item:first-child`;
  const handle = page.locator(sel).first();
  if (await handle.count() === 0) {
    append({ run, ts: new Date().toISOString(), selector: sel, label: `Roster card ${cardN}: click first item`, action: 'click', ok: false, effect: 'NO_ROSTER_ITEMS', errors: [] });
    return;
  }
  await doAction(page, { sel, label: `Roster card ${cardN}: click roster item → provenance modal`, action: 'click', type: 'button' }, errs, run);
  await page.waitForTimeout(300);

  // Check modal opened
  const modalOpen = await page.evaluate(() => {
    const m = document.querySelector('#provModal');
    return m && m.classList.contains('open');
  }).catch(() => false);

  append({ run, ts: new Date().toISOString(), selector: '#provModal', label: 'Provenance modal: opened?', action: 'observe', ok: modalOpen, effect: modalOpen ? 'MODAL_OPEN' : 'MODAL_NOT_OPEN', errors: [] });

  if (modalOpen) {
    // Close modal
    await doAction(page, { sel: '#provX', label: 'Provenance modal: close (X button)', action: 'click', type: 'button' }, errs, run);
  }
}

async function doDrag(page, errs, run) {
  const el = page.locator('#muralEl').first();
  if (await el.count() === 0) {
    append({ run, ts: new Date().toISOString(), selector: '#muralEl', label: 'Studio: Drag mural', action: 'drag', ok: false, effect: 'MURAL_EL_NOT_FOUND', errors: [] });
    return;
  }

  errs.splice(0);
  const ts = new Date().toISOString();
  try {
    await el.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
    await page.waitForTimeout(300);

    const box = await el.boundingBox();
    if (!box) throw new Error('no boundingBox');

    const before = await page.evaluate(() => {
      const m = document.querySelector('#muralEl');
      return m ? { left: m.style.left, top: m.style.top } : null;
    });

    const cx = box.x + box.width/2;
    const cy = box.y + box.height/2;
    await page.mouse.move(cx, cy);
    await page.mouse.down();
    await page.waitForTimeout(150);
    await page.mouse.move(cx + 60, cy - 20, { steps: 15 });
    await page.waitForTimeout(150);
    await page.mouse.move(cx + 120, cy, { steps: 15 });
    await page.waitForTimeout(200);
    await page.mouse.up();
    await page.waitForTimeout(400);

    const after = await page.evaluate(() => {
      const m = document.querySelector('#muralEl');
      return m ? { left: m.style.left, top: m.style.top } : null;
    });

    const readout = await page.evaluate(() => {
      const r = document.querySelector('#readout');
      return r ? r.textContent?.trim().slice(0, 300) : null;
    });

    const moved = before && after && (before.left !== after.left || before.top !== after.top);
    append({ run, ts, selector: '#muralEl', label: 'Studio: Drag mural', action: 'drag', ok: moved, effect: JSON.stringify({ before, after, readout: readout?.slice(0,150), moved }), errors: errs.splice(0) });

  } catch(err) {
    append({ run, ts, selector: '#muralEl', label: 'Studio: Drag mural', action: 'drag', ok: false, effect: 'DRAG_FAILED', errors: [String(err), ...errs.splice(0)] });
  }
}

// ── main 5-run loop ──
const RUN_LABELS = [
  'run0: DOM order',
  'run1: reverse order',
  'run2: sliders-first',
  'run3: seeded shuffle',
  'run4: errored-first',
];

for (let run = 0; run < 5; run++) {
  console.log(`\n=== ${RUN_LABELS[run]} ===`);

  const recDir = path.join(RECBASE, `run${run}`);
  fs.mkdirSync(recDir, { recursive: true });

  const browser = await chromium.launch({ headless: true });
  const ctx = await browser.newContext({
    viewport: { width: 1440, height: 900 },
    recordVideo: { dir: recDir, size: { width: 1440, height: 900 } },
  });
  const page = await ctx.newPage();

  const consoleErrs = [];
  const pageErrs = [];
  page.on('console', msg => { if (msg.type() === 'error') consoleErrs.push(`CONSOLE_ERR: ${msg.text()}`); });
  page.on('pageerror', err => pageErrs.push(`PAGE_ERR: ${String(err)}`));

  const combinedErrs = () => [...consoleErrs, ...pageErrs];

  // Navigate
  try {
    await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
  } catch(e) {
    append({ run, ts: new Date().toISOString(), selector: 'page', label: 'PAGE LOAD', action: 'goto', ok: false, effect: 'LOAD_FAILED', errors: [String(e)] });
    await ctx.close(); await browser.close();
    continue;
  }

  // Wait for boot() to complete (gallery should be populated)
  try {
    await page.waitForSelector('#gallery .m-card', { timeout: 10000 });
    await page.waitForSelector('#scenePresets button', { timeout: 8000 });
  } catch(e) {
    append({ run, ts: new Date().toISOString(), selector: '#gallery', label: 'BOOT WAIT', action: 'wait', ok: false, effect: 'GALLERY_NOT_RENDERED', errors: [String(e), ...combinedErrs()] });
    await ctx.close(); await browser.close();
    continue;
  }
  await page.waitForTimeout(1000);

  // Log page load status
  const pageTitle = await page.title();
  const muralCount = await page.evaluate(() => document.querySelectorAll('#gallery .m-card').length);
  const sigsLoaded = await page.evaluate(() => typeof SIGS !== 'undefined' && SIGS.length > 0).catch(() => false);
  append({ run, ts: new Date().toISOString(), selector: 'page', label: 'PAGE LOAD', action: 'goto', ok: true,
    effect: JSON.stringify({ title: pageTitle, muralCards: muralCount, sigsLoaded }), errors: combinedErrs().splice(0) });
  consoleErrs.splice(0); pageErrs.splice(0);

  console.log(`  Page loaded: ${muralCount} mural cards, sigsLoaded=${sigsLoaded}`);

  // ── Build the ordered element list for this run ──
  const orderedEls = orderFor(run, ALL_ELEMENTS);

  // ── Execute each element action ──
  for (const el of orderedEls) {
    const errs = combinedErrs();
    consoleErrs.splice(0); pageErrs.splice(0);

    // Special case: roster filter search requires roster to be open first
    if (el.sel.includes('r-search')) {
      // Check if already open
      const open = await page.evaluate((sel) => {
        const el = document.querySelector(sel);
        return el ? el.offsetParent !== null : false;
      }, el.sel).catch(() => false);
      if (!open) {
        append({ run, ts: new Date().toISOString(), selector: el.sel, label: el.label, action: el.action, ok: false, effect: 'SKIPPED_ROSTER_CLOSED', errors: [] });
        continue;
      }
    }

    const r = await doAction(page, el, consoleErrs, run);
    const cErrs = combinedErrs();
    if (cErrs.length) {
      // Attach any new errors that fired after the action settled
      const prev = JSON.parse(fs.readFileSync(LOG,'utf8').trim().split('\n').pop() || '{}');
      if (prev.errors) prev.errors.push(...cErrs);
      consoleErrs.splice(0); pageErrs.splice(0);
    }

    if (!r.ok) console.log(`  FAIL [run${run}] ${el.label}: ${r.effect}`);
    else console.log(`  ok   [run${run}] ${el.label}`);
  }

  // ── Extra targeted actions ──

  // Open roster on card 1 & 2 (may already be open from gallery elements)
  // Ensure card 1 roster is open, then filter
  console.log('  [extra] Ensuring roster card 1 open...');
  const rosterOpen1 = await page.evaluate(() => {
    const body = document.querySelector('[data-rosterbody]');
    return body && body.classList.contains('open');
  }).catch(() => false);
  if (!rosterOpen1) {
    const trig = page.locator('.m-card:nth-child(1) .roster-trig').first();
    if (await trig.count() > 0) { await trig.scrollIntoViewIfNeeded().catch(()=>{}); await trig.click().catch(()=>{}); await page.waitForTimeout(600); }
  }
  await runRosterFilter(page, consoleErrs, run, 1, 'wash');
  await page.waitForTimeout(400);
  await doRosterItemClick(page, consoleErrs, run, 1);
  await page.waitForTimeout(500);

  // Open roster card 2
  const rosterOpen2 = await page.evaluate(() => {
    const bodies = document.querySelectorAll('[data-rosterbody]');
    return bodies[1] && bodies[1].classList.contains('open');
  }).catch(() => false);
  if (!rosterOpen2) {
    const trig = page.locator('.m-card:nth-child(2) .roster-trig').first();
    if (await trig.count() > 0) { await trig.scrollIntoViewIfNeeded().catch(()=>{}); await trig.click().catch(()=>{}); await page.waitForTimeout(600); }
  }
  await runRosterFilter(page, consoleErrs, run, 2, 'hancock');
  await page.waitForTimeout(400);

  // ── Drag mural ──
  console.log('  [extra] Dragging mural...');
  await page.$('#studio')?.then(el => el?.scrollIntoView?.()).catch(()=>{});
  await page.evaluate(() => { const s = document.querySelector('#studio'); if(s) s.scrollIntoView({block:'center'}); });
  await page.waitForTimeout(500);
  await doDrag(page, consoleErrs, run);

  // ── Verify collage image loads in first card ──
  const img1Loaded = await page.evaluate(() => {
    const img = document.querySelector('.m-card:nth-child(1) img');
    return img ? { src: img.src, complete: img.complete, naturalW: img.naturalWidth } : null;
  }).catch(() => null);
  append({ run, ts: new Date().toISOString(), selector: '.m-card:nth-child(1) img', label: 'Gallery card 1: collage image load', action: 'observe', ok: !!(img1Loaded?.complete && img1Loaded?.naturalW > 0), effect: JSON.stringify(img1Loaded), errors: [] });

  // ── Check price & signatures readout in studio ──
  const readoutCheck = await page.evaluate(() => {
    const r = document.querySelector('#readout');
    if (!r) return null;
    const text = r.textContent;
    const priceMatch = text.match(/Price:\s*\$[\d,]+/);
    const sigMatch = text.match(/Signatures shown:\s*(\d+)/);
    const sqftMatch = text.match(/(\d+)\s*sq\s*ft/i) || text.match(/Mural:.*?(\d+)×(\d+)/);
    return { text: text.slice(0,300), price: priceMatch?.[0], sigs: sigMatch?.[0], sqft: sqftMatch?.[0] };
  }).catch(() => null);
  append({ run, ts: new Date().toISOString(), selector: '#readout', label: 'Studio: readout check (price + sig count)', action: 'observe', ok: !!(readoutCheck?.price && readoutCheck?.sigs), effect: JSON.stringify(readoutCheck), errors: [] });

  // ── Check order summary synced ──
  const orderSummaryCheck = await page.evaluate(() => {
    const s = document.querySelector('#orderSummary');
    return s ? s.textContent?.trim().slice(0,200) : null;
  }).catch(() => null);
  append({ run, ts: new Date().toISOString(), selector: '#orderSummary', label: 'Order: summary synced with studio', action: 'observe', ok: !!(orderSummaryCheck && orderSummaryCheck.length > 10), effect: orderSummaryCheck, errors: [] });

  // ── Escape key closes modal (if open) ──
  await page.keyboard.press('Escape');
  await page.waitForTimeout(200);

  // ── Capture final console errors ──
  const finalErrs = [...consoleErrs, ...pageErrs];
  if (finalErrs.length) {
    append({ run, ts: new Date().toISOString(), selector: 'page', label: 'FINAL CONSOLE ERRORS', action: 'observe', ok: false, effect: '', errors: finalErrs });
  }

  console.log(`  Closing run${run}...`);
  await ctx.close();
  await browser.close();

  // ── Convert webm → mp4 ──
  const webms = fs.readdirSync(recDir).filter(f => f.endsWith('.webm'));
  for (const w of webms) {
    const inp = path.join(recDir, w);
    const out = inp.replace('.webm', '.mp4');
    try {
      execSync(`ffmpeg -y -i "${inp}" -c:v libx264 -preset fast -crf 22 "${out}" 2>/dev/null`);
      console.log(`  converted: ${out}`);
    } catch(e) {
      console.log(`  ffmpeg failed for ${w}: ${e.message?.slice(0,100)}`);
    }
  }

  // Brief cooldown between runs
  await new Promise(r => setTimeout(r, 1000));
}

console.log('\n=== All 5 runs complete ===');
console.log(`Debug log: ${LOG}`);
console.log(`Recordings: ${RECBASE}/run{0..4}/`);