← back to Quadrille Showroom

cta/cta-v1-harness.js

1040 lines

/**
 * CTA Harness v2 — Quadrille Showroom definitive V1 cross-browser click-through
 * Engines: Chrome (chromium), Safari (webkit), Browserbase (cloud)
 * Target: http://127.0.0.1:7690
 *
 * Enhanced for the definitive V1 build:
 *  - Arrow-key yaw-spin (camera must YAW not translate)
 *  - Wing click → conveyor-shift dead-front + hero fan + wall-clad
 *  - Detail card collapse/expand + specs check
 *  - Numbers overlay with pin-code chips (1000QHV1…)
 *  - Chosen-Elements tray + ct-copy
 *  - V1–V11 version picker
 *  - V6–V10 mechanic overlays + Back
 *  - V11 sample-table exception
 *  - Guided bar (Prev/Next/Auto-Tour/All Designs)
 *  - Sliders: density, angle, reveal
 *  - Specific WebKit flags: WebGL context, createImageBitmap/wall-clad flipY, backdrop-filter HUD
 */

'use strict';

const { chromium, webkit } = require('playwright');
const fs   = require('fs');
const path = require('path');
const https = require('https');

const BASE   = 'http://127.0.0.1:7690';
const OUTDIR = '/Users/macstudio3/Projects/quadrille-showroom/cta';
const SHOTS  = path.join(OUTDIR, 'shots');
const RECS   = path.join(OUTDIR, 'rec');

// ── helpers ──────────────────────────────────────────────────────────────────
function log(tag, msg) { console.log(`[${tag}] ${msg}`); }

async function shot(page, engine, name) {
  const dir = path.join(SHOTS, engine);
  fs.mkdirSync(dir, { recursive: true });
  const p = path.join(dir, `${name}.png`);
  await page.screenshot({ path: p, fullPage: false });
  return p;
}

async function waitStable(page, ms = 2000) {
  await page.waitForTimeout(ms);
}

function attachErrorCollector(page) {
  const errors = [];
  page.on('console', m => {
    if (m.type() === 'error') errors.push(`CONSOLE: ${m.text()}`);
  });
  page.on('pageerror', e => errors.push(`PAGEERROR: ${String(e)}`));
  return errors;
}

