// 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=2 (M), card count ok('default size 2', await evl(`document.body.dataset.size`) === '2'); 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 await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`); 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);