1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
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);
|