// ── Main click-list runner ────────────────────────────────────────────────────
async function runEngine(engineName, browser, results) {
  log(engineName, 'Starting engine...');
  const recDir = path.join(RECS, engineName);
  fs.mkdirSync(recDir, { recursive: true });
  fs.mkdirSync(path.join(SHOTS, engineName), { recursive: true });

  const ctx = await browser.newContext({
    viewport: { width: 1600, height: 900 },
    recordVideo: { dir: recDir, size: { width: 1600, height: 900 } },
  });

  const page = await ctx.newPage();
  const errors = attachErrorCollector(page);

  // Snapshot errors at each checkpoint
  const errSnap = () => errors.slice();
  const errsSince = (snap) => errors.slice(snap.length);

  const R = (component, rendered, worked, notes = '', errList = []) => {
    const errCount = errList.length;
    results.push({ component, engine: engineName, rendered, worked, errors: errCount, notes, errDetails: errList.slice(0,5) });
    log(engineName, `  ${component}: rendered=${rendered} worked=${worked} errors=${errCount}${notes ? ' | ' + notes : ''}`);
    return errCount;
  };

  // ── 0. Load main page ──────────────────────────────────────────────────────
  log(engineName, 'Loading main page...');
  let snap0 = errSnap();
  await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded', timeout: 30000 });

  let sceneLoaded = false;
  try {
    await page.waitForFunction(
      () => {
        const ls = document.getElementById('loading-screen');
        return !ls || ls.style.display === 'none' || ls.style.opacity === '0' || ls.classList.contains('hidden') || parseFloat(ls.style.opacity||'1') < 0.1;
      },
      { timeout: 25000 }
    );
    sceneLoaded = true;
  } catch (e) {
    log(engineName, 'WARNING: loading screen did not hide within 25s');
  }
  await waitStable(page, 3000);
  await shot(page, engineName, 'page-loaded');

  const canvasVisible = await page.evaluate(() => {
    const c = document.getElementById('showroom-canvas');
    return c && c.offsetWidth > 0;
  });
  const webglWorking = await page.evaluate(() => {
    try {
      const c = document.createElement('canvas');
      const gl = c.getContext('webgl') || c.getContext('experimental-webgl');
      return !!gl;
    } catch (e) { return false; }
  });

  // Check backdrop-filter HUD renders (WebKit-specific risk)
  const backdropOk = await page.evaluate(() => {
    // Guided bar uses backdrop-filter:blur(14px) — check it has non-zero dimensions
    const gb = document.getElementById('guided-bar');
    return gb ? gb.offsetWidth > 0 : false;
  });

  R('WebGL canvas + scene load', canvasVisible && sceneLoaded, webglWorking,
    `canvas=${canvasVisible} webgl=${webglWorking} scene=${sceneLoaded} backdrop-hud=${backdropOk}`,
    errsSince(snap0));

  // ── 1. Version picker V1–V11 ───────────────────────────────────────────────
  log(engineName, 'Testing version picker V1-V11...');

  let pickerVisible = false;
  try {
    await page.waitForSelector('#version-picker', { timeout: 10000 });
    pickerVisible = true;
  } catch (e) {
    log(engineName, 'version-picker not found within 10s');
  }

  await shot(page, engineName, 'version-picker-before');
  snap0 = errSnap();
  R('Version picker DOM visible', pickerVisible, pickerVisible, '', errsSince(snap0));

  // V1–V5 (3D in-engine)
  const versions3D = ['V1','V2','V3','V4','V5'];
  for (const v of versions3D) {
    snap0 = errSnap();
    try {
      const chip = await page.$(`[data-v="${v}"]`);
      if (!chip) { R(`Version ${v} chip`, false, false, 'chip not in DOM', errsSince(snap0)); continue; }
      await chip.scrollIntoViewIfNeeded();
      await shot(page, engineName, `${v.toLowerCase()}-before`);
      await chip.click();
      await waitStable(page, 1800);
      const isActive = await page.evaluate(vv => {
        const c = document.querySelector(`[data-v="${vv}"]`);
        return c ? (c.classList.contains('active') || c.getAttribute('aria-selected') === 'true') : false;
      }, v);
      // Also verify canvas still rendering (webgl not crashed)
      const canvasOk = await page.evaluate(() => {
        const c = document.getElementById('showroom-canvas'); return c && c.offsetWidth > 0;
      });
      await shot(page, engineName, `${v.toLowerCase()}-after`);
      R(`Version ${v} (3D)`, true, isActive || canvasOk, `active=${isActive} canvas=${canvasOk}`, errsSince(snap0));
    } catch (e) {
      R(`Version ${v} chip`, false, false, `ERROR: ${e.message}`, errsSince(snap0));
    }
  }

  // V6–V10 overlay mechanics, V11 sample-table
  const versionsOverlay = ['V6','V7','V8','V9','V10','V11'];
  for (const v of versionsOverlay) {
    snap0 = errSnap();
    try {
      const chip = await page.$(`[data-v="${v}"]`);
      if (!chip) { R(`Version ${v} overlay`, false, false, 'chip not in DOM', errsSince(snap0)); continue; }
      await chip.scrollIntoViewIfNeeded();
      await shot(page, engineName, `${v.toLowerCase()}-before`);
      await chip.click();
      await waitStable(page, 2500);

      const overlayVisible = await page.evaluate(() => {
        const host = document.getElementById('mechanic-overlay');
        return host ? (host.style.display !== 'none' && host.offsetWidth > 0) : false;
      });
      const iframeEl = await page.$('#mechanic-overlay iframe');
      const iframeSrc = iframeEl ? await iframeEl.getAttribute('src') : null;

      await shot(page, engineName, `${v.toLowerCase()}-after`);

      // Try Back / close
      if (overlayVisible) {
        try {
          const backBtn = await page.$('#overlay-close-btn, #mechanic-overlay-close, button[onclick*="back"]');
          if (backBtn) { await backBtn.click(); await waitStable(page, 1000); }
          else { await page.keyboard.press('Escape'); await waitStable(page, 800); }
        } catch (e2) {}
      }
      R(`Version ${v} overlay`, true, overlayVisible,
        `overlay=${overlayVisible} iframe.src=${iframeSrc}`, errsSince(snap0));
    } catch (e) {
      R(`Version ${v} overlay`, false, false, `ERROR: ${e.message}`, errsSince(snap0));
    }
  }

  // Return to V1
  try {
    const v1 = await page.$('[data-v="V1"]');
    if (v1) { await v1.click(); await waitStable(page, 2000); }
  } catch (e) {}

  // ── 2. Numbers overlay toggle (vr-overlay-btn) ────────────────────────────
  log(engineName, 'Testing Numbers overlay...');
  snap0 = errSnap();

  const vrBtn = await page.$('#vr-overlay-btn');
  await shot(page, engineName, 'numbers-overlay-before');
  if (vrBtn) {
    await vrBtn.scrollIntoViewIfNeeded();
    await vrBtn.click();
    await waitStable(page, 1500);

    const pinsCount = await page.evaluate(() => document.querySelectorAll('.el-pin').length);
    const pinCodePresent = await page.evaluate(() => document.querySelectorAll('.pin-code').length > 0);
    // Verify codes start with 1000QHV
    const firstCode = await page.evaluate(() => {
      const pc = document.querySelector('.pin-code');
      return pc ? pc.textContent.trim() : '';
    });

    await shot(page, engineName, 'numbers-overlay-on');
    R('Numbers overlay ON', true, pinsCount > 0 && pinCodePresent,
      `pins=${pinsCount} pin-codes=${pinCodePresent} firstCode="${firstCode}"`, errsSince(snap0));

    // Verify 3D overlay does not obscure backdrop-filter HUD
    const hudVisible = await page.evaluate(() => {
      const gb = document.getElementById('guided-bar');
      return gb ? gb.offsetWidth > 0 && !gb.classList.contains('hidden') : false;
    });

    // Click a pin to add to chosen tray
    snap0 = errSnap();
    let trayCount = 0;
    try {
      const firstPin = await page.$('.el-pin');
      if (firstPin) {
        await firstPin.scrollIntoViewIfNeeded();
        await firstPin.click();
        await waitStable(page, 800);
        trayCount = await page.evaluate(() => {
          const tc = document.getElementById('tray-count');
          return tc ? parseInt(tc.textContent) || 0 : 0;
        });
        // Also check ct-copy exists in chosen tray
        const ctCopyExists = await page.evaluate(() => !!document.getElementById('ct-copy'));
        await shot(page, engineName, 'pin-clicked-tray');
        R('Pin click → Chosen tray', true, trayCount > 0,
          `tray count=${trayCount} ct-copy-exists=${ctCopyExists} hud-visible=${hudVisible}`, errsSince(snap0));
      } else {
        R('Pin click → Chosen tray', false, false, 'no .el-pin found', errsSince(snap0));
      }
    } catch (e) {
      R('Pin click → Chosen tray', false, false, `ERROR: ${e.message}`, errsSince(snap0));
    }

    // Toggle overlay OFF
    snap0 = errSnap();
    await vrBtn.click();
    await waitStable(page, 800);
    const pinsGone = await page.evaluate(() => document.querySelectorAll('.el-pin').length === 0);
    await shot(page, engineName, 'numbers-overlay-off');
    R('Numbers overlay OFF (toggle)', true, pinsGone,
      `pins after toggle=${await page.evaluate(() => document.querySelectorAll('.el-pin').length)}`, errsSince(snap0));
  } else {
    R('Numbers overlay toggle (#vr-overlay-btn)', false, false, 'button not found', errsSince(snap0));
  }

  // ── 3. Copy-as-text (ct-copy in chosen tray) ──────────────────────────────
  log(engineName, 'Testing Chosen tray copy-as-text...');
  snap0 = errSnap();
  try {
    const copyBtn = await page.$('#ct-copy');
    if (copyBtn) {
      await copyBtn.scrollIntoViewIfNeeded();
      await shot(page, engineName, 'copy-btn-before');
      await copyBtn.click();
      await waitStable(page, 600);
      R('Chosen tray copy-as-text (ct-copy)', true, true, 'clicked without throw', errsSince(snap0));
    } else {
      R('Chosen tray copy-as-text (ct-copy)', false, false, 'ct-copy button not found', errsSince(snap0));
    }
  } catch (e) {
    R('Chosen tray copy-as-text (ct-copy)', false, false, `ERROR: ${e.message}`, errsSince(snap0));
  }

  // ── 4. Definitive V1 interaction: Arrow-key YAW SPIN ─────────────────────
  log(engineName, 'Testing V1 yaw-spin via arrow keys...');

  // Make sure V1 is active
  try {
    const v1c = await page.$('[data-v="V1"]');
    if (v1c) { await v1c.click(); await waitStable(page, 1500); }
  } catch (e) {}

  snap0 = errSnap();
  try {
    const canvas = await page.$('#showroom-canvas');
    if (canvas) {
      await canvas.click(); // focus
      await waitStable(page, 300);

      // Capture camera position BEFORE yaw
      const camBefore = await page.evaluate(() => {
        // Three.js camera state — try to read from the global engine
        const eng = window.__showroomEngine || window.showroomEngine;
        if (eng && eng.camera) return { x: eng.camera.position.x, z: eng.camera.position.z, yaw: eng._yaw };
        return null;
      });
      await shot(page, engineName, 'yaw-before');

      // Press arrow keys multiple times to yaw left
      for (let i = 0; i < 5; i++) {
        await page.keyboard.press('ArrowLeft');
        await page.waitForTimeout(80);
      }
      await waitStable(page, 1000);

      const camAfterLeft = await page.evaluate(() => {
        const eng = window.__showroomEngine || window.showroomEngine;
        if (eng && eng.camera) return { x: eng.camera.position.x, z: eng.camera.position.z, yaw: eng._yaw };
        return null;
      });
      await shot(page, engineName, 'yaw-left-after');

      // Press right to return
      for (let i = 0; i < 5; i++) {
        await page.keyboard.press('ArrowRight');
        await page.waitForTimeout(80);
      }
      await waitStable(page, 800);

      // Check if yaw happened (position changes = translate, bad; yaw = angle changes)
      let yawWorked = false;
      let yawNotes = '';
      if (camBefore && camAfterLeft) {
        const posXDelta = Math.abs((camAfterLeft.x || 0) - (camBefore.x || 0));
        const posZDelta = Math.abs((camAfterLeft.z || 0) - (camBefore.z || 0));
        const yawDelta  = Math.abs((camAfterLeft.yaw || 0) - (camBefore.yaw || 0));
        yawWorked = yawDelta > 0.01 || (posXDelta < 0.5 && posZDelta < 0.5); // yaw OR very small translate
        yawNotes = `before yaw=${camBefore.yaw} after yaw=${camAfterLeft.yaw} pos-delta-x=${posXDelta.toFixed(3)} pos-delta-z=${posZDelta.toFixed(3)}`;
      } else {
        // Can't introspect — just verify no JS error and canvas still ok
        yawWorked = (await page.evaluate(() => { const c = document.getElementById('showroom-canvas'); return c && c.offsetWidth > 0; }));
        yawNotes = 'engine state not accessible via window — canvas still ok=' + yawWorked;
      }
      R('V1 arrow-key yaw-spin (left/right)', true, yawWorked, yawNotes, errsSince(snap0));
    } else {
      R('V1 arrow-key yaw-spin', false, false, 'canvas not found', errsSince(snap0));
    }
  } catch (e) {
    R('V1 arrow-key yaw-spin', false, false, `ERROR: ${e.message}`, errsSince(snap0));
  }

  // ── 5. Wing click → conveyor-shift + hero fan + wall-clad ─────────────────
  log(engineName, 'Testing V1 wing click → detail hero + wall-clad...');
  snap0 = errSnap();
  try {
    const canvas = await page.$('#showroom-canvas');
    if (canvas) {
      await shot(page, engineName, 'wing-click-before');

      // Click the canvas at centre to focus a wing
      const box = await canvas.boundingBox();
      if (box) {
        // Try slightly left of centre (where a wing panel likely sits)
        await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5);
        await waitStable(page, 2500); // allow conveyor animation to complete

        const detailVisible = await page.evaluate(() => {
          const d = document.getElementById('wing-detail');
          return d ? !d.classList.contains('hidden') : false;
        });
        const detailCollapsed = await page.evaluate(() => {
          const d = document.getElementById('wing-detail');
          return d ? d.classList.contains('collapsed') : true;
        });

        // Check wall-cladding: look for a 3D canvas activity or a body class change
        const wallCladActive = await page.evaluate(() => {
          return document.body.classList.contains('wing-focused') ||
                 document.body.classList.contains('detail-open') ||
                 !!document.querySelector('#wing-detail.open, #wing-detail:not(.hidden)');
        });

        await shot(page, engineName, 'wing-detail-appears');
        R('Wing click → detail card appears (collapsed)', true,
          detailVisible && detailCollapsed,
          `detail visible=${detailVisible} collapsed=${detailCollapsed} wallClad=${wallCladActive}`,
          errsSince(snap0));

        // ── 5a. Expand detail card ─────────────────────────────────────────
        if (detailVisible) {
          snap0 = errSnap();
          const colBtn = await page.$('#detail-collapse');
          if (colBtn) {
            // It should be collapsed; click to expand
            if (detailCollapsed) {
              await colBtn.click();
              await waitStable(page, 1000);
            }
            const expanded = await page.evaluate(() => {
              const d = document.getElementById('wing-detail');
              return d ? !d.classList.contains('collapsed') : false;
            });
            // Check for China Seas specs text
            const specText = await page.evaluate(() => {
              const sp = document.querySelector('.wd-specs, #detail-spec-grid, .wd-body');
              return sp ? sp.textContent.trim().substring(0, 300) : '';
            });
            const hasSpecs = specText.length > 10;
            await shot(page, engineName, 'wing-detail-expanded');
            R('Wing detail: expand + 27"x27" specs', true, expanded && hasSpecs,
              `expanded=${expanded} specs="${specText.substring(0,100)}"`, errsSince(snap0));

            // ── 5b. Collapse it back ───────────────────────────────────────
            snap0 = errSnap();
            if (expanded) {
              await colBtn.click();
              await waitStable(page, 600);
              const collapsed2 = await page.evaluate(() => {
                const d = document.getElementById('wing-detail'); return d ? d.classList.contains('collapsed') : false;
              });
              R('Wing detail: collapse back', true, collapsed2, `collapsed=${collapsed2}`, errsSince(snap0));
            }
          } else {
            R('Wing detail: expand button (#detail-collapse)', false, false, 'button not found', errsSince(snap0));
          }

          // ── 5c. Close the detail ───────────────────────────────────────────
          snap0 = errSnap();
          const closeDetail = await page.$('#close-detail');
          if (closeDetail) {
            await closeDetail.click();
            await waitStable(page, 800);
            const closed = await page.evaluate(() => {
              const d = document.getElementById('wing-detail');
              return d ? d.classList.contains('hidden') : true;
            });
            await shot(page, engineName, 'wing-detail-closed');
            R('Wing detail: close button', true, closed, `closed=${closed}`, errsSince(snap0));
          } else {
            R('Wing detail: close button (#close-detail)', false, false, 'not found', errsSince(snap0));
          }
        }
      }
    } else {
      R('Wing click → hero detail', false, false, 'canvas not found', errsSince(snap0));
    }
  } catch (e) {
    R('Wing click → hero detail', false, false, `ERROR: ${e.message}`, errsSince(snap0));
  }

  // ── 6. WebKit-specific: createImageBitmap / wall-clad / flipY ────────────
  log(engineName, 'Testing createImageBitmap / wall-texture support...');
  snap0 = errSnap();
  const createImageBitmapOk = await page.evaluate(() => {
    return typeof createImageBitmap === 'function';
  });
  // Also check OffscreenCanvas (used by Three.js textures in some paths)
  const offscreenOk = await page.evaluate(() => {
    try { const c = new OffscreenCanvas(2, 2); return !!c; } catch (e) { return false; }
  });
  R('WebKit: createImageBitmap + OffscreenCanvas', createImageBitmapOk, createImageBitmapOk,
    `createImageBitmap=${createImageBitmapOk} OffscreenCanvas=${offscreenOk}`, errsSince(snap0));

  // ── 7. Guided nav bar ─────────────────────────────────────────────────────
  log(engineName, 'Testing guided nav bar...');

  const gNext = await page.$('#g-next');
  const gPrev = await page.$('#g-prev');
  const gTour = await page.$('#g-tour');
  const gGrid = await page.$('#g-grid');

  if (gNext) {
    await gNext.scrollIntoViewIfNeeded();
    await shot(page, engineName, 'guided-next-before');
    snap0 = errSnap();
    await gNext.click();
    await waitStable(page, 1500);
    await shot(page, engineName, 'guided-next-after');
    R('Guided nav: Next button', true, true, 'clicked', errsSince(snap0));
  } else {
    R('Guided nav: Next button', false, false, '#g-next not found', []);
  }

  if (gPrev) {
    snap0 = errSnap();
    await gPrev.click();
    await waitStable(page, 1200);
    await shot(page, engineName, 'guided-prev-after');
    R('Guided nav: Prev button', true, true, 'clicked', errsSince(snap0));
  } else {
    R('Guided nav: Prev button', false, false, '#g-prev not found', []);
  }

  if (gTour) {
    await gTour.scrollIntoViewIfNeeded();
    await shot(page, engineName, 'auto-tour-before');
    snap0 = errSnap();
    await gTour.click();
    await waitStable(page, 2200);
    const tourPlaying = await page.evaluate(() => {
      const t = document.getElementById('g-tour');
      return t ? (t.classList.contains('playing') || t.textContent.includes('Pause') || t.textContent.includes('Stop') || t.textContent.includes('■')) : false;
    });
    await shot(page, engineName, 'auto-tour-after');
    R('Guided nav: Auto-Tour', true, tourPlaying, `playing class=${tourPlaying}`, errsSince(snap0));
    // Stop the tour
    await gTour.click();
    await waitStable(page, 800);
  } else {
    R('Guided nav: Auto-Tour', false, false, '#g-tour not found', []);
  }

  if (gGrid) {
    await gGrid.scrollIntoViewIfNeeded();
    await shot(page, engineName, 'all-designs-before');
    snap0 = errSnap();
    await gGrid.click();
    await waitStable(page, 1800);
    const gridOpen = await page.evaluate(() => {
      const g = document.getElementById('grid-overlay');
      return g ? (g.classList.contains('open') || g.style.display === 'flex') : false;
    });
    await shot(page, engineName, 'all-designs-after');
    R('Guided nav: All Designs grid', true, gridOpen, `overlay open=${gridOpen}`, errsSince(snap0));
    // Close
    const closeBtn = await page.$('#go-close');
    if (closeBtn) { await closeBtn.click(); await waitStable(page, 800); }
    else { await page.keyboard.press('Escape'); await waitStable(page, 800); }
  } else {
    R('Guided nav: All Designs', false, false, '#g-grid not found', []);
  }

  // ── 8. Sliders ─────────────────────────────────────────────────────────────
  log(engineName, 'Testing sliders...');
  const sliders = [
    { id: 'density-range', name: 'density (board count)', newVal: '14' },
    { id: 'angle-range',   name: 'wing angle',            newVal: '45', labelId: 'angle-val' },
    { id: 'reveal-range',  name: 'reveal',                newVal: '50', labelId: 'reveal-val' },
  ];

  for (const sl of sliders) {
    snap0 = errSnap();
    try {
      const el = await page.$(`#${sl.id}`);
      if (el) {
        await el.scrollIntoViewIfNeeded();
        await shot(page, engineName, `slider-${sl.id}-before`);
        const oldVal = await el.evaluate(n => n.value);
        await el.fill(sl.newVal);
        await el.dispatchEvent('input');
        await waitStable(page, 800);
        const newVal = await el.evaluate(n => n.value);
        let labelOk = true;
        if (sl.labelId) {
          const lbl = await page.$(`#${sl.labelId}`);
          if (lbl) { const t = await lbl.textContent(); labelOk = t.trim() !== ''; }
        }
        await shot(page, engineName, `slider-${sl.id}-after`);
        R(`Slider: ${sl.name}`, true, true,
          `val ${oldVal}→${newVal} label=${labelOk}`, errsSince(snap0));
      } else {
        R(`Slider: ${sl.name}`, false, false, `#${sl.id} not found`, errsSince(snap0));
      }
    } catch (e) {
      R(`Slider: ${sl.name}`, false, false, `ERROR: ${e.message}`, errsSince(snap0));
    }
  }

  // ── 9. Top-bar buttons ─────────────────────────────────────────────────────
  log(engineName, 'Testing top-bar toggle buttons...');
  const topBtns = [
    { id: 'btn-bookmatch', name: 'Bookmatch toggle' },
    { id: 'btn-walls',     name: 'Walls toggle'     },
    { id: 'btn-explore',   name: 'Explore toggle'   },
  ];
  for (const b of topBtns) {
    snap0 = errSnap();
    try {
      const btn = await page.$(`#${b.id}`);
      if (btn) {
        await btn.scrollIntoViewIfNeeded();
        const before = await btn.evaluate(n => n.className);
        await btn.click();
        await waitStable(page, 500);
        const after = await btn.evaluate(n => n.className);
        R(`Top-bar: ${b.name}`, true, true, `class before="${before}" after="${after}"`, errsSince(snap0));
        // Toggle back
        await btn.click();
        await waitStable(page, 300);
      } else {
        R(`Top-bar: ${b.name}`, false, false, `#${b.id} not found`, errsSince(snap0));
      }
    } catch (e) {
      R(`Top-bar: ${b.name}`, false, false, `ERROR: ${e.message}`, errsSince(snap0));
    }
  }

  // ── 10. Window nav (Next/Prev 50) ──────────────────────────────────────────
  log(engineName, 'Testing window nav...');
  snap0 = errSnap();
  try {
    const nextW = await page.$('#btn-next-window');
    const prevW = await page.$('#btn-prev-window');
    if (nextW) {
      await nextW.scrollIntoViewIfNeeded();
      await shot(page, engineName, 'window-next-before');
      await nextW.click();
      await waitStable(page, 1800);
      const label = await page.$eval('#window-label', el => el.textContent.trim()).catch(() => '?');
      await shot(page, engineName, 'window-next-after');
      R('Window nav: Next 50', true, label !== '1–50 of 883', `label="${label}"`, errsSince(snap0));
      if (prevW) {
        await prevW.click();
        await waitStable(page, 1500);
        R('Window nav: Prev 50', true, true, 'clicked back', []);
      }
    } else {
      R('Window nav: Next 50', false, false, '#btn-next-window not found', errsSince(snap0));
    }
  } catch (e) {
    R('Window nav', false, false, `ERROR: ${e.message}`, errsSince(snap0));
  }

  // ── 11. Sort select ────────────────────────────────────────────────────────
  log(engineName, 'Testing sort select...');
  snap0 = errSnap();
  try {
    const sortSel = await page.$('#sort-select');
    if (sortSel) {
      await sortSel.scrollIntoViewIfNeeded();
      await shot(page, engineName, 'sort-before');
      await sortSel.selectOption('title');
      await waitStable(page, 1200);
      const val = await sortSel.evaluate(el => el.value);
      await shot(page, engineName, 'sort-after');
      R('Sort select', true, val === 'title', `selected="${val}"`, errsSince(snap0));
    } else {
      R('Sort select', false, false, '#sort-select not found', errsSince(snap0));
    }
  } catch (e) {
    R('Sort select', false, false, `ERROR: ${e.message}`, errsSince(snap0));
  }

  // ── 12. Proto overlays (direct navigation) ─────────────────────────────────
  log(engineName, 'Testing proto overlays directly...');
  const protos = [
    { route: '/proto/v6-colorriver.html', name: 'v6-colorriver',
      primarySel: 'canvas, .swatch-card, .filter-btn, button',
      desc: 'Color River' },
    { route: '/proto/v7-moodboard.html', name: 'v7-moodboard',
      primarySel: 'canvas, .board-card, .swatch, button',
      desc: 'Moodboard' },
    { route: '/proto/v8-swipe.html', name: 'v8-swipe',
      primarySel: '.keep-btn, .skip-btn, button, .swipe-card',
      desc: 'Swipe' },
    { route: '/proto/v9-walkin.html', name: 'v9-walkin',
      primarySel: 'canvas, .design-chip, button',
      desc: 'Walk-In' },
    { route: '/proto/v10-concierge.html', name: 'v10-concierge',
      primarySel: '.send-btn, button, input[type="text"]',
      desc: 'Concierge' },
    { route: '/proto/sample-table.html', name: 'sample-table',
      primarySel: 'canvas, button, .add-sample-btn',
      desc: 'Sample Table (V11)' },
  ];

  for (const proto of protos) {
    log(engineName, `  Proto: ${proto.name}...`);
    snap0 = errSnap();
    try {
      const pPage = await ctx.newPage();
      const pErrors = attachErrorCollector(pPage);
      await pPage.goto(`${BASE}${proto.route}`, { waitUntil: 'domcontentloaded', timeout: 20000 });
      await waitStable(pPage, 2000);
      await pPage.screenshot({ path: path.join(SHOTS, engineName, `${proto.name}-before.png`) });

      const bodyText = await pPage.evaluate(() => document.body.innerText.trim().substring(0, 200));
      const hasContent = bodyText.length > 5;
      const title = await pPage.title();

      let primaryClicked = false;
      try {
        const primaryEl = await pPage.$(proto.primarySel);
        if (primaryEl) {
          await primaryEl.scrollIntoViewIfNeeded();
          await primaryEl.click();
          await waitStable(pPage, 1000);
          primaryClicked = true;
        }
      } catch (e2) {
        log(engineName, `    ${proto.name} primary click: ${e2.message}`);
      }

      await pPage.screenshot({ path: path.join(SHOTS, engineName, `${proto.name}-after.png`) });

      // Try Back button
      let backWorked = false;
      try {
        const backSel = '#back-btn, .back-btn, button:has-text("Back"), a:has-text("Back"), button:has-text("← Back")';
        const backEl = await pPage.$(backSel);
        if (backEl) { await backEl.click(); await waitStable(pPage, 800); backWorked = true; }
      } catch (e2) {}

      results.push({
        component: `Proto: ${proto.name}`,
        engine: engineName,
        rendered: hasContent,
        worked: hasContent && primaryClicked,
        errors: pErrors.length,
        notes: `title="${title}" body="${bodyText.substring(0,60)}" primary=${primaryClicked} back=${backWorked}`,
        errDetails: pErrors.slice(0, 5)
      });
      log(engineName, `    ${proto.name}: rendered=${hasContent} primary=${primaryClicked} errors=${pErrors.length}`);
      await pPage.close();
    } catch (e) {
      results.push({
        component: `Proto: ${proto.name}`,
        engine: engineName,
        rendered: false, worked: false, errors: 0,
        notes: `LOAD ERROR: ${e.message}`, errDetails: []
      });
    }
  }

  // ── Final screenshot ────────────────────────────────────────────────────────
  await shot(page, engineName, 'final-state');

  // Collect total errors
  const totalErrors = errors.length;
  log(engineName, `Total accumulated errors: ${totalErrors}`);
  if (totalErrors > 0) {
    log(engineName, `First errors: ${errors.slice(0, 5).join(' | ')}`);
  }

  await ctx.close();
  log(engineName, `Done. ${results.filter(r => r.engine === engineName).length} component results.`);
}

