master
John Lauer 2.15.4: restore sendbar/send-panel/demo-floaty CSS; gate appearance 0646131 1mo ago
#!/usr/bin/env node
/* Behavioural smoke test for the dashboard page.
 *
 * WHY THIS EXISTS. 2.14.0 shipped with api() deleted by an over-greedy regex and 2.14.2
 * shipped with shortState() and busySince missing. All three were invisible to 22 byte
 * checks, because the markup and the CSS were genuinely correct. They were invisible to
 * review. And they were invisible to the user too, because every call site catches its own
 * error: the page answered with an empty prompts panel and a single red "dashboard not
 * answering" pill while the server was serving 200s in four milliseconds.
 *
 * A structural audit cannot see a missing function. This runs the REAL page against the
 * REAL server and asserts what a person would look at.
 *
 * Requires jsdom (npm i jsdom@22 inside tools/). No layout engine, so this proves
 * BEHAVIOUR, not pixels; widths still have to be measured in a real webview.
 */
const { JSDOM, VirtualConsole } = require('jsdom');
const { spawn, execSync } = require('child_process');

const PORT = process.env.SMOKE_PORT || '8971';
const URL = `http://127.0.0.1:${PORT}/`;
const BIN = process.env.SMOKE_BIN || './target/release/adom-lbr';

// Selectors are the SHARED module's (.anp*), because the panel is no longer built by this
// page. If the module is not mounted these all read zero, which is the failure we want: a
// dashboard that silently drops the panel looks fine in a screenshot of the rest of the page.
const EXPECT = {
  sayRows: n => n >= 3 && n <= 6,           // newbie-panel: 3 to 6 rows, never zero
  iconButtons: n => n >= 6,                 // two per row, copy and send
  ledPills: n => n >= 5,                    // Binary, Wiki, Identity, Library, 3D viewer, Dock
  demoButton: v => v === true,
  moduleMounted: v => v === true,
};

