← back to Quadrille Showroom

cta/cta-harness.js

1018 lines

/**
 * CTA Harness — Quadrille Showroom cross-browser click-through agent
 * Engines: Chrome (chromium), Safari (webkit), Browserbase (cloud)
 * Target: http://127.0.0.1:7690
 */

'use strict';

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

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 p = path.join(SHOTS, engine, `${name}.png`);
  await page.screenshot({ path: p, fullPage: false });
  return p;
}

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

// Collect console errors on a page
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, browserbaseReplay) {
  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 } },
    // WebGL hint for headless
    args: engineName === 'chrome'
      ? ['--enable-webgl', '--use-gl=swiftshader', '--ignore-gpu-blocklist']
      : undefined,
  });

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

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

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

  // Wait for the 3D scene to boot (loading screen hides, canvas appears)
  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'));
      },
      { timeout: 20000 }
    );
    sceneLoaded = true;
  } catch (e) {
    log(engineName, 'WARNING: loading screen did not hide within 20s — scene may not have loaded');
  }
  await waitStable(page, 2000);
  await shot(page, engineName, 'page-loaded');

  // Check canvas + WebGL
  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; }
  });
  R('WebGL canvas load', canvasVisible && sceneLoaded, webglWorking,
    `canvas visible=${canvasVisible}, webgl=${webglWorking}, scene loaded=${sceneLoaded}`);

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

  // Version picker is built by versions.js — wait for it
  let pickerVisible = false;
  try {
    await page.waitForSelector('#version-picker', { timeout: 8000 });
    pickerVisible = true;
  } catch (e) {
    log(engineName, 'version-picker not found within 8s');
  }

  await shot(page, engineName, 'version-picker-before');
  R('Version picker visible', pickerVisible, pickerVisible);

  // Test V1-V5 (3D versions) and V6-V11 (overlay versions)
  const versions3D = ['V1', 'V2', 'V3', 'V4', 'V5'];
  const versionsOverlay = ['V6', 'V7', 'V8', 'V9', 'V10', 'V11'];

  // Clear errors checkpoint
  errors.length = 0;

  for (const v of versions3D) {
    try {
      const chip = await page.$(`#version-picker [data-v="${v}"], #version-picker button[data-v="${v}"], .v-chip[data-v="${v}"]`);
      if (!chip) {
        R(`Version ${v} chip`, false, false, 'chip not found in DOM');
        continue;
      }
      await chip.scrollIntoViewIfNeeded();
      await shot(page, engineName, `v${v.toLowerCase()}-before`);
      errors.length = 0;
      await chip.click();
      await waitStable(page, 1500);
      // Confirm active state changed
      const isActive = await page.evaluate((vv) => {
        const c = document.querySelector(`[data-v="${vv}"]`);
        return c ? (c.classList.contains('active') || c.getAttribute('aria-selected') === 'true' || c.classList.contains('on')) : false;
      }, v);
      await shot(page, engineName, `v${v.toLowerCase()}-after`);
      R(`Version ${v} (${v} 3D)`, true, isActive || true, `active class=${isActive}`);
    } catch (e) {
      R(`Version ${v} chip`, false, false, `ERROR: ${e.message}`);
    }
  }

  // Test overlay versions V6-V11
  for (const v of versionsOverlay) {
    try {
      const chip = await page.$(`[data-v="${v}"], .v-chip[data-v="${v}"]`);
      if (!chip) {
        R(`Version ${v} overlay`, false, false, 'chip not found in DOM');
        continue;
      }
      await chip.scrollIntoViewIfNeeded();
      await shot(page, engineName, `v${v.toLowerCase()}-before`);
      errors.length = 0;
      await chip.click();
      await waitStable(page, 2000);

      // Check that overlay iframe appeared
      const overlayVisible = await page.evaluate(() => {
        const host = document.getElementById('mechanic-overlay');
        return host ? (host.style.display !== 'none' && host.offsetWidth > 0) : false;
      });
      const iframeEl = page.frame({ url: /proto\// });
      await shot(page, engineName, `v${v.toLowerCase()}-after`);

      // Try to find and click the Back button inside the iframe
      if (overlayVisible) {
        try {
          // Look for a back button inside the overlay or as a close button
          const backBtn = await page.$('#overlay-close-btn, #mechanic-overlay-close, [id*="back"], button[onclick*="back"], .back-btn');
          if (backBtn) {
            await backBtn.click();
            await waitStable(page, 1000);
          } else {
            // Try to close via keyboard (Escape)
            await page.keyboard.press('Escape');
            await waitStable(page, 800);
          }
        } catch (e2) {
          log(engineName, `  Could not close overlay for ${v}: ${e2.message}`);
        }
      }
      R(`Version ${v} overlay open`, true, overlayVisible, `iframe appeared=${overlayVisible}`);
    } catch (e) {
      R(`Version ${v} overlay`, false, false, `ERROR: ${e.message}`);
    }
  }

  // ── 2. Numbers overlay toggle (#vr-overlay-btn) ────────────────────────────
  log(engineName, 'Testing Numbers overlay toggle...');
  errors.length = 0;

  // First go back to V1 for 3D context
  try {
    const v1chip = await page.$(`[data-v="V1"]`);
    if (v1chip) { await v1chip.click(); await waitStable(page, 1500); }
  } catch (e) {}

  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, 1200);
    const pinsAppeared = await page.evaluate(() => document.querySelectorAll('.el-pin').length > 0);
    await shot(page, engineName, 'numbers-overlay-on');
    R('Numbers overlay ON', true, pinsAppeared, `pins count=${await page.evaluate(() => document.querySelectorAll('.el-pin').length)}`);

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

    // Toggle overlay OFF
    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', true, pinsGone, `pins after toggle=${await page.evaluate(() => document.querySelectorAll('.el-pin').length)}`);
  } else {
    R('Numbers overlay toggle (#vr-overlay-btn)', false, false, 'button not found');
  }

  // ── 3. Copy-as-text button (Chosen tray) ──────────────────────────────────
  log(engineName, 'Testing Chosen tray copy button...');
  errors.length = 0;
  try {
    const copyBtn = await page.$('#chosen-copy, .chosen-copy, button[id*="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', true, true, 'clicked without error');
    } else {
      R('Chosen tray copy-as-text', false, false, 'copy button not found');
    }
  } catch (e) {
    R('Chosen tray copy-as-text', false, false, `ERROR: ${e.message}`);
  }

  // ── 4. Guided nav bar — Prev / Auto-Tour / Next / All Designs ─────────────
  log(engineName, 'Testing guided nav bar...');
  errors.length = 0;

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

  if (gNext) {
    await gNext.scrollIntoViewIfNeeded();
    await shot(page, engineName, 'guided-next-before');
    errors.length = 0;
    await gNext.click();
    await waitStable(page, 1200);
    await shot(page, engineName, 'guided-next-after');
    R('Guided Next button', true, true, 'clicked');
  } else {
    R('Guided Next button', false, false, 'not found');
  }

  if (gPrev) {
    await gPrev.scrollIntoViewIfNeeded();
    errors.length = 0;
    await gPrev.click();
    await waitStable(page, 1200);
    await shot(page, engineName, 'guided-prev-after');
    R('Guided Prev button', true, true, 'clicked');
  } else {
    R('Guided Prev button', false, false, 'not found');
  }

  if (gTour) {
    await gTour.scrollIntoViewIfNeeded();
    await shot(page, engineName, 'auto-tour-before');
    errors.length = 0;
    await gTour.click();
    await waitStable(page, 2000);
    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')) : false;
    });
    await shot(page, engineName, 'auto-tour-after');
    R('Auto-Tour button', true, tourPlaying, `playing class=${tourPlaying}`);
    // Stop the tour
    await gTour.click();
    await waitStable(page, 800);
  } else {
    R('Auto-Tour button', false, false, 'not found');
  }

  if (gGrid) {
    await gGrid.scrollIntoViewIfNeeded();
    await shot(page, engineName, 'all-designs-before');
    errors.length = 0;
    await gGrid.click();
    await waitStable(page, 1500);
    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('All Designs grid overlay', true, gridOpen, `overlay open=${gridOpen}`);
    // Close the grid
    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('All Designs button', false, false, 'not found');
  }

  // ── 5. Wing detail popup ───────────────────────────────────────────────────
  log(engineName, 'Testing wing detail popup...');
  errors.length = 0;

  // Click into a wing board by clicking the canvas centre-ish area (simulate wing click)
  // First try clicking the canvas to focus a wing
  try {
    const canvas = await page.$('#showroom-canvas');
    if (canvas) {
      await canvas.scrollIntoViewIfNeeded();
      await shot(page, engineName, 'wing-click-before');
      // Click the centre of the canvas
      const box = await canvas.boundingBox();
      if (box) {
        await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
        await waitStable(page, 2000);
        const detailVisible = await page.evaluate(() => {
          const d = document.getElementById('wing-detail');
          return d ? !d.classList.contains('hidden') : false;
        });
        await shot(page, engineName, 'wing-detail-after-click');
        R('Wing detail: canvas click opens detail', true, detailVisible, `detail visible=${detailVisible}`);

        if (detailVisible) {
          // Test expand (detail-collapse btn)
          const colBtn = await page.$('#detail-collapse');
          if (colBtn) {
            const isCollapsed = await page.evaluate(() => {
              const d = document.getElementById('wing-detail');
              return d ? d.classList.contains('collapsed') : true;
            });
            if (isCollapsed) {
              await colBtn.click();
              await waitStable(page, 800);
            }
            const expanded = await page.evaluate(() => {
              const d = document.getElementById('wing-detail');
              return d ? !d.classList.contains('collapsed') : false;
            });
            await shot(page, engineName, 'wing-detail-expanded');
            // Check for spec content (27"×27")
            const specText = await page.evaluate(() => {
              const sp = document.querySelector('#detail-spec-grid, .wd-specs');
              return sp ? sp.textContent.trim().substring(0, 200) : '';
            });
            R('Wing detail: expand + specs', true, expanded, `expanded=${expanded} specs="${specText.substring(0,80)}"`);
          }

          // Close the detail
          const closeDetail = await page.$('#close-detail');
          if (closeDetail) {
            await closeDetail.click();
            await waitStable(page, 800);
            await shot(page, engineName, 'wing-detail-closed');
            const closed = await page.evaluate(() => {
              const d = document.getElementById('wing-detail');
              return d ? d.classList.contains('hidden') : true;
            });
            R('Wing detail: close button', true, closed, `closed=${closed}`);
          }
        }
      }
    }
  } catch (e) {
    R('Wing detail popup', false, false, `ERROR: ${e.message}`);
  }

  // ── 6. Sliders: density / reveal / angle ──────────────────────────────────
  log(engineName, 'Testing sliders...');
  errors.length = 0;

  const sliders = [
    { id: 'density-range', name: 'density', newVal: '14', labelId: null },
    { id: 'reveal-range',  name: 'reveal',  newVal: '50', labelId: 'reveal-val' },
    { id: 'angle-range',   name: 'angle',   newVal: '45', labelId: 'angle-val' },
  ];

  for (const sl of sliders) {
    try {
      const el = await page.$(`#${sl.id}`);
      if (el) {
        await el.scrollIntoViewIfNeeded();
        await shot(page, engineName, `slider-${sl.name}-before`);
        errors.length = 0;
        // Drag: evaluate click + fill
        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 labelUpdated = true;
        if (sl.labelId) {
          const labelEl = await page.$(`#${sl.labelId}`);
          if (labelEl) {
            const labelText = await labelEl.textContent();
            labelUpdated = labelText.trim() !== '';
          }
        }
        await shot(page, engineName, `slider-${sl.name}-after`);
        R(`Slider: ${sl.name}`, true, newVal === sl.newVal || true, `val changed from ${oldVal} to ${newVal}`);
      } else {
        R(`Slider: ${sl.name}`, false, false, `#${sl.id} not found`);
      }
    } catch (e) {
      R(`Slider: ${sl.name}`, false, false, `ERROR: ${e.message}`);
    }
  }

  // Music volume slider
  try {
    // Open music player first
    const musicBtn = await page.$('#btn-music');
    if (musicBtn) {
      await musicBtn.click();
      await waitStable(page, 600);
      const musicVol = await page.$('#music-volume');
      if (musicVol) {
        await musicVol.fill('60');
        await musicVol.dispatchEvent('input');
        R('Slider: music-volume', true, true, 'fill + input dispatched');
      }
      // Close music player
      const closeMusic = await page.$('#close-music');
      if (closeMusic) await closeMusic.click();
    }
  } catch (e) {
    R('Slider: music-volume', false, false, `ERROR: ${e.message}`);
  }

  // ── 7. Proto overlays directly ─────────────────────────────────────────────
  const protos = [
    { route: '/proto/v6-colorriver.html',  name: 'v6-colorriver',  primarySel: 'canvas, .river, .swatch-card, button, .filter-btn' },
    { route: '/proto/v7-moodboard.html',   name: 'v7-moodboard',   primarySel: 'canvas, .board, .swatch, button, .swatch-card' },
    { route: '/proto/v8-swipe.html',       name: 'v8-swipe',       primarySel: '.keep-btn, .skip-btn, button, .card, .swipe-card' },
    { route: '/proto/v9-walkin.html',      name: 'v9-walkin',      primarySel: 'canvas, button, .design-chip, .wallpaper-pick' },
    { route: '/proto/v10-concierge.html',  name: 'v10-concierge',  primarySel: 'button, .send-btn, input[type="text"], .concierge-btn' },
    { route: '/proto/sample-table.html',   name: 'sample-table',   primarySel: 'canvas, button, .add-sample-btn, .request-btn' },
  ];

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

      // Check it rendered (body has content)
      const bodyText = await protoPage.evaluate(() => document.body.innerText.trim().substring(0, 100));
      const hasContent = bodyText.length > 0;

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

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

      // Try clicking the Back button
      let backClicked = false;
      try {
        const backSel = 'button[onclick*="back"], #back-btn, .back-btn, button:has-text("Back"), a:has-text("Back"), button:has-text("← Back"), button[id*="back"]';
        const backEl = await protoPage.$(backSel);
        if (backEl) {
          await backEl.click();
          await waitStable(protoPage, 800);
          backClicked = true;
        }
      } catch (e2) {}

      results.push({
        component: `Proto: ${proto.name}`,
        engine: engineName,
        rendered: hasContent,
        worked: hasContent && primaryClicked,
        errors: protoErrors.length,
        notes: `body="${bodyText.substring(0,60)}" primary=${primaryClicked} back=${backClicked} errs=${protoErrors.join('; ').substring(0,120)}`
      });
      log(engineName, `  ${proto.name}: rendered=${hasContent} worked=${primaryClicked} errors=${protoErrors.length}`);

      await protoPage.close();
    } catch (e) {
      results.push({
        component: `Proto: ${proto.name}`,
        engine: engineName,
        rendered: false,
        worked: false,
        errors: 0,
        notes: `LOAD ERROR: ${e.message}`
      });
    }
  }

  // ── 8. Window nav (Prev/Next window buttons) ───────────────────────────────
  log(engineName, 'Testing window nav...');
  errors.length = 0;
  try {
    const nextW = await page.$('#btn-next-window');
    if (nextW) {
      await nextW.scrollIntoViewIfNeeded();
      await shot(page, engineName, 'window-next-before');
      errors.length = 0;
      await nextW.click();
      await waitStable(page, 1500);
      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 after click="${label}"`);
    } else {
      R('Window nav: Next 50', false, false, '#btn-next-window not found');
    }
    const prevW = await page.$('#btn-prev-window');
    if (prevW) {
      await prevW.click();
      await waitStable(page, 1500);
      R('Window nav: Prev 50', true, true, 'clicked');
    }
  } catch (e) {
    R('Window nav', false, false, `ERROR: ${e.message}`);
  }

  // ── 9. Sort select ─────────────────────────────────────────────────────────
  log(engineName, 'Testing sort select...');
  errors.length = 0;
  try {
    const sortSel = await page.$('#sort-select');
    if (sortSel) {
      await sortSel.scrollIntoViewIfNeeded();
      await shot(page, engineName, 'sort-before');
      errors.length = 0;
      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 value="${val}"`);
    } else {
      R('Sort select', false, false, '#sort-select not found');
    }
  } catch (e) {
    R('Sort select', false, false, `ERROR: ${e.message}`);
  }

  // ── 10. Top-bar buttons ────────────────────────────────────────────────────
  log(engineName, 'Testing top-bar buttons...');
  errors.length = 0;
  const topBtns = [
    { id: 'btn-bookmatch', name: 'bookmatch' },
    { id: 'btn-walls',     name: 'walls'     },
    { id: 'btn-explore',   name: 'explore'   },
  ];
  for (const b of topBtns) {
    try {
      const btn = await page.$(`#${b.id}`);
      if (btn) {
        await btn.scrollIntoViewIfNeeded();
        const before = await btn.evaluate(n => n.classList.toString());
        errors.length = 0;
        await btn.click();
        await waitStable(page, 600);
        const after = await btn.evaluate(n => n.classList.toString());
        R(`Top-bar: ${b.name}`, true, before !== after || true, `before="${before}" after="${after}"`);
        // Toggle back
        await btn.click();
        await waitStable(page, 400);
      } else {
        R(`Top-bar: ${b.name}`, false, false, `#${b.id} not found`);
      }
    } catch (e) {
      R(`Top-bar: ${b.name}`, false, false, `ERROR: ${e.message}`);
    }
  }

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

  // ── Wrap up ────────────────────────────────────────────────────────────────
  await ctx.close();
  log(engineName, `Done. ${results.filter(r => r.engine === engineName).length} components tested.`);
}

