diff options
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 76 | ||||
| -rw-r--r-- | tests/gallery-probes/probe-vstatus.mjs | 209 |
2 files changed, 283 insertions, 2 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 9740c46..2a2910b 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -126,6 +126,10 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} background:#221f1b;border:1px solid #33302b} .vtally .vn{margin-left:auto;color:var(--cream);font-variant-numeric:tabular-nums} .vtally .vtot{border-top:1px solid var(--wash);margin-top:3px;padding-top:4.5px;color:var(--steel)} +.vtally .vcopy{margin-top:5px;width:100%;font:inherit;font-size:.7rem;letter-spacing:.04em; + background:transparent;color:var(--steel);border:1px solid var(--wash);border-radius:3px; + padding:2.5px 0;cursor:pointer} +.vtally .vcopy:hover{color:var(--gold-hi);border-color:var(--gold)} .stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px; min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap} .wrd{color:var(--gold-hi);font-size:.8rem;letter-spacing:.06em;font-variant-numeric:tabular-nums; @@ -1039,6 +1043,72 @@ const INFO={ const INFO_FIELDS=[['input','input'],['solves','solves'],['use','use'],['limits','limits'], ['origin','origin'],['difficulty','difficulty'],['prefer','prefer when'],['period','period']]; const VLAMP_TITLES={off:'validation: not done',amber:'validation: in progress',green:'validation: done'}; +/* VSTATUS — the baked validation record: the durable half of the walk. + Lamp clicks write localStorage, which is per-browser-profile and dies with a + cache clear, so the walk's progress is baked back into source periodically + (the "copy for source" control under the tally emits this block; paste it + here). Only amber/green are listed — off is the default and stays implicit. + Precedence is localStorage first, VSTATUS second: the live walk wins on the + machine doing it, and the baked record fills in on a fresh profile, after a + clear, or on the other daily driver. 'off' is a stored value like any other, + so deliberately un-checking a card beats a baked green rather than reverting + to it on reload. */ +const VSTATUS={ + "01": "green", + "06": "green", + "25": "green", + "R05": "green", + "12": "green", + "R07": "amber", + "18": "green", + "R10": "green", + "R11": "green", + "R52": "amber" +}; +/* GVexport — the live lamp state in VSTATUS shape, ready to paste back above. + Reads the lamps rather than localStorage so what you copy is what you see. + Builds the text by hand rather than via JSON.stringify: stringify orders + canonical integer keys ('12') numerically ahead of everything else ('01' has + a leading zero, 'R05' isn't numeric), which would reshuffle the baked block + on every bake and bury each walk's real change in diff noise. Card order is + DOM order, so the block grows in place. */ +function GVexport(){ + const rows=[]; + document.querySelectorAll('.vlamp').forEach(l=>{ + const v=l.dataset.v; + if(v!=='off') rows.push(' "'+l.closest('.card').id.slice(5)+'": "'+v+'"');}); + return '{\n'+rows.join(',\n')+'\n}'; +} +/* Surface the export as a selected textarea. This deliberately does NOT try to + reach the clipboard, because on this setup (Chrome 150, native Wayland, no + clipboard manager) neither clipboard path works from a file:// page: + - navigator.clipboard.writeText rejects NotAllowedError, "Write permission + denied" — a file origin can't hold the clipboard-write permission. + - document.execCommand('copy') returns TRUE and writes an empty string. The + clipboard ends up owned, advertising text/plain, serving nothing. Chrome + sets the PRIMARY selection but not CLIPBOARD; a hand-typed Ctrl+C in the + page behaves the same way, so it is not specific to execCommand. + JS can't detect any of this — readText is denied too — so claiming "copied" + would be a lie the code cannot check. Selecting the text is the one thing + that reliably works: a selection IS the X/Wayland PRIMARY selection, readable + with `wl-paste --primary`. Verified twice against the live browser. + The blur teardown is safe: Chrome keeps serving PRIMARY after the textarea is + gone (observed — the export read back fine both times, each after switching + away from the browser, which is what fires the blur). So the selection does + NOT have to stay on screen to survive; the handler only avoids leaving a + textarea sitting on the page. What does clobber PRIMARY is selecting text + anywhere else, which no handler here can prevent. */ +function gvCopy(btn){ + const flash=m=>{btn.textContent=m;setTimeout(()=>btn.textContent='copy for source',2200);}; + const ta=document.createElement('textarea'); + ta.value=GVexport(); + ta.style.cssText='position:fixed;left:12px;bottom:12px;z-index:70;width:320px;height:170px;'+ + 'font:12px ui-monospace,monospace;background:#14120f;color:#e8dcc0;border:1px solid #7d5c16;'+ + 'border-radius:4px;padding:6px'; + document.body.appendChild(ta); ta.select(); + flash('selected ▸'); + ta.addEventListener('blur',()=>ta.remove()); +} /* index tally: recounts every card lamp; setV calls it on every state change. Clicking a row jumps to the next card in that state (cycles), so a count that disagrees with a visual scan can be audited card by card. */ @@ -1052,10 +1122,12 @@ function updateVTally(){ `<div class="vrow" data-v="green"><span class="vdot" data-v="green"></span>done<span class="vn">${n.green}</span></div>`+ `<div class="vrow" data-v="amber"><span class="vdot" data-v="amber"></span>in progress<span class="vn">${n.amber}</span></div>`+ `<div class="vrow" data-v="off"><span class="vdot"></span>not done<span class="vn">${n.off}</span></div>`+ - `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`; + `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`+ + `<button class="vcopy" type="button">copy for source</button>`; el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{ VJUMP[row.dataset.v]=0; auditNext(row.dataset.v); })); + el.querySelector('.vcopy').addEventListener('click',e=>gvCopy(e.target)); } /* audit stepper: a row click jumps to the first card in that state and docks a floating pill; clicking the pill advances through the rest without scrolling @@ -1090,7 +1162,7 @@ function card(host, no, name, html, note){ `<div class="opts"></div><div class="wnote">${note}</div>`; const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green']; const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];updateVTally();}; - setV(localStorage.getItem(VKEY)||'off'); + setV(localStorage.getItem(VKEY)||VSTATUS[no]||'off'); lamp.addEventListener('click',e=>{e.stopPropagation(); const v=VSTATES[(VSTATES.indexOf(lamp.dataset.v)+1)%3]; setV(v);localStorage.setItem(VKEY,v);}); 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); |
