aboutsummaryrefslogtreecommitdiff
path: root/tests/gallery-probes/probe-vstatus.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/gallery-probes/probe-vstatus.mjs')
-rw-r--r--tests/gallery-probes/probe-vstatus.mjs209
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);