← back to Quadrille Showroom
scripts/clickthrough-showroom.mjs
177 lines
/* ============================================================================
* clickthrough-showroom.mjs — SHOWROOM-AWARE click-through (the /5x (a) build).
*
* The generic /3x clickthrough fails because every control is STOWED behind the
* dock.js hamburger (#qh-burger) on load. This walks the app the way a user does:
* 1. open the hamburger → assert the menu opens
* 2. launch EACH panel (Navigate/Controls/View&Theme/Versions/Collection) →
* assert each modal actually appears (un-stowed .qh-modal)
* 3. exercise the real controls inside Controls (#window-bar): the sort <select>
* through ALL options + the density/angle/reveal/open ranges
* 4. drive the Navigate (#guided-bar) buttons: Previous / Next / Auto Tour / All Designs
* 5. proxy-click EACH tool (Peruse/Wings/Overview/Explore/Age View/Open Board/
* Walls/Music/Lighting/Fullscreen) → assert no throw + menu reflects state
* 6. zero console/page errors across the whole pass
*
* Run: NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/clickthrough-showroom.mjs
* Exit 0 = clean; 1 = real defects (printed + JSON); 2 = crash.
* ========================================================================== */
import pw from '/Users/macstudio3/.npm-global/lib/node_modules/playwright/index.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
const USER = process.env.SHOWROOM_USER || 'admin';
const PASS = process.env.SHOWROOM_PASS || 'DWSecure2024!';
const OUT = path.join(__dirname, '..', 'recordings', 'phase5', 'clickthrough');
fs.mkdirSync(OUT, { recursive: true });
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// #guided-bar is NOT docked — Steve made it a permanent bottom-centre fixture (always visible
// while walking in book mode). So it's not a dock PANEL; its buttons are tested directly below.
const PANELS = [
{ sel: '#window-bar', label: 'Controls' },
{ sel: '#vm-panel', label: 'View & Theme' },
{ sel: '#ver-rail', label: 'Versions' },
{ sel: '#vendor-sidebar', label: 'Collection' },
];
const TOOLS = [
'#btn-peruse', '#top-bar [data-section="wings"]', '#top-bar [data-section="overview"]',
'#btn-explore', '#btn-bookmatch', '#btn-walls', '#btn-music',
'#btn-lighting', '#btn-fullscreen',
];
(async () => {
const browser = await pw.chromium.launch({ channel: 'chrome', args: ['--use-gl=angle', '--enable-webgl', '--ignore-gpu-blocklist'] });
const ctx = await browser.newContext({ viewport: { width: 1600, height: 950 }, httpCredentials: { username: USER, password: PASS } });
const page = await ctx.newPage();
const consoleErrors = [];
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
page.on('pageerror', e => consoleErrors.push('PAGEERROR ' + e.message));
await page.addInitScript(() => { try { localStorage.clear(); } catch (e) {} });
const defects = [];
const note = (where, msg) => { defects.push(`${where}: ${msg}`); console.log(` [DEFECT] ${where} — ${msg}`); };
const ok = (msg) => console.log(` [ok] ${msg}`);
console.log('→ loading', URL);
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForFunction(() => window._qh && window._qh.wingBoards, { timeout: 25000 }).catch(() => {});
// dock.js boots the hamburger
await page.waitForSelector('#qh-burger', { state: 'visible', timeout: 15000 }).catch(() => {});
await sleep(3000);
// 1. hamburger opens the menu
if (!(await page.$('#qh-burger'))) { note('dock', '#qh-burger never appeared'); }
else {
await page.click('#qh-burger');
await sleep(400);
const menuOpen = await page.evaluate(() => { const m = document.getElementById('qh-menu'); return !!m && m.classList.contains('open'); });
if (!menuOpen) note('dock', 'menu did not open on hamburger click'); else ok('hamburger → menu opens');
await page.screenshot({ path: path.join(OUT, 'menu-open.png') });
}
// 2. launch each panel from the menu
for (const p of PANELS) {
// ensure menu open
await page.evaluate(() => { const m = document.getElementById('qh-menu'); if (m && !m.classList.contains('open')) document.getElementById('qh-burger').click(); });
await sleep(150);
const item = await page.$(`.qh-menu-item[data-panel="${p.sel}"]`);
if (!item) { note('panel:' + p.label, 'menu item missing'); continue; }
await item.click().catch(e => note('panel:' + p.label, 'click threw ' + e.message));
await sleep(450);
const shown = await page.evaluate(sel => {
const el = document.querySelector(sel);
if (!el) return 'missing';
const wrap = el.closest('.qh-modal');
if (!wrap) return 'no-modal';
const r = wrap.getBoundingClientRect();
return (!wrap.classList.contains('qh-stowed') && r.width > 1 && r.height > 1) ? 'shown' : 'stowed';
}, p.sel);
if (shown !== 'shown') note('panel:' + p.label, `launch state = ${shown}`); else ok(`panel ${p.label} launches`);
}
await page.screenshot({ path: path.join(OUT, 'panels-open.png') });
// helper: stow every modal, then launch ONLY the named panel (so it's on top + clickable,
// not buried under other open modals — that stacking caused false timeouts in sweep 2).
async function openOnly(panelSel) {
await page.evaluate(() => document.querySelectorAll('.qh-modal').forEach(m => m.classList.add('qh-stowed')));
await page.evaluate(() => { const m = document.getElementById('qh-menu'); if (m && !m.classList.contains('open')) document.getElementById('qh-burger').click(); });
await sleep(150);
const item = await page.$(`.qh-menu-item[data-panel="${panelSel}"]`);
if (item) { await item.click(); await sleep(400); }
// close the launcher menu so it doesn't overlap the panel
await page.evaluate(() => { const m = document.getElementById('qh-menu'); if (m && m.classList.contains('open')) document.getElementById('qh-burger').click(); });
await sleep(200);
}
// 3. exercise Controls (#window-bar): sort select all options + ranges
await openOnly('#window-bar');
const sortSel = '#sort-select';
if (await page.$(sortSel)) {
const opts = await page.$$eval(sortSel + ' option', os => os.map(o => o.value));
for (const v of opts) {
try {
await page.selectOption(sortSel, v, { timeout: 5000 });
const now = await page.$eval(sortSel, s => s.value);
if (now !== v) note('sort', `selectOption ${v} → stuck at ${now}`); else ok(`sort = ${v}`);
await sleep(250);
} catch (e) { note('sort', `selectOption ${v} threw ${e.message.split('\n')[0]}`); }
}
} else note('sort', '#sort-select not reachable after launching Controls');
for (const rng of ['#density-range', '#angle-range', '#reveal-range', '#open-range']) {
if (!(await page.$(rng))) { note('range', `${rng} missing`); continue; }
try {
await page.$eval(rng, el => { el.value = el.min; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); });
await sleep(150);
await page.$eval(rng, el => { el.value = el.max; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); });
await sleep(150);
ok(`range ${rng} min↔max`);
} catch (e) { note('range', `${rng} threw ${e.message.split('\n')[0]}`); }
}
// 4. Navigate buttons (open only the Navigate panel so it isn't buried)
await openOnly('#guided-bar');
for (const b of ['#g-prev', '#g-next', '#g-tour', '#g-grid']) {
if (!(await page.$(b))) { note('nav', `${b} missing`); continue; }
try { await page.click(b, { timeout: 4000 }); await sleep(400); ok(`nav ${b} clicked`); }
catch (e) { note('nav', `${b} ${e.message.split('\n')[0]}`); }
}
// close the grid overlay if g-grid opened it
await page.evaluate(() => { const c = document.getElementById('go-close'); if (c) c.click(); });
await sleep(200);
// 5. proxy-click each tool from the menu (keyed by index — selectors may contain quotes)
for (let ti = 0; ti < TOOLS.length; ti++) {
const t = TOOLS[ti];
await page.evaluate(() => { const m = document.getElementById('qh-menu'); if (m && !m.classList.contains('open')) document.getElementById('qh-burger').click(); });
await sleep(150);
const item = await page.$(`.qh-menu-item[data-tool-idx="${ti}"]`);
if (!item) { note('tool:' + t, 'menu item missing'); continue; }
const before = consoleErrors.length;
try { await item.click({ timeout: 4000 }); await sleep(450); }
catch (e) { note('tool:' + t, 'click threw ' + e.message.split('\n')[0]); continue; }
if (consoleErrors.length > before) note('tool:' + t, 'console error on activate: ' + consoleErrors[consoleErrors.length - 1]);
else ok(`tool ${t} activated`);
// turn fullscreen back off / age view back off so we don't leave odd state
if (t === '#btn-fullscreen') await page.evaluate(() => document.exitFullscreen && document.exitFullscreen()).catch(() => {});
}
await page.screenshot({ path: path.join(OUT, 'after-tools.png') });
// 6. console error tally
if (consoleErrors.length) note('console', `${consoleErrors.length} error(s): ${consoleErrors.slice(0, 5).join(' | ')}`);
const result = { url: URL, defects, consoleErrors: consoleErrors.slice(0, 30), pass: defects.length === 0 };
fs.writeFileSync(path.join(OUT, 'clickthrough-result.json'), JSON.stringify(result, null, 2));
console.log('\n================ SHOWROOM CLICKTHROUGH ================');
console.log(` controls exercised via dock; defects = ${defects.length}`);
console.log(' OVERALL:', result.pass ? '✅ CLEAN' : '❌ ' + defects.length + ' DEFECT(S)');
console.log(' → recordings/phase5/clickthrough/');
await browser.close();
process.exit(result.pass ? 0 : 1);
})().catch(e => { console.error('CLICKTHROUGH CRASH:', e); process.exit(2); });