← 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); });