From 910993b25ba1736fc7f5958c53a7cdc523f98cf2 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 16 Jul 2026 09:11:24 -0500 Subject: feat(gallery): bake widget validation progress into the source The validation walk's per-card state lived only in browser localStorage, so a cache clear would lose it and the other machine never saw it. VSTATUS now carries the record in source, read as a fallback behind localStorage. The live walk wins on the machine doing it, and a deliberate un-check beats a baked green, because 'off' is a stored value like any other. I build the export's text by hand rather than through JSON.stringify, which hoists canonical integer keys ('12' ahead of '01') and would reshuffle the whole block on every bake. Copying it out never touches the clipboard. navigator.clipboard is denied outright on file://, and execCommand returns true while writing nothing. So the export lands in a selected textarea instead: a selection is readable as the PRIMARY selection, and that's the one path that works here. --- tests/gallery-probes/probe-vstatus.mjs | 209 +++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/gallery-probes/probe-vstatus.mjs (limited to 'tests') diff --git a/tests/gallery-probes/probe-vstatus.mjs b/tests/gallery-probes/probe-vstatus.mjs new file mode 100644 index 0000000..aefe43b --- /dev/null +++ b/tests/gallery-probes/probe-vstatus.mjs @@ -0,0 +1,209 @@ +// 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); -- cgit v1.2.3