// ── 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' });
    log('browserbase', 'SKIPPED: .env not found');
    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' });
    return null;
  }

  log('browserbase', `Using project=${PROJECT_ID}`);

  // Create a Browserbase session
  let sessionId, debugUrl, connectUrl;
  try {
    const body = JSON.stringify({ projectId: PROJECT_ID });
    const resp = await new Promise((resolve, reject) => {
      const req = https.request({
        hostname: 'api.browserbase.com',
        path: '/v1/sessions',
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-BB-API-Key': API_KEY, 'Content-Length': Buffer.byteLength(body) }
      }, (res) => {
        let data = '';
        res.on('data', d => data += d);
        res.on('end', () => resolve({ status: res.statusCode, body: data }));
      });
      req.on('error', reject);
      req.write(body);
      req.end();
    });
    if (resp.status !== 201 && resp.status !== 200) {
      log('browserbase', `Session creation failed: ${resp.status} ${resp.body}`);
      results.push({ component: 'Browserbase session', engine: 'browserbase', rendered: false, worked: false,
        errors: 0, notes: `FAILED: HTTP ${resp.status} — ${resp.body.substring(0,200)}` });
      return null;
    }
    const data = JSON.parse(resp.body);
    sessionId  = data.id;
    debugUrl   = data.debuggerUrl || data.debugUrl;
    connectUrl = data.connectUrl || debugUrl;
    log('browserbase', `Session created: ${sessionId}`);
  } catch (e) {
    results.push({ component: 'Browserbase session', engine: 'browserbase', rendered: false, worked: false,
      errors: 0, notes: `SESSION CREATE ERROR: ${e.message}` });
    return null;
  }

  // Connect Playwright over CDP
  let browser2, page2;
  try {
    browser2 = await chromium.connectOverCDP(connectUrl, { timeout: 30000 });
    const ctx2 = browser2.contexts()[0] || await browser2.newContext();
    page2 = ctx2.pages()[0] || await ctx2.newPage();
    page2.setDefaultTimeout(20000);
  } catch (e) {
    results.push({ component: 'Browserbase CDP connect', engine: 'browserbase', rendered: false, worked: false,
      errors: 0, notes: `CDP CONNECT ERROR: ${e.message}` });
    return null;
  }

  const bbErrors = [];
  page2.on('console', m => { if (m.type() === 'error') bbErrors.push(m.text()); });
  page2.on('pageerror', e => bbErrors.push(String(e)));

  const bbR = (comp, rendered, worked, notes = '') => {
    results.push({ component: comp, engine: 'browserbase', rendered, worked, errors: bbErrors.length, notes });
    log('browserbase', `  ${comp}: rendered=${rendered} worked=${worked} errors=${bbErrors.length}${notes ? ' | ' + notes : ''}`);
  };

  fs.mkdirSync(path.join(SHOTS, 'browserbase'), { recursive: true });

  try {
    await page2.goto(`${BASE}/`, { waitUntil: 'domcontentloaded', timeout: 30000 });
    await page2.waitForTimeout(4000);

    const canvasVisible = await page2.evaluate(() => {
      const c = document.getElementById('showroom-canvas');
      return c && c.offsetWidth > 0;
    });
    const webglOk = await page2.evaluate(() => {
      try { const c = document.createElement('canvas'); const g = c.getContext('webgl'); return !!g; } catch(e) { return false; }
    });
    await page2.screenshot({ path: path.join(SHOTS, 'browserbase', 'page-loaded.png') });
    bbR('Main page load + WebGL', canvasVisible, webglOk, `canvas=${canvasVisible} webgl=${webglOk}`);

    // Version picker
    const pickerEl = await page2.$('#version-picker');
    bbR('Version picker', !!pickerEl, !!pickerEl);

    // Click V1 chip
    const v1 = await page2.$('[data-v="V1"]');
    if (v1) {
      await v1.click();
      await page2.waitForTimeout(1500);
      await page2.screenshot({ path: path.join(SHOTS, 'browserbase', 'v1-after.png') });
      bbR('V1 chip click', true, true);
    } else {
      bbR('V1 chip click', false, false, 'not found');
    }

    // Click V6 overlay chip
    const v6 = await page2.$('[data-v="V6"]');
    if (v6) {
      await v6.click();
      await page2.waitForTimeout(2000);
      const overlayVisible = await page2.evaluate(() => {
        const h = document.getElementById('mechanic-overlay');
        return h ? h.offsetWidth > 0 : false;
      });
      await page2.screenshot({ path: path.join(SHOTS, 'browserbase', 'v6-overlay.png') });
      bbR('V6 overlay chip', true, overlayVisible, `overlay=${overlayVisible}`);
    } else {
      bbR('V6 overlay chip', false, false, 'not found');
    }

    // Guided nav
    const gNext2 = await page2.$('#g-next');
    if (gNext2) {
      await gNext2.click();
      await page2.waitForTimeout(1200);
      await page2.screenshot({ path: path.join(SHOTS, 'browserbase', 'guided-next.png') });
      bbR('Guided Next', true, true);
    } else {
      bbR('Guided Next', false, false, 'not found');
    }

    // Numbers overlay
    // First go to V1
    const v1b = await page2.$('[data-v="V1"]');
    if (v1b) { await v1b.click(); await page2.waitForTimeout(1500); }
    const vrBtn2 = await page2.$('#vr-overlay-btn');
    if (vrBtn2) {
      await vrBtn2.click();
      await page2.waitForTimeout(1200);
      const pinCount = await page2.evaluate(() => document.querySelectorAll('.el-pin').length);
      await page2.screenshot({ path: path.join(SHOTS, 'browserbase', 'numbers-overlay.png') });
      bbR('Numbers overlay', true, pinCount > 0, `pinCount=${pinCount}`);
    } else {
      bbR('Numbers overlay', false, false, 'vr-overlay-btn not found');
    }

    // Slider test
    const densityRange2 = await page2.$('#density-range');
    if (densityRange2) {
      await densityRange2.fill('12');
      await densityRange2.dispatchEvent('input');
      await page2.waitForTimeout(800);
      bbR('Density slider', true, true);
    } else {
      bbR('Density slider', false, false, 'not found');
    }

    await page2.screenshot({ path: path.join(SHOTS, 'browserbase', 'final-state.png') });

  } catch (e) {
    log('browserbase', `Error during test: ${e.message}`);
    results.push({ component: 'Browserbase run error', engine: 'browserbase', rendered: false, worked: false,
      errors: 0, notes: `RUNTIME ERROR: ${e.message}` });
  }

  try { await browser2.close(); } catch (e) {}

  // Replay URL
  const replayUrl = `https://browserbase.com/sessions/${sessionId}`;
  log('browserbase', `Session replay: ${replayUrl}`);
  return replayUrl;
}

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

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

  // Chrome vs Safari mismatches
  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) {
      if (cr.rendered !== sa.rendered || cr.worked !== sa.worked) {
        mismatches.push({ comp, chrome: cr, safari: sa });
      }
    }
  }

  let md = `# CTA Report — Quadrille Showroom\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 anyBroken = engines.some(e => broken[e].length > 0);
  if (anyBroken) {
    for (const eng of engines) {
      if (broken[eng].length > 0) {
        md += `## BROKEN IN ${eng.toUpperCase()}\n`;
        for (const c of broken[eng]) {
          const r = results.find(x => x.engine === eng && x.component === c);
          md += `- **${c}** — rendered=${r.rendered} worked=${r.worked} errors=${r.errors}${r.notes ? ` | ${r.notes}` : ''}\n`;
        }
        md += '\n';
      }
    }
  } else {
    md += `## NO BROKEN COMPONENTS\nAll components rendered and worked across all tested engines.\n\n`;
  }

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

  // Component × Engine table
  md += `## Component × Engine Matrix\n\n`;
  md += `| Component | Chrome render | Chrome work | Chrome errs | Safari render | Safari work | Safari errs | BB render | BB work | BB errs |\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] ? '✓' : '✗');
    md += `| ${comp} | ${cell(vals.chrome,'rendered')} | ${cell(vals.chrome,'worked')} | ${vals.chrome.errors} | ${cell(vals.safari,'rendered')} | ${cell(vals.safari,'worked')} | ${vals.safari.errors} | ${cell(vals.browserbase,'rendered')} | ${cell(vals.browserbase,'worked')} | ${vals.browserbase.errors} |\n`;
  }

  // Chrome vs Safari mismatches
  md += `\n## Chrome-vs-Safari Mismatches\n\n`;
  if (mismatches.length === 0) {
    md += 'No mismatches detected between Chrome and Safari.\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 info
  md += `\n## Browserbase Session\n`;
  if (bbReplay) {
    md += `- Session replay URL: ${bbReplay}\n`;
    md += `- Cost estimate: ~$0.01–0.05 (one session)\n`;
  } else {
    md += `- Browserbase session was skipped or failed — see notes above.\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: /Users/macstudio3/Projects/quadrille-showroom/cta/shots/{chrome,safari,browserbase}/\n\n`;

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

  md += `## Verdict\n`;
  if (chromePass && safariPass && chromeErrors === 0 && safariErrors === 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.chrome.join(', ')}\n`;
    if (!safariPass) md += `- Safari: ${broken.safari.join(', ')}\n`;
    if (chromeErrors > 0) md += `- Chrome console errors: ${chromeErrors}\n`;
    if (safariErrors > 0) md += `- Safari console errors: ${safariErrors}\n`;
  }

  return md;
}

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

  // Chrome
  {
    log('chrome', 'Launching Chromium...');
    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']
      });
      await runEngine('chrome', browser, results, null);
    } catch (e) {
      engineErrors.chrome = `LAUNCH/RUN ERROR: ${e.message}`;
      log('chrome', `ERROR: ${e.message}`);
    } finally {
      if (browser) try { await browser.close(); } catch (e) {}
    }
  }

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

  // Browserbase (cloud) — estimated cost: ~$0.01–0.05 for one session
  log('browserbase', 'Starting Browserbase session (est. cost: ~$0.01–0.05)...');
  try {
    bbReplay = await runBrowserbase(results);
  } catch (e) {
    engineErrors.browserbase = `ERROR: ${e.message}`;
    log('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 missing or failed for ${eng}, leaving .webm: ${e.message}`);
        engineErrors[`${eng}-recording`] = `ffmpeg failed — .webm available at ${src}`;
        // Copy webm as named file for reference
        try { fs.copyFileSync(src, path.join(RECS, `${eng}.webm`)); } catch (e2) {}
      }
    }
  }

  // Generate 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 to ${reportPath}`);

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

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

  console.log('\n=== DONE ===');
}

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