aboutsummaryrefslogtreecommitdiff
path: root/tests/gallery-probes
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-12 20:48:41 -0500
committerCraig Jennings <c@cjennings.net>2026-07-12 20:48:41 -0500
commite66c60c5a7f66be08d017cefac80e557ced0c957 (patch)
treecb798585a4a0096be116e8ddd365c589c037ec08 /tests/gallery-probes
parentf2cd7b4a97a28f1df58d3258f17d01b6f142e5a2 (diff)
downloadarchsetup-e66c60c5a7f66be08d017cefac80e557ced0c957.tar.gz
archsetup-e66c60c5a7f66be08d017cefac80e557ced0c957.zip
test(gallery): commit the CDP behavioral probes
The full-regression and screen-family probes lived in session scratch; the widgets.js extraction uses them as its per-batch no-regression gate, so they become repo tooling.
Diffstat (limited to 'tests/gallery-probes')
-rw-r--r--tests/gallery-probes/README.md13
-rw-r--r--tests/gallery-probes/probe-fams.mjs63
-rw-r--r--tests/gallery-probes/probe.mjs119
3 files changed, 195 insertions, 0 deletions
diff --git a/tests/gallery-probes/README.md b/tests/gallery-probes/README.md
new file mode 100644
index 0000000..a062bfc
--- /dev/null
+++ b/tests/gallery-probes/README.md
@@ -0,0 +1,13 @@
+# Gallery CDP probes
+
+Behavioral verification for docs/prototypes/panel-widget-gallery.html — headless Chrome
+driven over the DevTools protocol (Node global WebSocket/fetch, no playwright).
+
+- probe.mjs — full regression: card count, size toggle, drag + click behavior under zoom,
+ zero-exception gate. Run: `node probe.mjs [--shot out.png]`. Update the card-count
+ assertion when cards are added.
+- probe-fams.mjs — screen-color families: default fallbacks pixel-identical, chip clicks
+ recolor ink/face/trace, dynamic fills stay var-based.
+
+Both exit nonzero on failure. The gallery's componentization work (widgets.js extraction)
+uses a green run of these as its no-regression gate per batch.
diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs
new file mode 100644
index 0000000..08ac709
--- /dev/null
+++ b/tests/gallery-probes/probe-fams.mjs
@@ -0,0 +1,63 @@
+// Verify screen-family chips: presence, default fallbacks intact, live recolor on click.
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+const PORT = 9335;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const chrome = spawn('google-chrome-stable', ['--headless=new', `--remote-debugging-port=${PORT}`, '--no-first-run', '--window-size=1600,1200', URL], { stdio: 'ignore' });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0; const pending = new Map(); const events = [];
+for (let i = 0; i < 40; i++) { try { const l = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const p = l.find(t => t.type === 'page' && t.url.startsWith('file')); if (p) { ws = new WebSocket(p.webSocketDebuggerUrl); break; } } catch {} await sleep(250); }
+await new Promise(r => ws.onopen = r);
+ws.onmessage = m => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) events.push(d); };
+const send = (method, params = {}) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+const evl = async e => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); return r.result.result.value; };
+const fails = [];
+const ok = (n, c, d = '') => { console.log(`${c ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails.push(n); };
+await send('Runtime.enable'); await sleep(1500);
+
+ok('no exceptions on load', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+ok('5 chip rows', await evl(`document.querySelectorAll('.famchips').length`) === 5);
+
+// default fallback: dmx ink computes to gold-hi rgb(255,190,84)
+const dmxFill0 = await evl(`getComputedStyle(document.querySelector('#dmx text')).fill`);
+ok('R10 default ink = gold-hi', dmxFill0 === 'rgb(255, 190, 84)', dmxFill0);
+
+// click green chip on R10 -> ink becomes phos rgb(127,224,160)
+await evl(`document.querySelector('#dmx').closest('.card').querySelector('.fc[title="green"]').click()`);
+const dmxFill1 = await evl(`getComputedStyle(document.querySelector('#dmx text')).fill`);
+ok('R10 green chip recolors ink', dmxFill1 === 'rgb(127, 224, 160)', dmxFill1);
+
+// radar: default sweep line = gold-hi; click green -> phos
+const swSel = `document.querySelector('#radar').closest('.card')`;
+const line0 = await evl(`getComputedStyle(document.querySelectorAll('#radar line')[0]).stroke`);
+await evl(`${swSel}.querySelector('.fc[title="green"]').click()`);
+const line1 = await evl(`getComputedStyle(document.querySelectorAll('#radar line')[0]).stroke`);
+ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`);
+
+// scope (CSS widget): trace stroke changes on amber chip
+const tr0 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+await evl(`document.querySelector('.scope').closest('.card').querySelector('.fc[title="amber"]').click()`);
+const tr1 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`);
+
+// R19: drag still works after family switch (click vfd, then check setWregion writes var-based fills)
+await evl(`document.querySelector('#wregion').closest('.card').querySelector('.fc[title="vfd"]').click()`);
+await evl(`setWregion(30,70)`);
+const barFill = await evl(`(()=>{const b=document.querySelectorAll('#wregion rect')[10];return b.getAttribute('fill');})()`);
+ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill);
+
+// R17: face gradient stop resolves to amber face after chip
+await evl(`document.querySelector('#rcrt').closest('.card').querySelector('.fc[title="amber"]').click()`);
+const face = await evl(`getComputedStyle(document.querySelector('#rcrtFace stop')).stopColor`);
+ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face);
+
+ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+
+// screenshot the recolored screens region (scroll R31 into view)
+await evl(`document.querySelector('#radar').scrollIntoView({block:'center'})`);
+await sleep(400);
+const shot = await send('Page.captureScreenshot', { format: 'png' });
+writeFileSync('fams.png', Buffer.from(shot.result.data, 'base64'));
+console.log('shot: fams.png');
+chrome.kill();
+process.exit(fails.length ? 1 : 0);
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
new file mode 100644
index 0000000..457ecf3
--- /dev/null
+++ b/tests/gallery-probes/probe.mjs
@@ -0,0 +1,119 @@
+// CDP probe for the widget gallery — no playwright, Node global WebSocket/fetch.
+// Usage: node probe.mjs [--shot out.png] [--size N]
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const PORT = 9333;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const shotIdx = process.argv.indexOf('--shot');
+const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null;
+
+const chrome = spawn('google-chrome-stable', [
+ '--headless=new', `--remote-debugging-port=${PORT}`,
+ '--no-first-run', '--no-default-browser-check',
+ '--window-size=1600,1200', URL,
+], { stdio: 'ignore' });
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let ws, id = 0;
+const pending = new Map();
+const events = [];
+
+async function connect() {
+ for (let i = 0; i < 40; i++) {
+ try {
+ const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
+ const page = list.find(t => t.type === 'page' && t.url.startsWith('file'));
+ if (page) { ws = new WebSocket(page.webSocketDebuggerUrl); break; }
+ } catch { /* retry */ }
+ await sleep(250);
+ }
+ if (!ws) throw new Error('no page target');
+ await new Promise(r => ws.onopen = r);
+ ws.onmessage = m => {
+ const d = JSON.parse(m.data);
+ if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); }
+ else if (d.method) events.push(d);
+ };
+}
+function send(method, params = {}) {
+ return new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+}
+async function evl(expr) {
+ const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true });
+ if (r.result.exceptionDetails) throw new Error('eval failed: ' + JSON.stringify(r.result.exceptionDetails.exception?.description || r.result.exceptionDetails.text));
+ return r.result.result.value;
+}
+async function drag(x1, y1, x2, y2) {
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 });
+ const steps = 8;
+ for (let i = 1; i <= steps; i++)
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 });
+}
+
+const fails = [];
+const ok = (name, cond, detail = '') => { console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); if (!cond) fails.push(name); };
+
+try {
+ await connect();
+ await send('Runtime.enable');
+ await send('Page.enable');
+ await sleep(1500);
+
+ // 0. console errors / exceptions
+ const errs = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions on load', errs.length === 0, errs.map(e => e.params.exceptionDetails?.exception?.description).join('; ').slice(0, 200));
+
+ // 1. defaults: size=3, 84 cards
+ ok('default size 3', await evl(`document.body.dataset.size`) === '3');
+ const cards = await evl(`document.querySelectorAll('.card').length`);
+ ok('109 cards', cards === 109, `got ${cards}`);
+
+ // 2. zoom actually scales: card visual width at 3x vs 1x
+ const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`);
+ const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`);
+ ok('size chip flips state', await evl(`document.body.dataset.size`) === '1');
+
+ // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`);
+ const before = await evl(`document.getElementById('rd-03').textContent`);
+ await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2);
+ await sleep(150);
+ const after = await evl(`document.getElementById('rd-03').textContent`);
+ ok('fader drag tracks at 3x', before !== after && after !== '—', `'${before}' -> '${after}'`);
+
+ // 4. behavioral at 3x: toggle click on card 01
+ await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`);
+ const t0 = await evl(`document.getElementById('rd-01').textContent`);
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await sleep(150);
+ const t1 = await evl(`document.getElementById('rd-01').textContent`);
+ ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`);
+
+ // late exceptions from interactions
+ const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions after interaction', errs2.length === 0);
+
+ if (shotPath) {
+ await evl(`window.scrollTo(0,0)`);
+ await sleep(300);
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64'));
+ console.log('shot: ' + shotPath);
+ }
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ chrome.kill();
+}
+process.exit(fails.length ? 1 : 0);