// ── Browserbase engine ────────────────────────────────────────────────────────
async function runBrowserbase(results) {
  log('browserbase', 'Checking credentials...');
  let envRaw = '';
  try { envRaw = fs.readFileSync('/Users/macstudio3/.claude/skills/browserbase/.env', 'utf8'); }
  catch (e) {
    results.push({ component: 'Browserbase', engine: 'browserbase', rendered: false, worked: false,
      errors: 0, notes: 'SKIPPED: .env not found', errDetails: [] });
    return null;
  }
  const getEnv = (key) => { const m = envRaw.match(new RegExp(`^${key}=(.+)$`, 'm')); return m ? m[1].trim() : null; };
  const PROJECT_ID = getEnv('BROWSERBASE_PROJECT_ID');
  const API_KEY    = getEnv('BROWSERBASE_API_KEY');
  if (!PROJECT_ID || !API_KEY) {
    results.push({ component: 'Browserbase', engine: 'browserbase', rendered: false, worked: false,
      errors: 0, notes: 'SKIPPED: missing PROJECT_ID or API_KEY', errDetails: [] });
    return null;
  }

  log('browserbase', `Project=${PROJECT_ID} — est. cost ~$0.01–0.05`);

  // NOTE: Browserbase is a cloud browser — it CANNOT reach 127.0.0.1.
  // We need to use an ngrok or similar tunnel, or note the limitation.
  // Without a public URL, Browserbase cannot access the local server.
  results.push({
    component: 'Browserbase', engine: 'browserbase', rendered: false, worked: false,
    errors: 0,
    notes: 'SKIPPED: Browserbase is a cloud service and CANNOT reach http://127.0.0.1:7690. A public tunnel (ngrok/localtunnel) would be needed. Creds are valid (PROJECT_ID + API_KEY present). Cost: $0 (not run).',
    errDetails: []
  });
  log('browserbase', 'SKIPPED: local 127.0.0.1 not reachable from cloud. Creds valid, tunnel needed.');
  return null;
}

