// CDP probe for the gallery's baked validation statuses (VSTATUS) — no playwright. // Usage: node probe-vstatus.mjs // // Runs against a throwaway --user-data-dir so localStorage starts empty every run: // the whole point of VSTATUS is what renders when there is no local state, so a // probe that inherited a profile's leftover gv-* keys would test nothing. import { spawn } from 'node:child_process'; import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const PORT = 9334; const URL_FILE = '/home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html'; const URL = 'file://' + URL_FILE; const PROFILE = mkdtempSync(join(tmpdir(), 'gallery-vstatus-')); const chrome = spawn('google-chrome-stable', [ '--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${PROFILE}`, '--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 reload() { await send('Page.reload'); await sleep(1200); } const lampOf = no => `document.querySelector('#card-${no} .vlamp').dataset.v`; 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); 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. VSTATUS exists and is a plain record of no -> state const shape = await evl(`(()=>{ if (typeof VSTATUS === 'undefined') return 'undefined'; if (typeof VSTATUS !== 'object' || VSTATUS === null || Array.isArray(VSTATUS)) return 'not-an-object'; const bad = Object.entries(VSTATUS).filter(([,v]) => !['amber','green'].includes(v)); return bad.length ? 'bad-values:' + JSON.stringify(bad.slice(0,3)) : 'ok'; })()`); ok('VSTATUS is a no->state record of amber/green only', shape === 'ok', shape); // 2. every baked entry names a real card const orphans = await evl(`Object.keys(VSTATUS).filter(no => !document.getElementById('card-'+no))`); ok('no baked entry names a missing card', orphans.length === 0, orphans.join(', ')); // 2b. the baked block's SOURCE TEXT is in DOM order — it is hand-pasted from // GVexport, and a block written in some other order reshuffles wholesale on // the next bake. This reads the file rather than the live object on purpose: // JS hoists canonical integer keys ('12' before '01') on any object, so // Object.keys can never report DOM order and asserting it would be // unsatisfiable. Only the text on disk carries the property that matters. const src = readFileSync(URL_FILE, 'utf8'); const block = src.match(/const VSTATUS=\{([\s\S]*?)\n\};/); if (!block) { ok('VSTATUS source block is findable', false, 'regex did not match'); } else { const textKeys = [...block[1].matchAll(/"([^"]+)"\s*:/g)].map(m => m[1]); const domOrder = await evl(`[...document.querySelectorAll('.vlamp')].map(l => l.closest('.card').id.slice(5))`); const want = domOrder.filter(no => textKeys.includes(no)); ok('VSTATUS source text is in DOM order', JSON.stringify(textKeys) === JSON.stringify(want), `baked=${JSON.stringify(textKeys)} want=${JSON.stringify(want)}`); } // 3. with empty localStorage, baked entries render as their baked state const mismatched = await evl(`Object.entries(VSTATUS) .filter(([no,v]) => document.querySelector('#card-'+no+' .vlamp').dataset.v !== v) .map(([no,v]) => no+':want='+v+',got='+document.querySelector('#card-'+no+' .vlamp').dataset.v)`); ok('baked entries render on a fresh profile', mismatched.length === 0, mismatched.slice(0, 5).join(' ')); // 4. a card with no baked entry renders off const unbaked = await evl(`(()=>{ const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); return l ? l.closest('.card').id.slice(5) + '=' + l.dataset.v : 'none'; })()`); ok('an unbaked card defaults to off', unbaked === 'none' || unbaked.endsWith('=off'), unbaked); // 5. the tally's done count equals the green lamps on screen const tally = await evl(`(()=>{ const green = document.querySelectorAll('.vlamp[data-v="green"]').length; const row = document.querySelector('.vtally .vrow[data-v="green"] .vn'); return [green, row ? +row.textContent : -1]; })()`); ok('tally done count matches green lamps', tally[0] === tally[1], `lamps=${tally[0]} tally=${tally[1]}`); // 6. localStorage wins over the default. Holds with an empty VSTATUS, so this // check is live from the first commit rather than waiting on a baked block. const any = await evl(`(()=>{ const l = [...document.querySelectorAll('.vlamp')].find(l => !(l.closest('.card').id.slice(5) in VSTATUS)); return l ? l.closest('.card').id.slice(5) : null; })()`); if (any) { await evl(`localStorage.setItem('gv-${any}','green'); ''`); await reload(); const set = await evl(lampOf(any)); ok('localStorage sets an unbaked lamp', set === 'green', `card ${any} -> ${set}`); await evl(`localStorage.removeItem('gv-${any}'); ''`); await reload(); const cleared = await evl(lampOf(any)); ok('clearing an unbaked lamp returns it to off', cleared === 'off', `card ${any} -> ${cleared}`); } // 6b. localStorage beats baked, both ways — a deliberate un-check must survive a // baked green, and clearing must fall back rather than stick at off. const target = await evl(`Object.keys(VSTATUS)[0] || null`); if (target) { await evl(`localStorage.setItem('gv-${target}','off'); ''`); await reload(); const got = await evl(lampOf(target)); ok('localStorage off overrides a baked entry', got === 'off', `card ${target} -> ${got}`); await evl(`localStorage.removeItem('gv-${target}'); ''`); await reload(); const back = await evl(lampOf(target)); const want = await evl(`VSTATUS['${target}']`); ok('clearing localStorage falls back to baked', back === want, `card ${target} -> ${back}, want ${want}`); } else { console.log('SKIP baked-override checks — VSTATUS is empty (nothing baked yet)'); } // 7. the export affordance reproduces what is on screen, in VSTATUS shape const round = await evl(`(()=>{ if (typeof GVexport !== 'function') return 'no-GVexport'; let parsed; try { parsed = JSON.parse(GVexport()); } catch (e) { return 'not-json: ' + e.message; } const live = {}; document.querySelectorAll('.vlamp').forEach(l => { const v = l.dataset.v; if (v !== 'off') live[l.closest('.card').id.slice(5)] = v; }); return JSON.stringify(parsed) === JSON.stringify(live) ? 'ok' : 'export=' + JSON.stringify(parsed).slice(0,120) + ' live=' + JSON.stringify(live).slice(0,120); })()`); ok('GVexport reproduces the live lamp state', round === 'ok', round); // 7b. exported keys follow card order. JSON.stringify would hoist canonical // integer keys ('12') above the rest ('01', 'R05'), reshuffling the baked // block on every bake; the export builds its text by hand to avoid it. const order = await evl(`(()=>{ const keys = [...GVexport().matchAll(/"([^"]+)":/g)].map(m => m[1]); const dom = [...document.querySelectorAll('.vlamp')] .filter(l => l.dataset.v !== 'off').map(l => l.closest('.card').id.slice(5)); return JSON.stringify(keys) === JSON.stringify(dom) ? 'ok' : 'export=' + JSON.stringify(keys) + ' dom=' + JSON.stringify(dom); })()`); ok('exported keys follow card order', order === 'ok', order); // 8. export tracks a click, so a mid-walk copy is current const clicked = await evl(`(()=>{ const l = document.querySelector('.vlamp'); const no = l.closest('.card').id.slice(5); l.click(); const v = l.dataset.v; const parsed = JSON.parse(GVexport()); return (v === 'off' ? !(no in parsed) : parsed[no] === v) ? 'ok' : 'stale: lamp=' + v + ' export=' + JSON.stringify(parsed[no]); })()`); ok('GVexport tracks a lamp click', clicked === 'ok', clicked); const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown'); ok('no exceptions after interaction', errs2.length === 0); } catch (e) { console.error('PROBE ERROR: ' + e.message); fails.push('probe-error'); } finally { // Wait for Chrome to actually exit before removing its profile — kill() only // signals, and a browser still flushing its profile dir makes rmSync throw // ENOTEMPTY, which would fail an otherwise-green run. chrome.kill(); await Promise.race([new Promise(r => chrome.once('exit', r)), sleep(3000)]); rmSync(PROFILE, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } process.exit(fails.length ? 1 : 0);