diff options
Diffstat (limited to 'tests/gallery-probes')
| -rw-r--r-- | tests/gallery-probes/probe-vstatus.mjs | 209 |
1 files changed, 209 insertions, 0 deletions
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); |