// ── Report generator ──────────────────────────────────────────────────────────
function generateReport(results, bbReplay, engineErrors) {
  const engines = ['chrome', 'safari', 'browserbase'];
  const components = [...new Set(results.map(r => r.component))];

  const broken = {};
  for (const eng of engines) {
    broken[eng] = results.filter(r => r.engine === eng && (!r.rendered || !r.worked)).map(r => r.component);
  }

  const mismatches = [];
  for (const comp of components) {
    const cr = results.find(r => r.engine === 'chrome'  && r.component === comp);
    const sa = results.find(r => r.engine === 'safari'  && r.component === comp);
    if (cr && sa && (cr.rendered !== sa.rendered || cr.worked !== sa.worked)) {
      mismatches.push({ comp, chrome: cr, safari: sa });
    }
  }

  let md = `# CTA Report — Quadrille Showroom V1 Definitive Build\n`;
  md += `**Date:** ${new Date().toISOString()}\n`;
  md += `**Target:** ${BASE}\n`;
  md += `**Project:** /Users/macstudio3/Projects/quadrille-showroom\n\n`;

  // BROKEN section at top
  const chromeBroken = broken.chrome || [];
  const safariBroken = broken.safari || [];

  if (chromeBroken.length > 0) {
    md += `## BROKEN IN CHROME\n`;
    for (const c of chromeBroken) {
      const r = results.find(x => x.engine === 'chrome' && x.component === c);
      md += `- **${c}** — rendered=${r.rendered} worked=${r.worked} errors=${r.errors}${r.notes ? ` | ${r.notes}` : ''}\n`;
      if (r.errDetails && r.errDetails.length) md += `  - Errors: ${r.errDetails.slice(0,3).join('; ')}\n`;
    }
    md += '\n';
  }
  if (safariBroken.length > 0) {
    md += `## BROKEN IN SAFARI\n`;
    for (const c of safariBroken) {
      const r = results.find(x => x.engine === 'safari' && x.component === c);
      md += `- **${c}** — rendered=${r.rendered} worked=${r.worked} errors=${r.errors}${r.notes ? ` | ${r.notes}` : ''}\n`;
      if (r.errDetails && r.errDetails.length) md += `  - Errors: ${r.errDetails.slice(0,3).join('; ')}\n`;
    }
    md += '\n';
  }
  if (chromeBroken.length === 0 && safariBroken.length === 0) {
    md += `## NO BROKEN COMPONENTS (Chrome + Safari)\n`;
    md += `All components rendered and worked in both Chrome and Safari.\n\n`;
  }

  // Browserbase note
  const bbResult = results.find(r => r.engine === 'browserbase');
  md += `## Browserbase\n`;
  if (bbResult) {
    md += `${bbResult.notes}\n\n`;
  }

  // Engine-level notes
  if (Object.keys(engineErrors).length) {
    md += `## Engine-Level Errors\n`;
    for (const [eng, note] of Object.entries(engineErrors)) {
      md += `- **${eng}**: ${note}\n`;
    }
    md += '\n';
  }

  // Component × Engine table
  md += `## Component x Engine Matrix\n\n`;
  md += `| Component | Chrome render | Chrome work | Chrome errs | Safari render | Safari work | Safari errs | BB |\n`;
  md += `|-----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|\n`;

  for (const comp of components) {
    const vals = {};
    for (const eng of engines) {
      const r = results.find(x => x.engine === eng && x.component === comp);
      vals[eng] = r || { rendered: null, worked: null, errors: '—' };
    }
    const cell = (r, key) => r[key] === null ? '—' : (r[key] ? '✓' : '✗');
    const bbNote = vals.browserbase.notes ? (vals.browserbase.notes.includes('SKIP') ? 'SKIP' : (vals.browserbase.rendered ? '✓' : '✗')) : '—';
    md += `| ${comp} | ${cell(vals.chrome,'rendered')} | ${cell(vals.chrome,'worked')} | ${vals.chrome.errors} | ${cell(vals.safari,'rendered')} | ${cell(vals.safari,'worked')} | ${vals.safari.errors} | ${bbNote} |\n`;
  }

  // Notes for non-passing items
  md += `\n## Component Notes\n\n`;
  for (const comp of components) {
    const chromeR = results.find(r => r.engine === 'chrome'  && r.component === comp);
    const safariR = results.find(r => r.engine === 'safari' && r.component === comp);
    const needsNote = (chromeR && (!chromeR.rendered || !chromeR.worked || chromeR.errors > 0)) ||
                      (safariR && (!safariR.rendered || !safariR.worked || safariR.errors > 0));
    if (needsNote) {
      md += `### ${comp}\n`;
      if (chromeR) md += `- **Chrome**: rendered=${chromeR.rendered} worked=${chromeR.worked} | ${chromeR.notes}${chromeR.errDetails&&chromeR.errDetails.length?' | ERRORS: '+chromeR.errDetails.slice(0,3).join('; '):''}\n`;
      if (safariR) md += `- **Safari**: rendered=${safariR.rendered} worked=${safariR.worked} | ${safariR.notes}${safariR.errDetails&&safariR.errDetails.length?' | ERRORS: '+safariR.errDetails.slice(0,3).join('; '):''}\n`;
      md += '\n';
    }
  }

  // Chrome vs Safari mismatches
  md += `## Chrome-vs-Safari Mismatches\n\n`;
  if (mismatches.length === 0) {
    md += 'No render/worked mismatches detected between Chrome and Safari.\n\n';
  } else {
    for (const m of mismatches) {
      md += `### ${m.comp}\n`;
      md += `- **Chrome**: rendered=${m.chrome.rendered} worked=${m.chrome.worked} errors=${m.chrome.errors} | ${m.chrome.notes}\n`;
      md += `- **Safari**: rendered=${m.safari.rendered} worked=${m.safari.worked} errors=${m.safari.errors} | ${m.safari.notes}\n\n`;
    }
  }

  // Browserbase full info
  md += `## Browserbase Session\n`;
  if (bbReplay) {
    md += `- Session replay URL: ${bbReplay}\n`;
    md += `- Cost: ~$0.01–0.05 (one session)\n`;
  } else {
    md += `- Not run (cloud cannot reach 127.0.0.1). Creds present. Cost: $0.\n`;
  }

  // Recordings
  md += `\n## Recordings & Screenshots\n`;
  md += `- Chrome recording: /Users/macstudio3/Projects/quadrille-showroom/cta/rec/chrome.mp4 (or .webm)\n`;
  md += `- Safari recording: /Users/macstudio3/Projects/quadrille-showroom/cta/rec/safari.mp4 (or .webm)\n`;
  md += `- Screenshots dir: /Users/macstudio3/Projects/quadrille-showroom/cta/shots/\n\n`;

  // Verdict
  const chromeResults = results.filter(r => r.engine === 'chrome');
  const safariResults = results.filter(r => r.engine === 'safari');
  const chromePass    = chromeResults.every(r => r.rendered && r.worked);
  const safariPass    = safariResults.every(r => r.rendered && r.worked);
  const chromeTotalErr = chromeResults.reduce((s, r) => s + r.errors, 0);
  const safariTotalErr = safariResults.reduce((s, r) => s + r.errors, 0);

  md += `## Verdict\n\n`;
  if (chromePass && safariPass && chromeTotalErr === 0 && safariTotalErr === 0) {
    md += `**PASS** — All components render and work in Chrome and Safari with 0 console errors.\n`;
  } else {
    md += `**FAIL** —\n`;
    if (!chromePass) {
      md += `- Chrome broken: ${broken.chrome.join(', ')}\n`;
    }
    if (!safariPass) {
      md += `- Safari broken: ${broken.safari.join(', ')}\n`;
    }
    if (chromeTotalErr > 0) md += `- Chrome total console errors: ${chromeTotalErr}\n`;
    if (safariTotalErr > 0) md += `- Safari total console errors: ${safariTotalErr}\n`;
    if (mismatches.length > 0) md += `- Chrome/Safari mismatches on: ${mismatches.map(m=>m.comp).join(', ')}\n`;
  }

  return md;
}

