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