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