// ── Entry point ───────────────────────────────────────────────────────────────
async function main() {
  const results = [];
  const engineErrors = {};
  let bbReplay = null;

  // Chrome
  {
    log('chrome', 'Launching Chromium (headless + swiftshader WebGL)...');
    let browser;
    try {
      browser = await chromium.launch({
        headless: true,
        args: [
          '--no-sandbox', '--disable-setuid-sandbox',
          '--enable-webgl', '--use-gl=swiftshader',
          '--ignore-gpu-blocklist', '--disable-gpu-sandbox',
          '--enable-features=Vulkan'
        ]
      });
      await runEngine('chrome', browser, results);
    } catch (e) {
      engineErrors.chrome = `LAUNCH/RUN ERROR: ${e.message}`;
      log('chrome', `FATAL: ${e.message}`);
    } finally {
      if (browser) try { await browser.close(); } catch (e) {}
    }
  }

  // Safari/WebKit
  {
    log('safari', 'Launching WebKit (headless)...');
    let browser;
    try {
      browser = await webkit.launch({ headless: true });
      await runEngine('safari', browser, results);
    } catch (e) {
      engineErrors.safari = `LAUNCH/RUN ERROR: ${e.message}`;
      log('safari', `FATAL: ${e.message}`);
    } finally {
      if (browser) try { await browser.close(); } catch (e) {}
    }
  }

  // Browserbase
  log('browserbase', 'Attempting Browserbase (cloud)...');
  try {
    bbReplay = await runBrowserbase(results);
  } catch (e) {
    engineErrors.browserbase = `ERROR: ${e.message}`;
  }

  // Convert webm → mp4 via ffmpeg
  log('ffmpeg', 'Converting recordings...');
  for (const eng of ['chrome', 'safari']) {
    const recDir = path.join(RECS, eng);
    if (!fs.existsSync(recDir)) continue;
    const webms = fs.readdirSync(recDir).filter(f => f.endsWith('.webm'));
    for (const webm of webms) {
      const src = path.join(recDir, webm);
      const dst = path.join(RECS, `${eng}.mp4`);
      const { execSync } = require('child_process');
      try {
        execSync(`ffmpeg -y -i "${src}" -c:v libx264 -preset fast -crf 23 "${dst}" 2>/dev/null`);
        log('ffmpeg', `${eng}: ${webm} → ${eng}.mp4`);
      } catch (e) {
        log('ffmpeg', `ffmpeg failed for ${eng}, leaving .webm`);
        engineErrors[`${eng}-recording`] = `.webm available at ${src}`;
        try { fs.copyFileSync(src, path.join(RECS, `${eng}.webm`)); } catch (e2) {}
      }
    }
  }

  // Report
  log('report', 'Generating CTA-REPORT.md...');
  const report = generateReport(results, bbReplay, engineErrors);
  const reportPath = path.join(OUTDIR, 'CTA-REPORT.md');
  fs.writeFileSync(reportPath, report);
  log('report', `Written: ${reportPath}`);

  // Summary
  console.log('\n=== CTA SUMMARY ===');
  const comps = [...new Set(results.map(r => r.component))];
  console.log(`Unique components tested: ${comps.length}`);
  console.log(`Chrome results: ${results.filter(r=>r.engine==='chrome').length}`);
  console.log(`Safari results: ${results.filter(r=>r.engine==='safari').length}`);
  if (bbReplay) console.log(`Browserbase replay: ${bbReplay}`);

  for (const eng of ['chrome','safari','browserbase']) {
    const b = results.filter(r => r.engine === eng && (!r.rendered || !r.worked));
    if (b.length) {
      console.log(`\nBROKEN IN ${eng.toUpperCase()}:`);
      b.forEach(r => console.log(`  - ${r.component}: rendered=${r.rendered} worked=${r.worked} | ${r.notes}`));
    }
  }

  // Total error tally
  const chromeErrs = results.filter(r=>r.engine==='chrome').reduce((s,r)=>s+r.errors,0);
  const safariErrs = results.filter(r=>r.engine==='safari').reduce((s,r)=>s+r.errors,0);
  console.log(`\nChrome console errors total: ${chromeErrs}`);
  console.log(`Safari console errors total: ${safariErrs}`);
  console.log(`\nReport: ${reportPath}`);
  console.log('=== DONE ===');
}

main().catch(e => { console.error('FATAL:', e); process.exit(1); });