(async () => {
  // NEVER MEASURE A SERVER YOU DID NOT START. This app's resolve_port deliberately falls
  // back to the next free port when its own is taken, so a lingering instance leaves the
  // harness talking to a STALE build on the port it expected while the fresh one runs
  // somewhere else. That produced a run where every check read zero and the page was in
  // fact perfect. Refuse to start on an occupied port, then prove the version.
  let occupied = false;
  try { await fetch(URL); occupied = true; } catch (e) {}
  if (occupied) {
    console.log(`[smoke] FAIL port ${PORT} is already answering: something else is serving it.`);
    console.log('        kill it, or run with SMOKE_PORT=<free port>. Refusing to measure a server I did not start.');
    process.exit(1);
  }

  const srv = spawn(BIN, ['dashboard', 'serve', '--port', String(PORT), '--foreground'],
                    { stdio: 'ignore', detached: true });
  const done = code => { try { process.kill(-srv.pid); } catch (e) {} process.exit(code); };

  for (let i = 0; i < 40; i++) {
    try { await fetch(URL); break; } catch (e) { await new Promise(r => setTimeout(r, 250)); }
  }

  // Prove the thing answering is the build we just made, not a survivor from an earlier run.
  const want = require('fs').readFileSync('VERSION', 'utf8').trim();
  try {
    const st = await (await fetch(URL + 'api/status')).json();
    const bin = (st.leds || []).find(l => l.label === 'Binary');
    const got = bin && String(bin.short || '').replace(/^v/, '');
    if (got !== want) {
      console.log(`[smoke] FAIL served version is ${got}, expected ${want}: measuring the wrong server.`);
      done(1); return;
    }
    console.log(`[smoke] ok   serving ${got} on port ${PORT}`);
  } catch (e) {
    console.log('[smoke] FAIL could not read /api/status to confirm the version: ' + e.message);
    done(1); return;
  }

  const errs = [];
  const vc = new VirtualConsole();
  vc.on('jsdomError', e => errs.push(String(e.message || e)));

  let html = await (await fetch(URL)).text();
  // Make the page's own swallowing catches confess, in this COPY only. A caught error is
  // still a defect; the user just never gets told about it.
  html = html.replace(/\}catch\((\w+)\)\{/g, '}catch($1){ if(typeof console!=="undefined")console.error("SWALLOWED >>> "+($1&&$1.stack?$1.stack:$1));');
  vc.on('error', (...a) => { const s = a.join(' '); if (/SWALLOWED/.test(s)) errs.push(s.split('\n')[0]); });

  const dom = new JSDOM(html, {
    url: URL, runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc,
    resources: 'usable',   // the page loads newbie-panel.js by <script src>; without this jsdom skips it
    beforeParse(w) {
      w.fetch = (p, o) => fetch(new w.URL(p, URL).href, o);
      w.scrollTo = () => {};
      w.matchMedia = w.matchMedia || (q => ({ matches: false, media: q, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){} }));
    }
  });
  const d = dom.window.document;
  await new Promise(r => setTimeout(r, 4000));

  const got = {
    sayRows: d.querySelectorAll('.anp-tbl tbody tr').length,
    iconButtons: d.querySelectorAll('.anp-acts .anp-ib').length,
    ledPills: d.querySelectorAll('.led-pill').length,
    demoButton: !!d.querySelector('.anp-demo'),
    moduleMounted: !!(dom.window.AdomNewbiePanel && d.querySelector('.anp')),
  };
  const ledText = [...d.querySelectorAll('.led-pill')].map(p => p.textContent.replace(/\s+/g, ' ').trim());

  let bad = 0;
  for (const [k, test] of Object.entries(EXPECT)) {
    const ok = test(got[k]);
    if (!ok) bad++;
    console.log(`[smoke] ${ok ? 'ok  ' : 'FAIL'} ${k} = ${got[k]}`);
  }
  // The specific shape of the 2.14.2 bug: the LED row falls back to one red pill.
  if (ledText.length === 1 && /not answering/.test(ledText[0])) {
    console.log('[smoke] FAIL LED row is the error fallback: ' + ledText[0]); bad++;
  }
  const real = errs.filter(e => /ReferenceError|TypeError|SyntaxError/.test(e));
  if (real.length) {
    bad++;
    console.log('[smoke] FAIL javascript errors on load (including ones the page swallows):');
    [...new Set(real)].slice(0, 6).forEach(e => console.log('        ' + e.slice(0, 160)));
  }
  // ---- APPEARANCE, not just behaviour ----------------------------------------------
  // The 2.14.0 regex destroyed TWO things. The missing function broke behaviour and was
  // caught. The missing STYLESHEET broke only appearance, so the Send-to-KiCad bar, the
  // send-evidence panel and the demo floaty rendered as bare labels and unstyled buttons
  // for five releases while every gate stayed green. jsdom has no layout engine but it DOES
  // resolve the cascade, so a signature declared property per component is checkable here.
  // An id-orphan scan was tried first and abandoned: most ids are styled through class or
  // descendant rules, so it flagged 70 false positives and would have been switched off.
  const LOOKS = [
    ['#sendbar',        'display',      'flex',     'the Send to KiCad / Fusion bar'],
    ['#sendbar button', 'borderRadius', /\d/,       'the send buttons'],
    ['#sendpanel',      'position',     'fixed',    'the send-evidence panel'],
    ['#tour-card',      'position',     'fixed',    'the demo narration card'],
    ['#tour-bar',       'borderRadius', '999px',    'the demo play/pause floaty'],
    ['#sp-console',     'fontFamily',   /mono/,     'the streaming console'],
  ];
  let looksBad = 0;
  for (const [sel, prop, want, what] of LOOKS) {
    const el = d.querySelector(sel);
    if (!el) { bad++; looksBad++; console.log(`[smoke] FAIL ${what} (${sel}) is not in the page at all`); continue; }
    const got = dom.window.getComputedStyle(el)[prop] || '';
    const ok = want instanceof RegExp ? want.test(got) : got === want;
    if (!ok) { bad++; looksBad++; console.log(`[smoke] FAIL ${what} is unstyled: ${sel} ${prop} = ${got || '(empty)'}`); }
  }
  // never print a pass line next to a failure: a summary that says ok while a check
  // above it says FAIL is how a broken gate gets read as a working one
  if (!looksBad) console.log('[smoke] ok   ' + LOOKS.length + ' components carry their styling');

  // GRADUATION IS NOT DISMISSAL. The panel folding on its own after enough launches must
  // not be recorded as the user dismissing it, or the fold becomes permanent. The module
  // reports who moved the panel (newbie-panel 2.1.0 onToggle(open, source)); this asserts
  // this app reads it correctly, because the failure is silent and only shows up as a panel
  // that never comes back.
  try {
    const w = dom.window;
    const gradKey = Object.keys(w.localStorage).find(k => /graduation/.test(k));
    const readGrad = () => gradKey ? w.localStorage.getItem(gradKey) : '';
    // `let npanel` at script top level is NOT a window property, so w.npanel is undefined
    // and an `if (w.npanel)` guard skips this check in silence, which is the same silent
    // pass this whole file exists to abolish. Reach it through eval, in script scope.
    const hasHandle = w.eval("typeof npanel !== 'undefined' && !!npanel");
    if (!hasHandle) {
      bad++;
      console.log('[smoke] FAIL no panel handle in script scope: cannot verify graduation vs dismissal');
    } else {
      w.eval("npanel.close({remember:false})");
      const afterApi = readGrad();
      w.eval("npanel.open({remember:false})");
      const xBtn = d.querySelector('.anp-x');
      if (xBtn) xBtn.dispatchEvent(new w.MouseEvent('click', { bubbles: true }));
      const afterUser = readGrad();
      const apiClean = !/dismissed/.test(afterApi || '');
      const userRecorded = /dismissed/.test(afterUser || '');
      if (!apiClean) { bad++; console.log('[smoke] FAIL a programmatic close was recorded as a user dismissal'); }
      if (!userRecorded) { bad++; console.log('[smoke] FAIL a user close was NOT recorded, so the panel returns after dismissal'); }
      if (apiClean && userRecorded) console.log('[smoke] ok   graduation close vs user dismissal are distinguished');
      // ...and a user REOPEN has to survive a reload. The setup matters: with a FRESH
      // profile the policy would keep the panel open anyway, so the check passes for the
      // wrong reason and cannot fail. (It did exactly that on its first run, and only the
      // negative control exposed it.) Graduate the user first, so the policy actively wants
      // the panel folded and only a durable recall can keep it open.
      w.eval("gradSave({launches:5})");
      const tgl = d.querySelector('#btnHelp');
      if (tgl) tgl.dispatchEvent(new w.MouseEvent('click', { bubbles: true }));
      const survives = w.eval("(function(){gradRecall=false;return panelOpen();})()");
      if (!survives) { bad++; console.log('[smoke] FAIL a user reopen does not survive a reload: graduation re-folds it'); }
      else console.log('[smoke] ok   a user reopen survives a reload');
    }
  } catch (e) { console.log('[smoke] note: could not exercise the toggle: ' + e.message); }

  // The module proves its own conformance. jsdom has no layout engine, so the geometric
  // rows are meaningless here and only the structural verdict is worth reading; the real
  // selfCheck has to be run in ah's webview. Reported, never used as the pass condition.
  try {
    const sc = dom.window.AdomNewbiePanel && dom.window.AdomNewbiePanel.selfCheck();
    if (sc) console.log('[smoke] module selfCheck (jsdom, no layout engine): ok=' + sc.ok +
      ' rows=' + (sc.rows || []).length);
  } catch (e) { console.log('[smoke] module selfCheck threw: ' + e.message); }
  console.log('[smoke] leds: ' + (ledText.join(' | ') || '(none)'));
  console.log(bad ? `[smoke] FAILED ${bad} check(s)` : '[smoke] OK  page renders and answers');
  dom.window.close();
  done(bad ? 1 : 0);
})().catch(e => { console.log('[smoke] FAIL ' + e.message); process.exit(1); });