aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/gallery-probes/README.md26
-rw-r--r--tests/gallery-probes/probe-fams.mjs101
-rw-r--r--tests/gallery-probes/probe-vstatus.mjs209
-rw-r--r--tests/gallery-probes/probe.mjs753
-rw-r--r--tests/gallery-tokens/test_gen_tokens.py278
-rw-r--r--tests/gallery-widgets/test-gallery-widget.el114
-rw-r--r--tests/hypr-live-update-guard/test_hypr_live_update_guard.py165
-rw-r--r--tests/import-wireguard-configs/fake-nmcli45
-rw-r--r--tests/import-wireguard-configs/test_import_wireguard_configs.py167
-rw-r--r--tests/installer-steps/test_orchestrators.py34
-rw-r--r--tests/installer-steps/test_pacman_install.py95
-rw-r--r--tests/maint-scenarios/test_scenario_plan.py273
-rw-r--r--tests/network-diagnostics/test_network_diagnostics.py215
-rw-r--r--tests/nvidia-preflight/test_nvidia_preflight.py162
-rwxr-xr-xtests/zfs-pre-snapshot/fake-zfs14
-rw-r--r--tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py116
16 files changed, 2766 insertions, 1 deletions
diff --git a/tests/gallery-probes/README.md b/tests/gallery-probes/README.md
new file mode 100644
index 0000000..9b5ce7d
--- /dev/null
+++ b/tests/gallery-probes/README.md
@@ -0,0 +1,26 @@
+# Gallery CDP probes
+
+Behavioral verification for docs/prototypes/panel-widget-gallery.html — headless Chrome
+driven over the DevTools protocol (Node global WebSocket/fetch, no playwright).
+
+- probe.mjs — full regression: card count, size toggle, drag + click behavior under zoom,
+ zero-exception gate. Run: `node probe.mjs [--shot out.png]`. Update the card-count
+ assertion when cards are added.
+- probe-fams.mjs — screen-color families: default fallbacks pixel-identical, chip clicks
+ recolor ink/face/trace, dynamic fills stay var-based.
+
+Both exit nonzero on failure. The gallery's componentization work (widgets.js extraction)
+uses a green run of these as its no-regression gate per batch.
+
+## Traps when writing new checks
+
+- Dispatch clicks on the element that owns the listener, not an ancestor; drive drags
+ with in-page synthetic PointerEvents (setPointerCapture stubbed).
+- A `find()` by textContent that matches nothing returns undefined, and the dispatch
+ no-ops silently — the check then fails looking like a widget bug. Match glyphs
+ exactly against the builder source (e.g. the transport stop button is '⏹' U+23F9,
+ not '■' U+25A0), and prefer asserting the find() hit before dispatching.
+- Reduced-motion emulation must be set before Page.navigate (launch on about:blank,
+ Emulation.setEmulatedMedia, then navigate) or the page's matchMedia snapshot misses it.
+- Kill stale headless Chromes after a crashed run; bracket the pkill pattern
+ (`pkill -f 'remote-debugging-port=934[5]'`) so it can't match its own command line.
diff --git a/tests/gallery-probes/probe-fams.mjs b/tests/gallery-probes/probe-fams.mjs
new file mode 100644
index 0000000..7aa2c4b
--- /dev/null
+++ b/tests/gallery-probes/probe-fams.mjs
@@ -0,0 +1,101 @@
+// Verify screen-family chips: presence, default fallbacks intact, live recolor on click.
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+const PORT = 9335;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const chrome = spawn('google-chrome-stable', ['--headless=new', `--remote-debugging-port=${PORT}`, '--no-first-run', '--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 = [];
+for (let i = 0; i < 40; i++) { try { const l = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const p = l.find(t => t.type === 'page' && t.url.startsWith('file')); if (p) { ws = new WebSocket(p.webSocketDebuggerUrl); break; } } catch {} await sleep(250); }
+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); };
+const send = (method, params = {}) => new Promise(res => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
+const evl = async e => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true }); if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails)); return r.result.result.value; };
+const fails = [];
+const ok = (n, c, d = '') => { console.log(`${c ? 'PASS' : 'FAIL'} ${n}${d ? ' — ' + d : ''}`); if (!c) fails.push(n); };
+await send('Runtime.enable'); await sleep(1500);
+
+/* Everything below runs inside try/finally so a failing check cannot orphan the
+ browser. It could before, and the consequence was worse than a leak: the next
+ run connected to the survivor on this same port and reported results from a
+ STALE page — old widgets.js, chips already clicked. A probe that answers from
+ the previous run is worse than one that crashes. */
+try {
+ ok('no exceptions on load', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+ // count screen-family rows only — card option groups (e.g. card 01) reuse .famchips
+ const famRows = await evl(`[...document.querySelectorAll('.famchips .lab')].filter(l=>l.textContent==='screen').length`);
+ ok('6 screen chip rows', famRows === 6, `got ${famRows}`);
+
+ // default fallback: dmx ink computes to gold-hi rgb(255,190,84)
+ const dmxFill0 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ ok('R10 default ink = gold-hi', dmxFill0 === 'rgb(255, 190, 84)', dmxFill0);
+
+ // click green chip on R10 -> ink becomes phos rgb(127,224,160)
+ await evl(`document.querySelector('#card-R10').querySelector('.fc[title="green"]').click()`);
+ const dmxFill1 = await evl(`getComputedStyle(document.querySelector('#card-R10 svg text')).fill`);
+ ok('R10 green chip recolors ink', dmxFill1 === 'rgb(127, 224, 160)', dmxFill1);
+
+ // radar: default sweep line = gold-hi; click green -> phos
+ const swSel = `document.querySelector('#card-R31')`;
+ const line0 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+ await evl(`${swSel}.querySelector('.fc[title="green"]').click()`);
+ const line1 = await evl(`getComputedStyle(document.querySelectorAll('#card-R31 svg line')[0]).stroke`);
+ ok('R31 green chip recolors furniture', line0 !== line1 && line1 === 'rgb(88, 184, 126)', `${line0} -> ${line1}`);
+
+ // scope (CSS widget): trace stroke changes on amber chip
+ const tr0 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+ await evl(`document.querySelector('.scope').closest('.card').querySelector('.fc[title="amber"]').click()`);
+ const tr1 = await evl(`getComputedStyle(document.querySelector('.scope polyline')).stroke`);
+ ok('N11 amber chip recolors trace', tr0 !== tr1 && tr1 === 'rgb(255, 190, 84)', `${tr0} -> ${tr1}`);
+
+ // R19: drag still works after family switch (click vfd, then check the handle's set() writes var-based fills)
+ await evl(`document.querySelector('#card-R19 .fc[title="vfd"]').click()`);
+ await evl(`document.getElementById('card-R19').gw.set(30,70)`);
+ const barFill = await evl(`(()=>{const b=document.querySelectorAll('#card-R19 svg rect')[10];return b.getAttribute('fill');})()`);
+ ok('R19 dynamic fills use vars', barFill.startsWith('var(--scr-'), barFill);
+
+ // R17: face gradient stop resolves to amber face after chip
+ await evl(`document.querySelector('#card-R17 .fc[title="amber"]').click()`);
+ const face = await evl(`getComputedStyle(document.querySelector('#card-R17 svg radialGradient stop')).stopColor`);
+ ok('R17 amber chip retints face', face === 'rgb(216, 203, 166)', face);
+
+ // R57's window is a screen like any other, and it offers ALL six families
+ // rather than the five its siblings carry — including vfd, the marquee cyan.
+ const kpFams = await evl(`(()=>{
+ const chips = [...document.querySelectorAll('#card-R57 .fc')].map(c => c.title);
+ const want = ['amber','green','red','blue','vfd','white'];
+ return want.every(f => chips.includes(f)) ? 'ok' : 'got ' + JSON.stringify(chips);
+ })()`);
+ ok('R57 offers all six screen families', kpFams === 'ok', kpFams);
+
+ // Default is the shipped gold, unchanged until a chip is clicked. It reads the
+ // same as the amber family's --scr-hi because --gold-hi IS #ffbe54 — which is
+ // what makes the amber default pixel-identical rather than a near-miss.
+ const kpInk0 = await evl(`getComputedStyle(document.querySelector('#card-R57 .kp-pad text')).fill`);
+ ok('R57 default ink = gold-hi', kpInk0 === 'rgb(255, 190, 84)', kpInk0);
+
+ // vfd recolors both the ink and the window behind it — a screen is not just its text
+ await evl(`document.querySelector('#card-R57 .fc[title="vfd"]').click()`);
+ const kpInk1 = await evl(`getComputedStyle(document.querySelector('#card-R57 .kp-pad text')).fill`);
+ ok('R57 vfd chip recolors the ink to marquee cyan', kpInk1 === 'rgb(99, 230, 200)', kpInk1);
+ /* .kp-win, not the first rect on the pad — that one is the faceplate, and
+ reading it made this check report the plate's colour and fail for the wrong
+ reason. */
+ const kpBg = await evl(`getComputedStyle(document.querySelector('#card-R57 .kp-win')).fill`);
+ ok('R57 vfd chip recolors the window too', kpBg === 'rgb(6, 16, 13)', kpBg);
+
+ ok('no exceptions after chip clicks', events.filter(e => e.method === 'Runtime.exceptionThrown').length === 0);
+
+ // screenshot the recolored screens region (scroll R31 into view)
+ await evl(`document.querySelector('#card-R31 svg').scrollIntoView({block:'center'})`);
+ await sleep(400);
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync('fams.png', Buffer.from(shot.result.data, 'base64'));
+ console.log('shot: fams.png');
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ chrome.kill();
+}
+process.exit(fails.length ? 1 : 0);
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);
diff --git a/tests/gallery-probes/probe.mjs b/tests/gallery-probes/probe.mjs
new file mode 100644
index 0000000..5e95096
--- /dev/null
+++ b/tests/gallery-probes/probe.mjs
@@ -0,0 +1,753 @@
+// CDP probe for the widget gallery — no playwright, Node global WebSocket/fetch.
+// Usage: node probe.mjs [--shot out.png] [--size N]
+import { spawn } from 'node:child_process';
+import { writeFileSync } from 'node:fs';
+
+const PORT = 9333;
+const URL = 'file:///home/cjennings/code/archsetup/docs/prototypes/panel-widget-gallery.html';
+const shotIdx = process.argv.indexOf('--shot');
+const shotPath = shotIdx > -1 ? process.argv[shotIdx + 1] : null;
+
+const chrome = spawn('google-chrome-stable', [
+ '--headless=new', `--remote-debugging-port=${PORT}`,
+ '--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 drag(x1, y1, x2, y2) {
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 });
+ const steps = 8;
+ for (let i = 1; i <= steps; i++)
+ await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1 + (x2 - x1) * i / steps, y: y1 + (y2 - y1) * i / steps, button: 'left' });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 });
+}
+
+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);
+
+ // 0. console errors / exceptions
+ 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. defaults: size=2 (M), card count
+ ok('default size 2', await evl(`document.body.dataset.size`) === '2');
+ const cards = await evl(`document.querySelectorAll('.card').length`);
+ ok('111 cards', cards === 111, `got ${cards}`);
+
+ // 2. zoom actually scales: card visual width at 3x vs 1x
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ const w3 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ await evl(`document.querySelector('.szbar .key[data-sz="1"]').click()`);
+ const w1 = await evl(`document.querySelector('.card').getBoundingClientRect().width`);
+ ok('3x wider than 1x', w3 > w1 * 1.8, `w3=${Math.round(w3)} w1=${Math.round(w1)}`);
+ ok('size chip flips state', await evl(`document.body.dataset.size`) === '1');
+
+ // 3. behavioral at 3x: fader drag on card 03 (horizontal fader) changes readout
+ await evl(`document.querySelector('.szbar .key[data-sz="3"]').click()`);
+ await evl(`document.querySelectorAll('.card')[2].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const fr = await evl(`(()=>{const c=document.querySelectorAll('.card')[2];const f=c.querySelector('.fader');const r=f.getBoundingClientRect();return [r.left,r.top,r.width,r.height];})()`);
+ const before = await evl(`document.getElementById('rd-03').textContent`);
+ await drag(fr[0] + fr[2] * 0.2, fr[1] + fr[3] / 2, fr[0] + fr[2] * 0.9, fr[1] + fr[3] / 2);
+ await sleep(150);
+ const after = await evl(`document.getElementById('rd-03').textContent`);
+ ok('fader drag tracks at 3x', before !== after && after !== '—', `'${before}' -> '${after}'`);
+
+ // 4. behavioral at 3x: toggle click on card 01
+ await evl(`document.querySelectorAll('.card')[0].scrollIntoView({block:'center'}); ''`);
+ await sleep(200);
+ const sw = await evl(`(()=>{const c=document.querySelectorAll('.card')[0];const s=c.querySelector('.switch')||c.querySelector('.stagew > *');const r=s.getBoundingClientRect();return [r.left+r.width/2,r.top+r.height/2];})()`);
+ const t0 = await evl(`document.getElementById('rd-01').textContent`);
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: sw[0], y: sw[1], button: 'left', clickCount: 1 });
+ await sleep(150);
+ const t1 = await evl(`document.getElementById('rd-01').textContent`);
+ ok('toggle click responds at 3x', t0 !== t1, `'${t0}' -> '${t1}'`);
+
+ // 5. card 02 console keys: reading order and per-key tone. LIVE is green
+ // because --pass is what the kit means by live everywhere else; gold stays
+ // the generic engaged look. Craig's call, 2026-07-16.
+ const order = await evl(`[...document.querySelectorAll('#card-02 .key')].map(b => b.textContent)`);
+ ok('card 02 keys read SCAN, LIVE, MUTED',
+ JSON.stringify(order) === JSON.stringify(['SCAN', 'LIVE', 'MUTED']), JSON.stringify(order));
+
+ const engaged = await evl(`(()=>{
+ const lit = [...document.querySelectorAll('#card-02 .key')].filter(b => /\\b(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length;
+ })()`);
+ ok('card 02 engages LIVE in green by default', engaged === 'LIVE:green', engaged);
+
+ const muted = await evl(`(()=>{
+ const keys = [...document.querySelectorAll('#card-02 .key')];
+ keys.find(b => b.textContent === 'MUTED').click();
+ const lit = keys.filter(b => /\\b(on|green|red)\\b/.test(b.className));
+ return lit.length === 1 ? lit[0].textContent + ':' + lit[0].className.replace('key ','') : 'lit=' + lit.length;
+ })()`);
+ ok('card 02 MUTED engages red and releases LIVE', muted === 'MUTED:red', muted);
+
+ const rd02 = await evl(`document.getElementById('rd-02').textContent`);
+ ok('card 02 readout tracks the engaged key', rd02 === 'MUTED', rd02);
+
+ // 6. card 01 slide toggle: the new tone atoms exist. red/warn on the `on` axis
+ // let the ENGAGED state be the notable one (mute, record, airplane) — before
+ // these, red was only reachable via the `off` axis, which colours the
+ // disengaged state instead. dim offText lets an off toggle recede in a dense
+ // panel. Craig's call, 2026-07-16.
+ const atoms = await evl(`(()=>{
+ const S = GW.slideToggle.STYLES;
+ const want = { on: ['amber','green','dark','red','warn'], onText: ['panel','cream','green'],
+ off: ['dark','red'], offText: ['white','red','black','dim'],
+ thumb: ['light','dark','chrome','brass'] };
+ const missing = [];
+ for (const [axis, names] of Object.entries(want))
+ for (const n of names) if (!S[axis] || !S[axis][n]) missing.push(axis + '.' + n);
+ return missing.length ? 'missing: ' + missing.join(', ') : 'ok';
+ })()`);
+ ok('slide toggle style atoms present', atoms === 'ok', atoms);
+
+ // 7. presets name intent (a combination), not paint. Every axis a preset names
+ // must resolve in STYLES — a typo here would silently fall through to the
+ // stylesheet default and look "nearly right", which is the worst outcome.
+ const presets = await evl(`(()=>{
+ const P = GW.slideToggle.PRESETS, S = GW.slideToggle.STYLES;
+ if (!P) return 'no PRESETS';
+ const AX = GW.slideToggle.AXIS_ORDER;
+ const bad = [];
+ for (const [name, p] of Object.entries(P)) {
+ for (const ax of AX) {
+ if (!p[ax]) bad.push(name + ' missing ' + ax);
+ else if (!S[ax][p[ax]]) bad.push(name + '.' + ax + '="' + p[ax] + '" not in STYLES');
+ }
+ }
+ return bad.length ? bad.join('; ') : Object.keys(P).join(',');
+ })()`);
+ ok('every preset names a resolvable style on every axis',
+ presets === 'panel,run,armed,caution,dark', presets);
+
+ // 7a. the dark preset: neither state lights the pill, so the legend colour is
+ // the only thing carrying state. Both backgrounds must stay dark while the
+ // inks diverge — if either pill lights, the preset has lost its point.
+ const dark = await evl(`(()=>{
+ const P = GW.slideToggle.PRESETS.dark, S = GW.slideToggle.STYLES;
+ const onBg = S.on[P.on].vars['--sw-on-bg'], offBg = S.off[P.off].vars['--sw-off-bg'];
+ const onInk = S.onText[P.onText].vars['--sw-on-ink'], offInk = S.offText[P.offText].vars['--sw-off-ink'];
+ if (onBg !== offBg) return 'pills differ: on=' + onBg + ' off=' + offBg;
+ if (onInk === offInk) return 'inks identical, state unreadable: ' + onInk;
+ return 'ok';
+ })()`);
+ ok('dark preset keeps both pills dark and the inks distinct', dark === 'ok', dark);
+
+ // 7b. the card claims the preset it is actually in. The widget defaults to
+ // `panel`, so a preset group with nothing lit would assert "no preset
+ // active" — false, and exactly the kind of quiet mislabel this walk exists
+ // to catch. Nothing above this point touches card 01's chips (checks 2-4
+ // only toggle its switch, which does not restyle), so the default still
+ // stands here — but this must stay ABOVE checks 8/8b/9, which do mutate
+ // the chips. Insert preset-touching checks after them, not before.
+ const defaultPreset = await evl(`(()=>{
+ const pg = [...document.querySelectorAll('#card-01 .fgroup')]
+ .find(g => g.querySelector('.lab')?.textContent === 'preset');
+ if (!pg) return 'no preset group';
+ const lit = [...pg.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ return lit.length === 1 ? lit[0].title : 'lit=' + lit.length;
+ })()`);
+ ok('card 01 defaults to the panel preset', defaultPreset === 'panel', defaultPreset);
+
+ // 8. a preset chip drives the widget AND re-syncs the axis chips, so the card
+ // never shows a combination the widget isn't in.
+ const applied = await evl(`(()=>{
+ const card = document.getElementById('card-01');
+ const groups = [...card.querySelectorAll('.fgroup')];
+ const pg = groups.find(g => g.querySelector('.lab')?.textContent === 'preset');
+ if (!pg) return 'no preset group';
+ const armed = [...pg.querySelectorAll('.fc')].find(c => c.title === 'armed');
+ if (!armed) return 'no armed chip';
+ armed.click();
+ const onGroup = groups.find(g => g.querySelector('.lab')?.textContent === 'on');
+ const lit = [...onGroup.querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ const brd = card.querySelector('.switch').style.getPropertyValue('--sw-on-brd');
+ return (lit.length === 1 ? lit[0].title : 'lit=' + lit.length) + '|' + brd;
+ })()`);
+ ok('armed preset drives widget and syncs the on chip', applied === 'red|var(--fail)', applied);
+
+ // 8b. axes are not independent: onText overrides the ink `on` sets, so changing
+ // `on` after picking an onText must not silently revert the legend while its
+ // chip still shows lit. Drive it in the hazardous order and check the ink.
+ const orderSafe = await evl(`(()=>{
+ const card = document.getElementById('card-01');
+ const groups = [...card.querySelectorAll('.fgroup')];
+ const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
+ const chip = (l, t) => [...grp(l).querySelectorAll('.fc')].find(c => c.title === t);
+ chip('on text', 'green').click(); // legend green
+ chip('on', 'dark').click(); // then change the pill — must keep it
+ const ink = card.querySelector('.switch').style.getPropertyValue('--sw-on-ink');
+ const lit = [...grp('on text').querySelectorAll('.fc')].filter(c => c.classList.contains('on'));
+ return ink + '|' + (lit.length === 1 ? lit[0].title : 'lit=' + lit.length);
+ })()`);
+ ok('changing on keeps the chosen onText (chips cannot lie)',
+ orderSafe === 'var(--sevgrn)|green', orderSafe);
+
+ // 9. diverging on one axis clears the preset — the card stops claiming a preset
+ // it is no longer in. Re-establishes its own precondition (preset lit) rather
+ // than inheriting it: the checks above already clicked axis chips, each of
+ // which clears the preset, so asserting lit===0 without re-lighting first
+ // would pass against an already-cleared group and prove nothing — including
+ // against a regression where clearing fired for onText but not for `on`.
+ const diverged = await evl(`(()=>{
+ const groups = [...document.querySelectorAll('#card-01 .fgroup')];
+ const grp = l => groups.find(g => g.querySelector('.lab')?.textContent === l);
+ const litPresets = () => [...grp('preset').querySelectorAll('.fc')].filter(c => c.classList.contains('on')).length;
+ [...grp('preset').querySelectorAll('.fc')].find(c => c.title === 'run').click();
+ const before = litPresets();
+ [...grp('on').querySelectorAll('.fc')].find(c => c.title === 'green').click();
+ return before + '->' + litPresets();
+ })()`);
+ ok('changing an axis clears the preset selection', diverged === '1->0', diverged);
+
+ // 10. R57 ABC keypad — fills the taxonomy's text x alphanumeric empty cell.
+ // ABC order is the whole point: it is what industrial keypads do wherever
+ // the operator can't be assumed to touch-type, and it is what Craig's
+ // reference photos show. A QWERTY drift here would silently lose the idiom.
+ const abcOrder = await evl(`(()=>{
+ const letters = [...document.querySelectorAll('#card-R57 .kp-key')]
+ .map(k => k.dataset.k).filter(k => /^[A-Z]$/.test(k));
+ const want = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
+ return JSON.stringify(letters) === JSON.stringify(want)
+ ? 'ok' : 'got ' + letters.length + ': ' + letters.join('');
+ })()`);
+ ok('R57 carries A-Z in alphabetical order', abcOrder === 'ok', abcOrder);
+
+ const abcDigits = await evl(`(()=>{
+ const d = [...document.querySelectorAll('#card-R57 .kp-key')]
+ .map(k => k.dataset.k).filter(k => /^[0-9]$/.test(k));
+ return d.length === 10 ? 'ok' : 'got ' + d.length + ': ' + d.join('');
+ })()`);
+ ok('R57 carries a full 0-9 block', abcDigits === 'ok', abcDigits);
+
+ // 10b. LAYOUT, geometrically. The A-Z check above reads DOM order, which the
+ // builder controls by push order — it would pass with every key rendered
+ // in the wrong place. This reads actual x positions instead: letters own
+ // the left columns, digits the right, and the alphabet column-aligns with
+ // itself all the way down (the discontinuity Craig caught: A-L used to
+ // start at column 3 while M-X started at column 0).
+ const layout = await evl(`(()=>{
+ const x = k => {
+ const g = [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ return g ? Math.round(g.querySelector('rect').getBBox().x) : null;
+ };
+ const colStarts = ['A','D','G','J','M','S','Y'].map(x);
+ if (new Set(colStarts).size !== 1) return 'alphabet not column-aligned: ' + JSON.stringify(colStarts);
+ if (!(x('A') < x('1'))) return 'letters not left of digits: A=' + x('A') + ' 1=' + x('1');
+ if (!(x('DEL') > x('J'))) return 'DEL not in the block beside the digits: DEL=' + x('DEL') + ' J=' + x('J');
+ return 'ok';
+ })()`);
+ ok('R57 letters left, digits right, alphabet column-aligned', layout === 'ok', layout);
+
+ // 10c. DEL sits where the hand already is and CLR is exiled to the corner.
+ // Frequency and blast radius pull the same way: DEL is constant and costs
+ // one character, CLR is rare and costs the entry. Pinned because it is a
+ // deliberate inversion of where they started, easy to "tidy" back.
+ const reach = await evl(`(()=>{
+ const box = k => {
+ const g = [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ return g ? g.querySelector('rect').getBBox() : null;
+ };
+ const del = box('DEL'), clr = box('CLR'), ent = box('ENT');
+ if (!del || !clr || !ent) return 'missing key';
+ if (!(del.y < clr.y)) return 'DEL should sit above CLR: DEL.y=' + del.y + ' CLR.y=' + clr.y;
+ if (!(Math.abs(del.y - ent.y) < 1)) return 'DEL should share the ENT row';
+ return 'ok';
+ })()`);
+ ok('R57 DEL is in reach, CLR is in the corner', reach === 'ok', reach);
+
+ // 10d. The three function keys are a cost ladder — DEL takes one character
+ // back, CLR throws the entry away, ENT commits — so each must read as a
+ // different key before the legend is read. Checks they are mutually
+ // distinct and all differ from a plain cap, rather than naming a gradient:
+ // the palette may be retuned, the distinction may not collapse.
+ const ladder = await evl(`(()=>{
+ const fill = k => {
+ const g = [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ return g ? g.querySelector('rect').getAttribute('fill') : null;
+ };
+ const f = { DEL: fill('DEL'), CLR: fill('CLR'), ENT: fill('ENT'), plain: fill('A'), digit: fill('1') };
+ const fn = [f.DEL, f.CLR, f.ENT];
+ if (new Set(fn).size !== 3) return 'function keys not mutually distinct: ' + JSON.stringify(f);
+ if (fn.includes(f.plain) || fn.includes(f.digit)) return 'a function key wears a plain cap: ' + JSON.stringify(f);
+ return 'ok';
+ })()`);
+ ok('R57 DEL/CLR/ENT each read as their own key', ladder === 'ok', ladder);
+
+ // 11. typing accumulates, in order. A keypad that registers presses but drops
+ // or reorders them is the failure that matters for a password field.
+ const typed = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ for (const c of ['W','I','F','I','7']) key(c).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ return document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 accumulates typed characters in order', typed.includes('WIFI7'), typed);
+
+ // 11b. DEL takes back ONE character. Without it the only way out of a typo is
+ // wiping the whole entry, which on a 20-character passphrase means
+ // starting over — so the check that matters is that DEL is not CLR.
+ const del = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ click('CLR');
+ for (const c of ['C','A','B','S']) click(c);
+ click('DEL');
+ const back = document.getElementById('rd-R57').textContent;
+ for (let i = 0; i < 6; i++) click('DEL'); // past empty: must not throw or wrap
+ const floor = document.getElementById('rd-R57').textContent;
+ return back + ' | ' + floor;
+ })()`);
+ // Both halves are asserted: the earlier version computed the past-empty
+ // state and then never looked at it, so "stops at empty" was a promise in
+ // the name only — a DEL that wrapped the buffer would have passed.
+ ok('R57 DEL takes back one character, and stops at empty',
+ del.split(' | ')[0] === 'CAB' && del.split(' | ')[1] === 'empty', del);
+
+ // 11c. A space must be VISIBLE in the window. The buffer is honest either way,
+ // but SVG collapses trailing whitespace, so a space rendered as a space is
+ // a keypress with no feedback: the operator presses SPACE, sees nothing,
+ // presses again, and now carries two spaces they cannot see in a
+ // passphrase they can't read back. Checked past the 13-char truncation
+ // boundary, where there are no pad dots left for a space to displace.
+ const space = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ const win = () => document.querySelector('#card-R57 text[font-size="14"]').textContent;
+ click('CLR');
+ for (let i = 0; i < 13; i++) click('A');
+ const before = win();
+ click('SPC');
+ const after = win();
+ if (before === after) return 'space produced no visible change: ' + JSON.stringify(after);
+ if (/ $/.test(after)) return 'space rendered as a raw trailing space (invisible): ' + JSON.stringify(after);
+ click('CLR');
+ return 'ok';
+ })()`);
+ ok('R57 a typed space is visible in the window', space === 'ok', space);
+
+ // 12. the two committing keys do different things: ENTER commits the buffer,
+ // CLEAR empties it. Types its own buffer rather than inheriting one from
+ // the checks above — they mutate it, so a check that assumed their leftovers
+ // would pass or fail on their behaviour instead of its own.
+ const committed = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ const click = k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ click('CLR');
+ for (const c of ['N','E','T','5']) click(c);
+ click('ENT');
+ const after = document.getElementById('rd-R57').textContent;
+ click('CLR');
+ return after + ' | ' + document.getElementById('rd-R57').textContent;
+ })()`);
+ // Asserts the COMMIT SIGNAL ('ENTER · ' + buf), not merely that the buffer
+ // is still readable: typing the last character already put NET5 in the
+ // readout, so /NET5/ was true before ENT was ever pressed. That check
+ // passed with the ENT branch deleted (the key falls through to buf += 'ENT'
+ // and NET5 still matches) — it could not fail.
+ ok('R57 ENTER commits and CLEAR empties',
+ /^ENTER · NET5$/.test(committed.split(' | ')[0]) && !/NET5/.test(committed.split(' | ')[1]), committed);
+
+ // 13. KEYS is a declarative TABLE, not a function over a DOM event. The Emacs
+ // port installs this same table into a keymap — it never sees a keydown —
+ // so a function here would force it to re-derive the widget's intent and
+ // the two bindings would drift. (README, keyboard contract.)
+ const keysTable = await evl(`(()=>{
+ const K = GW.abcKeypad.KEYS;
+ if (!K) return 'no KEYS';
+ if (typeof K !== 'object' || Array.isArray(K)) return 'KEYS is not a plain table: ' + typeof K;
+ const missing = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'].filter(c => K[c] !== c);
+ if (missing.length) return 'unmapped or mis-mapped: ' + missing.join('');
+ const want = { Space: 'SPC', Backspace: 'DEL', Enter: 'ENT' };
+ for (const [k, v] of Object.entries(want)) if (K[k] !== v) return k + ' -> ' + K[k] + ', want ' + v;
+ return 'ok';
+ })()`);
+ ok('R57 KEYS is a declarative table covering the plate', keysTable === 'ok', keysTable);
+
+ // 13b. THE contract's first rule: no document-level listener. A widget that
+ // binds globally types into itself from anywhere on a 110-card page.
+ // Typing at the body with the card unfocused must do nothing at all.
+ const unfocused = await evl(`(()=>{
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ document.activeElement.blur();
+ for (const c of ['A','B','C']) document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { key: c, bubbles: true, cancelable: true }));
+ return document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 ignores keys when it does not have focus', unfocused === 'cleared', unfocused);
+
+ // 13c. Focused, the same keys land — and land through press(), so click and key
+ // cannot drift apart.
+ const typedByKey = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ ['n','e','t',' ','5'].forEach(send); // lowercase must normalise; space must map to SPC
+ const typed = document.getElementById('rd-R57').textContent;
+ send('Backspace');
+ const bs = document.getElementById('rd-R57').textContent;
+ send('Enter');
+ return typed + ' | ' + bs + ' | ' + document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 types from the keyboard when focused', typedByKey === 'NET 5 | NET | ENTER · NET ', typedByKey);
+
+ // 13c-2. Click and key must not drift. Both routes are supposed to land in the
+ // same press(), so the same sequence entered each way must produce an
+ // identical buffer and readout. Duplicated logic in the handler would
+ // pass every check above this one and fail here.
+ const drift = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ const rd = () => document.getElementById('rd-R57').textContent;
+ const seq = ['A','B','SPC','7'];
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ seq.forEach(k => key(k).dispatchEvent(new MouseEvent('click', {bubbles:true})));
+ const byClick = rd() + '/' + document.getElementById('card-R57').gw.get();
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ ['A','B',' ','7'].forEach(k =>
+ pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })));
+ const byKey = rd() + '/' + document.getElementById('card-R57').gw.get();
+ return byClick === byKey ? 'ok' : 'drift: click=' + byClick + ' key=' + byKey;
+ })()`);
+ ok('R57 click and key land in the same place', drift === 'ok', drift);
+
+ // 13c-3. press() is the allowlist, not the keydown handler. The handler guards
+ // the web; the Emacs port installs KEYS into a keymap and calls press
+ // directly, with no handler in the stack — so a press that trusts its
+ // caller is a hole in exactly the target the table exists for.
+ const pressGuard = await evl(`(()=>{
+ const card = document.getElementById('card-R57');
+ const h = card.gw;
+ if (!h || !h.press) return 'no handle';
+ const key = k => [...card.querySelectorAll('.kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ h.press('F1'); h.press('ArrowLeft'); h.press('');
+ const junk = h.get();
+ h.press('A');
+ return junk === '' && h.get() === 'A' ? 'ok' : 'junk=' + JSON.stringify(junk) + ' then=' + JSON.stringify(h.get());
+ })()`);
+ ok('R57 press() filters junk from any caller, not just the keyboard', pressGuard === 'ok', pressGuard);
+
+ // 13c-4. Every key the table maps must be a real plate action, or the Emacs
+ // port installs a binding that silently does nothing.
+ const keysSubset = await evl(`(()=>{
+ const bad = Object.entries(GW.abcKeypad.KEYS).filter(([,v]) => !GW.abcKeypad.ACTIONS.has(v));
+ return bad.length ? 'KEYS maps to non-actions: ' + JSON.stringify(bad) : 'ok';
+ })()`);
+ ok('R57 every KEYS value is a real plate action', keysSubset === 'ok', keysSubset);
+
+ // 13d. preventDefault is spent only where there is a default worth killing.
+ // Space scrolls and Backspace navigates back, so those are claimed; Tab is
+ // how the page is navigable and Escape belongs to the audit stepper, so a
+ // widget that swallows either breaks something it cannot see.
+ const defaults = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .kp-pad');
+ pad.focus();
+ const fired = k => {
+ const e = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true });
+ pad.dispatchEvent(e);
+ return e.defaultPrevented;
+ };
+ const claimed = { Space: fired(' '), Backspace: fired('Backspace') };
+ /* 'A' and 'Enter' are MAPPED keys that must still not be claimed — they reach
+ the same code path as Space, so they are what catches a preventDefault
+ moved after the lookup. Tab/Escape/F1 return before it and would stay green
+ through that regression on their own. */
+ const free = { A: fired('A'), Enter: fired('Enter'), Tab: fired('Tab'), Escape: fired('Escape'), F1: fired('F1') };
+ if (!claimed.Space || !claimed.Backspace) return 'not claimed: ' + JSON.stringify(claimed);
+ if (Object.values(free).some(Boolean)) return 'swallowed: ' + JSON.stringify(free);
+ return 'ok';
+ })()`);
+ ok('R57 claims Space and Backspace, lets Tab/Escape through', defaults === 'ok', defaults);
+
+ // 13e. press() is an allowlist, not a mailbox: an unmapped key must not append.
+ const unmapped = await evl(`(()=>{
+ const pad = document.querySelector('#card-R57 .kp-pad');
+ const key = k => [...document.querySelectorAll('#card-R57 .kp-key')].find(e => e.dataset.k === k);
+ key('CLR').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ ['F1','ArrowLeft','Home','é','!'].forEach(k =>
+ pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })));
+ return document.getElementById('rd-R57').textContent;
+ })()`);
+ ok('R57 drops keys that are not on the plate', unmapped === 'cleared', unmapped);
+
+ // 14. R58 index typewriter. THE check: selecting is not committing. Walking the
+ // stylus over the plate must print nothing at all — that separation IS the
+ // grammar, and it's the whole reason this card exists next to R57. If a cell
+ // click ever prints, the card has silently become a keypad with extra steps.
+ // Reads the BUFFER, not the card readout: the readout is supposed to change
+ // while hunting ("stylus over g"), and asserting on it would fail a correct
+ // widget for showing the operator where the pointer is.
+ const grammar = await evl(`(()=>{
+ const cell = c => document.querySelector('#card-R58 .ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .ix-lever');
+ const buf = () => document.getElementById('card-R58').gw.get();
+ document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ const empty = buf();
+ ['M','i','g'].forEach(c => cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true})));
+ const afterSelecting = buf();
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ return JSON.stringify([empty, afterSelecting, buf()]);
+ })()`);
+ ok('R58 selecting prints nothing; only the lever commits', grammar === '["","","g"]', grammar);
+
+ // 14b. The lever prints whatever the stylus is resting on, once per pull — the
+ // operator's two hands are two separate acts.
+ const spelled = await evl(`(()=>{
+ const cell = c => document.querySelector('#card-R58 .ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .ix-lever');
+ document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ for (const c of ['M','i','g','n','o','n']) {
+ cell(c).dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ }
+ return document.getElementById('rd-R58').textContent;
+ })()`);
+ ok('R58 stylus + lever spells a word', spelled.includes('Mignon'), spelled);
+
+ // 14c. Pulling the lever twice prints the character twice: the selection stays
+ // put, which is what lets you type 'ss' without re-aiming.
+ const repeat = await evl(`(()=>{
+ const cell = c => document.querySelector('#card-R58 .ix-cell[data-c="' + c + '"]');
+ const lever = document.querySelector('#card-R58 .ix-lever');
+ document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ cell('s').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ lever.dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ return document.getElementById('card-R58').gw.get();
+ })()`);
+ /* Exact, on the buffer. /ss$/ on the readout also matched 'sss', so it passed
+ even if selecting printed — unable to fail on the one bug this card is about. */
+ ok('R58 the lever repeats without re-aiming', repeat === 'ss', JSON.stringify(repeat));
+
+ // 14d. The plate's whole point, per Craig: it considered the characters a
+ // keyboard skips. Both cases with no shift key, plus accents, fractions and
+ // the section mark — that coverage is the idea being preserved from the
+ // Mignon, where the key ORDER deliberately is not.
+ const charset = await evl(`(()=>{
+ const have = new Set([...document.querySelectorAll('#card-R58 .ix-cell')].map(c => c.dataset.c));
+ const need = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ', ...'abcdefghijklmnopqrstuvwxyz',
+ ...'0123456789', 'ä','ö','ü','Ä','Ö','Ü','ß','§','½','¼'];
+ const missing = need.filter(c => !have.has(c));
+ return missing.length ? 'missing: ' + missing.join(' ') : 'ok';
+ })()`);
+ ok('R58 plate carries both cases, digits and the extended set', charset === 'ok', charset);
+
+ // 14e. The layout is DATA, so revising the order is a table edit rather than a
+ // redraw. Craig has already said the keys will change; a layout welded into
+ // the drawing is one that never does.
+ const layoutData = await evl(`(()=>{
+ const L = GW.indexPlate && GW.indexPlate.LAYOUT;
+ if (!L) return 'no LAYOUT';
+ if (!Array.isArray(L) || !L.every(Array.isArray)) return 'LAYOUT is not a grid';
+ const cells = document.querySelectorAll('#card-R58 .ix-cell').length;
+ const declared = L.flat().filter(Boolean).length;
+ return cells === declared ? 'ok' : 'drawn ' + cells + ' but declared ' + declared;
+ })()`);
+ ok('R58 layout is a declared table the plate renders', layoutData === 'ok', layoutData);
+
+ // 14f. Nothing sits on top of the plate. The lever and CLR started life over the
+ // last column, burying characters and the PRINT legend, and every check
+ // above stayed green through it — geometry is invisible to behaviour. The
+ // gutter is sized off the layout table, so a wider plate must not slide the
+ // controls back onto the characters.
+ const clear = await evl(`(()=>{
+ const box = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; };
+ const cells = [...document.querySelectorAll('#card-R58 .ix-cell')].map(c => c.getBBox());
+ const right = Math.max(...cells.map(b => b.x + b.width));
+ const hits = [];
+ for (const sel of ['.ix-lever', '.ix-clear']) {
+ const b = box(sel);
+ if (!b) { hits.push(sel + ' missing'); continue; }
+ if (b.x < right) hits.push(sel + ' starts at ' + Math.round(b.x) + ', left of the plate edge ' + Math.round(right));
+ }
+ return hits.length ? hits.join('; ') : 'ok';
+ })()`);
+ ok('R58 lever and CLR clear the plate', clear === 'ok', clear);
+
+ // 14g. The gutter stack doesn't collide with itself. The x-only check above
+ // can't see this: the lever and legend are anchored to the plate's top and
+ // CLR to the viewBox, so a SHORTER layout table (five rows is a plausible
+ // edit) used to ride CLR up over the PRINT legend. Growth was always safe;
+ // shrink was the trap, which is why VH now takes a floor.
+ const stack = await evl(`(()=>{
+ const bb = sel => { const e = document.querySelector('#card-R58 ' + sel); return e ? e.getBBox() : null; };
+ const lever = bb('.ix-lever'), clr = bb('.ix-clear');
+ const legend = [...document.querySelectorAll('#card-R58 text')].find(t => t.textContent === 'PRINT');
+ if (!lever || !clr || !legend) return 'missing gutter part';
+ const lg = legend.getBBox();
+ const overlaps = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width &&
+ a.y < b.y + b.height && b.y < a.y + a.height;
+ if (overlaps(lever, clr)) return 'lever overlaps CLR';
+ if (overlaps(lg, clr)) return 'PRINT legend overlaps CLR';
+ if (overlaps(lg, lever)) return 'PRINT legend overlaps the lever';
+ const vb = document.querySelector('#card-R58 .ix-pad').viewBox.baseVal;
+ if (clr.y + clr.height > vb.height) return 'CLR falls outside the viewBox';
+ return 'ok';
+ })()`);
+ ok('R58 gutter stack does not collide or overflow', stack === 'ok', stack);
+
+ // 14h. The layout table has no duplicate characters. cells{} and KEYS{} are both
+ // keyed by character, so a repeat would silently keep only the last: two
+ // cells would render and both be clickable, but clicking the first would
+ // jump the stylus across the plate to the second. 14e can't see it — a
+ // duplicate inflates the drawn count and the declared count equally.
+ const dupes = await evl(`(()=>{
+ const flat = GW.indexPlate.LAYOUT.flat().filter(Boolean);
+ const seen = new Set(), dup = new Set();
+ for (const c of flat) (seen.has(c) ? dup : seen).add(c);
+ return dup.size ? 'duplicated on the plate: ' + [...dup].join(' ') : 'ok';
+ })()`);
+ ok('R58 layout has no duplicate characters', dupes === 'ok', dupes);
+
+ // 14i. press() is the allowlist for R58 too. R57 has this check; without it,
+ // press('F1') reaching select() unfiltered would ship green.
+ const ixPress = await evl(`(()=>{
+ const h = document.getElementById('card-R58').gw;
+ h.press('CLR');
+ ['F1', 'PRINT ', '', 'constructor', 'ZZ'].forEach(k => h.press(k));
+ return JSON.stringify([h.get(), h.selected()]);
+ })()`);
+ ok('R58 press() filters junk from any caller', ixPress.startsWith('[""'), ixPress);
+
+ // 15. R58's keymap comes OUT of the layout table rather than beside it, so
+ // relaying the plate can't leave a keybinding pointing at a character the
+ // plate no longer carries. Enter is the lever.
+ const ixKeys = await evl(`(()=>{
+ const K = GW.indexPlate.KEYS, L = GW.indexPlate.LAYOUT;
+ if (!K) return 'no KEYS';
+ const chars = L.flat().filter(Boolean);
+ const missing = chars.filter(c => K[c] !== c);
+ if (missing.length) return 'plate chars not mapped: ' + missing.join(' ');
+ if (K.Enter !== 'PRINT') return 'Enter -> ' + K.Enter + ', want PRINT';
+ const extra = Object.keys(K).filter(k => k !== 'Enter' && !chars.includes(k));
+ return extra.length ? 'maps keys not on the plate: ' + extra.join(' ') : 'ok';
+ })()`);
+ ok('R58 keymap is derived from the layout, Enter is the lever', ixKeys === 'ok', ixKeys);
+
+ // 15b. THE grammar again, now through the keyboard. Typing a letter must move
+ // the stylus and print NOTHING. If a keypress ever prints, the card has
+ // quietly become R57 with a nicer plate, and the one thing it exists to
+ // demonstrate is gone.
+ const ixType = await evl(`(()=>{
+ const pad = document.querySelector('#card-R58 .ix-pad');
+ const h = document.getElementById('card-R58').gw;
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ ['M','i','g'].forEach(send);
+ const afterTyping = h.get();
+ const resting = h.selected();
+ send('Enter');
+ return JSON.stringify([afterTyping, resting, h.get()]);
+ })()`);
+ ok('R58 typing selects, Enter prints', ixType === '["","g","g"]', ixType);
+
+ // 15c. The plate holds both cases, so nothing is uppercased on the way in:
+ // Shift picks the case because 'a' and 'A' are different cells. R57 has to
+ // uppercase; this one must not, and that difference is the plate's whole
+ // argument for having no shift key.
+ const ixCase = await evl(`(()=>{
+ const pad = document.querySelector('#card-R58 .ix-pad');
+ const h = document.getElementById('card-R58').gw;
+ const send = k => pad.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
+ document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ pad.focus();
+ send('a'); send('Enter');
+ send('A'); send('Enter');
+ return h.get();
+ })()`);
+ ok('R58 keeps case: a and A are different cells', ixCase === 'aA', ixCase);
+
+ // 15d. Space isn't on the plate, so it isn't ours: it must scroll the page like
+ // always. (The missing space cell is a known gap — the real Mignon has a
+ // separate space key.)
+ const ixSpace = await evl(`(()=>{
+ const pad = document.querySelector('#card-R58 .ix-pad');
+ pad.focus();
+ const e = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
+ pad.dispatchEvent(e);
+ return e.defaultPrevented ? 'swallowed space' : 'ok';
+ })()`);
+ ok('R58 leaves Space alone (not on the plate)', ixSpace === 'ok', ixSpace);
+
+ // 15e. Unfocused, it hears nothing — the contract's first rule, on the second
+ // widget to take keys.
+ // Asserts nothing CHANGED, rather than expecting a cleared selection: CLR
+ // is fresh paper, and fresh paper doesn't move the operator's hand, so the
+ // stylus legitimately stays where the previous check left it.
+ const ixBlur = await evl(`(()=>{
+ const h = document.getElementById('card-R58').gw;
+ document.querySelector('#card-R58 .ix-clear').dispatchEvent(new MouseEvent('click', {bubbles:true}));
+ document.activeElement.blur();
+ const before = JSON.stringify([h.get(), h.selected()]);
+ ['Q','Z'].forEach(k => document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })));
+ const after = JSON.stringify([h.get(), h.selected()]);
+ return before === after ? 'ok' : before + ' -> ' + after;
+ })()`);
+ ok('R58 ignores keys when unfocused', ixBlur === 'ok', ixBlur);
+
+ // late exceptions from interactions
+ const errs2 = events.filter(e => e.method === 'Runtime.exceptionThrown');
+ ok('no exceptions after interaction', errs2.length === 0);
+
+ if (shotPath) {
+ await evl(`window.scrollTo(0,0)`);
+ await sleep(300);
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
+ writeFileSync(shotPath, Buffer.from(shot.result.data, 'base64'));
+ console.log('shot: ' + shotPath);
+ }
+} catch (e) {
+ console.error('PROBE ERROR: ' + e.message);
+ fails.push('probe-error');
+} finally {
+ chrome.kill();
+}
+process.exit(fails.length ? 1 : 0);
diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py
new file mode 100644
index 0000000..b9fbb50
--- /dev/null
+++ b/tests/gallery-tokens/test_gen_tokens.py
@@ -0,0 +1,278 @@
+"""Tests for docs/prototypes/gen_tokens.py.
+
+gen_tokens.py is the single-source token generator for the panel widget
+gallery. It reads docs/prototypes/tokens.json (the neutral source of truth
+for the design tokens) and emits three target representations from it:
+
+ - web CSS custom properties (:root { --gold:#e2a038; ... })
+ - waybar GTK CSS (@define-color gold #e2a038; ...)
+ - Emacs elisp (an alist for the future svg.el renderer)
+
+The three differ on purpose: CSS uses hyphenated --vars and stores glow
+colors as bare "r,g,b" triples (so rgba(var(--glow-hi),.5) works); GTK CSS
+has no custom properties, so it uses @define-color with underscore names
+and resolves glows to their source hex (GTK uses alpha(@color,a)); elisp
+uses a hyphenated-symbol alist of hex strings. One source, three emitters
+is the whole reason the generator earns its keep across the three targets.
+
+These tests import the REAL gen_tokens module from docs/prototypes/ (not a
+copy) and exercise its functions directly against fixture token dicts, plus
+one integration test that runs main() against temp copies.
+
+Run from repo root:
+ make test-unit
+ (or python3 -m unittest tests.gallery-tokens.test_gen_tokens)
+"""
+
+import importlib.util
+import json
+import os
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+GEN = os.path.join(REPO_ROOT, "docs", "prototypes", "gen_tokens.py")
+TOKENS_JSON = os.path.join(REPO_ROOT, "docs", "prototypes", "tokens.json")
+
+
+def _load_module():
+ """Import the real gen_tokens.py by path (no importable package name)."""
+ spec = importlib.util.spec_from_file_location("gen_tokens", GEN)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+gt = _load_module()
+
+
+# A small deterministic fixture — not the real tokens.json, so these tests
+# don't drift when the palette is retuned.
+FIXTURE = {
+ "palette": {"ground": "#151311", "wash": "#2c2f32", "slate-hi": "#54677d"},
+ "amber": {"gold": "#e2a038", "gold-hi": "#ffbe54", "amber-grad-top": "#f2c76a"},
+ "glow": {"glow-hi": "gold-hi", "glow-lo": "gold"},
+ "font": {"mono": '"Berkeley Mono",monospace'},
+ "timing": {"pulse-rate": "1s"},
+}
+
+
+class HexToTriple(unittest.TestCase):
+ def test_normal_values(self):
+ self.assertEqual(gt.hex_to_triple("#ffbe54"), "255,190,84")
+ self.assertEqual(gt.hex_to_triple("#e2a038"), "226,160,56")
+
+ def test_boundary_black_and_white(self):
+ self.assertEqual(gt.hex_to_triple("#000000"), "0,0,0")
+ self.assertEqual(gt.hex_to_triple("#ffffff"), "255,255,255")
+
+ def test_boundary_three_digit_shorthand(self):
+ self.assertEqual(gt.hex_to_triple("#fff"), "255,255,255")
+ self.assertEqual(gt.hex_to_triple("#0f0"), "0,255,0")
+
+ def test_boundary_no_leading_hash(self):
+ self.assertEqual(gt.hex_to_triple("e2a038"), "226,160,56")
+
+ def test_error_bad_chars(self):
+ with self.assertRaises(ValueError):
+ gt.hex_to_triple("#gggggg")
+
+ def test_error_bad_length(self):
+ with self.assertRaises(ValueError):
+ gt.hex_to_triple("#12")
+
+
+class ResolveColor(unittest.TestCase):
+ def test_finds_in_amber(self):
+ self.assertEqual(gt.resolve_color(FIXTURE, "gold"), "#e2a038")
+
+ def test_finds_in_palette(self):
+ self.assertEqual(gt.resolve_color(FIXTURE, "wash"), "#2c2f32")
+
+ def test_missing_raises(self):
+ with self.assertRaises(KeyError):
+ gt.resolve_color(FIXTURE, "nonexistent")
+
+
+class EmitWebCss(unittest.TestCase):
+ def setUp(self):
+ self.css = gt.emit_web_css(FIXTURE)
+
+ def test_solid_colors_are_hyphenated_vars(self):
+ self.assertIn("--gold:#e2a038;", self.css)
+ self.assertIn("--slate-hi:#54677d;", self.css)
+ self.assertIn("--amber-grad-top:#f2c76a;", self.css)
+
+ def test_glow_stored_as_rgb_triple(self):
+ # so rgba(var(--glow-hi),.5) resolves; derived from the source hex
+ self.assertIn("--glow-hi:255,190,84;", self.css)
+ self.assertIn("--glow-lo:226,160,56;", self.css)
+
+ def test_font_and_timing(self):
+ self.assertIn("--pulse-rate:1s;", self.css)
+ self.assertIn('--mono:"Berkeley Mono",monospace;', self.css)
+
+
+class EmitWaybarGtk(unittest.TestCase):
+ def setUp(self):
+ self.gtk = gt.emit_waybar_gtk(FIXTURE)
+
+ def test_uses_define_color_not_custom_props(self):
+ self.assertIn("@define-color gold #e2a038;", self.gtk)
+ self.assertNotIn("--gold", self.gtk)
+ self.assertNotIn(":root", self.gtk)
+
+ def test_glow_resolved_to_source_hex(self):
+ # GTK has no bare triples; it uses alpha(@color,a), so glow -> hex
+ self.assertIn("@define-color glow_hi #ffbe54;", self.gtk)
+ self.assertIn("@define-color glow_lo #e2a038;", self.gtk)
+
+ def test_names_use_underscores_not_hyphens(self):
+ self.assertIn("@define-color slate_hi #54677d;", self.gtk)
+ self.assertNotIn("slate-hi", self.gtk)
+ self.assertNotIn("amber-grad-top", self.gtk)
+
+
+class EmitElisp(unittest.TestCase):
+ def setUp(self):
+ self.el = gt.emit_elisp(FIXTURE)
+
+ def test_alist_of_hex_with_hyphenated_symbols(self):
+ self.assertIn('(gold . "#e2a038")', self.el)
+ self.assertIn('(slate-hi . "#54677d")', self.el)
+
+ def test_glow_is_hex_for_svg(self):
+ # svg.el / librsvg wants a real color, not a bare triple
+ self.assertIn('(glow-hi . "#ffbe54")', self.el)
+
+ def test_timing_included(self):
+ self.assertIn('(pulse-rate . "1s")', self.el)
+
+
+class ReplaceBetweenMarkers(unittest.TestCase):
+ START = "/* @tokens:start */"
+ END = "/* @tokens:end */"
+
+ def _wrap(self, inner):
+ return f"a\n{self.START}\n{inner}\n{self.END}\nb\n"
+
+ def test_replaces_inner_block(self):
+ src = self._wrap("OLD")
+ out = gt.replace_between_markers(src, self.START, self.END, "NEW")
+ self.assertIn("NEW", out)
+ self.assertNotIn("OLD", out)
+ # markers and surrounding text survive
+ self.assertIn(self.START, out)
+ self.assertIn(self.END, out)
+ self.assertTrue(out.startswith("a\n"))
+ self.assertTrue(out.rstrip().endswith("b"))
+
+ def test_idempotent(self):
+ src = self._wrap("OLD")
+ once = gt.replace_between_markers(src, self.START, self.END, "NEW")
+ twice = gt.replace_between_markers(once, self.START, self.END, "NEW")
+ self.assertEqual(once, twice)
+
+ def test_missing_marker_raises(self):
+ with self.assertRaises(ValueError):
+ gt.replace_between_markers("no markers here", self.START, self.END, "NEW")
+
+ def test_start_marker_on_final_line_without_newline(self):
+ # Defensive branch: no newline after the start marker. The guard must
+ # not let text[:si-of-newline+1] collapse to "" and wipe the prefix.
+ src = f"keep\n{self.START} {self.END}"
+ out = gt.replace_between_markers(src, self.START, self.END, "X")
+ self.assertTrue(out.startswith("keep\n"))
+ self.assertIn(self.END, out)
+ self.assertIn("X", out)
+
+
+class RealTokensJson(unittest.TestCase):
+ """The committed tokens.json must stay well-formed and complete."""
+
+ def setUp(self):
+ with open(TOKENS_JSON) as f:
+ self.tokens = json.load(f)
+
+ def test_has_required_sections(self):
+ for section in ("palette", "amber", "glow", "font", "timing"):
+ self.assertIn(section, self.tokens)
+
+ def test_amber_defines_gold_and_glow_sources_resolve(self):
+ self.assertIn("gold", self.tokens["amber"])
+ self.assertIn("gold-hi", self.tokens["amber"])
+ for _name, source in self.tokens["glow"].items():
+ # every glow source must resolve to a real color
+ gt.resolve_color(self.tokens, source)
+
+ def test_pulse_rate_present(self):
+ self.assertIn("pulse-rate", self.tokens["timing"])
+
+
+class DefaultElispFilename(unittest.TestCase):
+ """The default elisp target must be gallery-tokens.el so that the file's
+ (provide 'gallery-tokens) matches its name and `require` can resolve it
+ from a load-path. A tokens.el/gallery-tokens feature mismatch is a trap."""
+
+ def test_default_elisp_path_matches_provided_feature(self):
+ self.assertEqual(
+ os.path.basename(gt.DEFAULT_ELISP_NAME), "gallery-tokens.el")
+
+
+class MainIntegration(unittest.TestCase):
+ """main() rewrites the html :root and writes the waybar + elisp files."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="gen-tokens-test-")
+ self.html = os.path.join(self.tmp, "gallery.html")
+ self.tokens_path = os.path.join(self.tmp, "tokens.json")
+ self.waybar = os.path.join(self.tmp, "tokens-waybar.css")
+ self.elisp = os.path.join(self.tmp, "gallery-tokens.el")
+ with open(self.tokens_path, "w") as f:
+ json.dump(FIXTURE, f)
+ with open(self.html, "w") as f:
+ f.write(
+ "<style>\n:root{\n"
+ "/* @tokens:start */\n"
+ "STALE\n"
+ "/* @tokens:end */\n"
+ "}\n</style>\n"
+ )
+
+ def tearDown(self):
+ import shutil
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def test_main_writes_all_three_targets(self):
+ gt.main(
+ tokens_path=self.tokens_path,
+ html_path=self.html,
+ waybar_path=self.waybar,
+ elisp_path=self.elisp,
+ )
+ with open(self.html) as f:
+ html = f.read()
+ self.assertIn("--gold:#e2a038;", html)
+ self.assertNotIn("STALE", html)
+ self.assertTrue(os.path.exists(self.waybar))
+ self.assertTrue(os.path.exists(self.elisp))
+ with open(self.waybar) as f:
+ self.assertIn("@define-color gold #e2a038;", f.read())
+ with open(self.elisp) as f:
+ self.assertIn('(gold . "#e2a038")', f.read())
+
+ def test_main_is_idempotent(self):
+ gt.main(tokens_path=self.tokens_path, html_path=self.html,
+ waybar_path=self.waybar, elisp_path=self.elisp)
+ with open(self.html) as f:
+ first = f.read()
+ gt.main(tokens_path=self.tokens_path, html_path=self.html,
+ waybar_path=self.waybar, elisp_path=self.elisp)
+ with open(self.html) as f:
+ second = f.read()
+ self.assertEqual(first, second)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el
new file mode 100644
index 0000000..166cd59
--- /dev/null
+++ b/tests/gallery-widgets/test-gallery-widget.el
@@ -0,0 +1,114 @@
+;;; test-gallery-widget.el --- ERT tests for gallery-widget.el -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the svg.el proof-of-concept renderer in docs/prototypes/.
+;; gallery-widget.el reads the generated gallery-tokens.el (same source of truth as
+;; the web gallery and the waybar CSS) and renders gallery widgets as SVG.
+;; These tests exercise the REAL module (loaded by path, not a copy):
+;; token resolution, needle-angle math (normal/boundary/error), and the
+;; rendered SVG document's structure.
+;;
+;; Run from repo root:
+;; make test-elisp
+;; (or emacs --batch -l ert -l tests/gallery-widgets/test-gallery-widget.el \
+;; -f ert-run-tests-batch-and-exit)
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(defvar test-gallery-widget--root
+ (expand-file-name "../.." (file-name-directory (or load-file-name buffer-file-name)))
+ "Repo root, derived from this test file's location.")
+
+(load (expand-file-name "docs/prototypes/gallery-widget.el" test-gallery-widget--root))
+
+;;; --- token access ---
+
+(ert-deftest gallery-widget-token-resolves-hex ()
+ "Known tokens resolve to hex color strings from the generated alist."
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'gold)))
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'glow-hi)))
+ (should (string-match-p "\\`#[0-9a-f]\\{6\\}\\'" (gallery-widget-token 'wash))))
+
+(ert-deftest gallery-widget-token-missing-errors ()
+ "An unknown token name signals an error rather than returning nil."
+ (should-error (gallery-widget-token 'no-such-token)))
+
+;;; --- needle angle math ---
+
+(ert-deftest gallery-widget-needle-angle-normal ()
+ "0..100 maps linearly onto -60..+60 degrees."
+ (should (= (gallery-widget--needle-angle 0) -60.0))
+ (should (= (gallery-widget--needle-angle 50) 0.0))
+ (should (= (gallery-widget--needle-angle 100) 60.0)))
+
+(ert-deftest gallery-widget-needle-angle-boundary-clamps ()
+ "Out-of-range values clamp to the dial's ends instead of overswinging."
+ (should (= (gallery-widget--needle-angle -5) -60.0))
+ (should (= (gallery-widget--needle-angle 150) 60.0)))
+
+(ert-deftest gallery-widget-needle-angle-error-non-number ()
+ "A non-numeric value signals an error."
+ (should-error (gallery-widget--needle-angle "fifty"))
+ (should-error (gallery-widget--needle-angle nil)))
+
+;;; --- rendered SVG structure ---
+
+(defun test-gallery-widget--svg-string (value)
+ "Render the needle gauge at VALUE and return its XML string."
+ (gallery-widget-svg-string (gallery-widget-needle-gauge value)))
+
+(ert-deftest gallery-widget-gauge-renders-svg-document ()
+ "The gauge renders to a parseable SVG document."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ (should (string-match-p "\\`<svg" xml))
+ (should (with-temp-buffer
+ (insert xml)
+ (libxml-parse-xml-region (point-min) (point-max))))))
+
+(ert-deftest gallery-widget-gauge-has-expected-parts ()
+ "Arc, three ticks, needle, hub, and value text are all present."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ ;; arc path stroked in the wash token
+ (should (string-match-p (format "path[^>]*stroke=\"%s\"" (gallery-widget-token 'wash)) xml))
+ ;; exactly three ticks
+ (should (= 3 (cl-count-if (lambda (_) t)
+ (split-string xml "class=\"tick\"" t)
+ :start 1)))
+ ;; needle + hub in the amber tokens
+ (should (string-match-p (format "class=\"needle\"[^>]*stroke=\"%s\""
+ (gallery-widget-token 'gold-hi))
+ xml))
+ ;; hub is a half-dome sitting on the dial's bottom edge, like the web card
+ (should (string-match-p (format "class=\"hub\"[^>]*fill=\"%s\""
+ (gallery-widget-token 'gold))
+ xml))
+ (should (string-match-p "class=\"hub\"[^>]*d=\"M 44 48 A 4 4 0 0 1 52 48 Z\"" xml))
+ ;; value readout
+ (should (string-match-p ">42%<" xml))))
+
+(ert-deftest gallery-widget-gauge-has-glow-filter ()
+ "The needle glow is a real SVG blur filter, not a dropped effect."
+ (let ((xml (test-gallery-widget--svg-string 42)))
+ (should (string-match-p "feGaussianBlur" xml))
+ (should (string-match-p "filter=\"url(#" xml))))
+
+(ert-deftest gallery-widget-gauge-needle-tracks-value ()
+ "The needle endpoint lands where the angle math says: left at 0, up at 50, right at 100."
+ ;; value 50 -> vertical: x2 = pivot x (48), y2 = 48 - 40 = 8
+ (let ((xml (test-gallery-widget--svg-string 50)))
+ (should (string-match-p "class=\"needle\"[^>]*x2=\"48.0+\"" xml))
+ (should (string-match-p "class=\"needle\"[^>]*y2=\"8.0+\"" xml)))
+ ;; value 100 -> +60 deg: x2 = 48 + 40*sin60 ~ 82.64
+ (should (string-match-p "x2=\"82.6" (test-gallery-widget--svg-string 100)))
+ ;; value 0 -> -60 deg: x2 = 48 - 40*sin60 ~ 13.36
+ (should (string-match-p "x2=\"13.3" (test-gallery-widget--svg-string 0))))
+
+(ert-deftest gallery-widget-gauge-integer-percent-in-readout ()
+ "The readout shows a rounded integer percent, matching the web card."
+ (should (string-match-p ">67%<" (test-gallery-widget--svg-string 66.6))))
+
+(provide 'test-gallery-widget)
+;;; test-gallery-widget.el ends here
diff --git a/tests/hypr-live-update-guard/test_hypr_live_update_guard.py b/tests/hypr-live-update-guard/test_hypr_live_update_guard.py
new file mode 100644
index 0000000..a6c6f68
--- /dev/null
+++ b/tests/hypr-live-update-guard/test_hypr_live_update_guard.py
@@ -0,0 +1,165 @@
+"""Tests for the hypr-live-update-guard pacman PreTransaction hook script.
+
+The guard aborts a live pacman upgrade of GPU/compositor runtime libraries
+(mesa, hyprland, wayland, GPU drivers) while a Hyprland session is running,
+so the compositor doesn't SIGABRT when a now-"(deleted)" library is next
+called. It reads the triggering package names on stdin (pacman NeedsTargets)
+and exits non-zero to abort the transaction (AbortOnFail) before any package
+is swapped. When Hyprland isn't running, or an override is set, it exits 0
+and the upgrade proceeds.
+
+Test seams (env vars the production script honors):
+ HYPR_GUARD_RUNNING 1/0 forces the Hyprland-running check (default: pgrep)
+ HYPR_ALLOW_LIVE_UPDATE 1 overrides the guard (proceed anyway)
+ HYPR_GUARD_SENTINEL path whose existence also overrides the guard
+ HYPR_GUARD_VERSIONS "pkg installed candidate" lines replacing the
+ pacman -Q / expac -S version lookups; when set,
+ a package absent from the map reads as unknown
+ (conservative block)
+
+Run from repo root:
+ python3 -m unittest tests.hypr-live-update-guard.test_hypr_live_update_guard
+"""
+
+import os
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+GUARD = os.path.join(REPO_ROOT, "scripts", "hypr-live-update-guard")
+
+# Every package the legacy tests feed, mapped to a version CHANGE — those
+# tests describe real upgrades, and the map keeps them hermetic (no
+# pacman/expac calls against the test host).
+CHANGING_VERSIONS = "\n".join((
+ "mesa 25.1.0-1 26.0.0-1",
+ "hyprland 0.55.3-1 0.55.4-1",
+ "vulkan-radeon 25.1.0-1 26.0.0-1",
+))
+
+
+def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None,
+ versions=CHANGING_VERSIONS):
+ env = dict(os.environ)
+ env["HYPR_GUARD_RUNNING"] = running
+ if allow is not None:
+ env["HYPR_ALLOW_LIVE_UPDATE"] = allow
+ # Point the sentinel at a path that does not exist unless a test sets one,
+ # so the host's real /run state can't leak into the result.
+ env["HYPR_GUARD_SENTINEL"] = sentinel if sentinel else "/nonexistent/guard-sentinel"
+ env["HYPR_GUARD_VERSIONS"] = versions
+ return subprocess.run(
+ ["sh", GUARD],
+ input=stdin, capture_output=True, text=True, timeout=10, env=env,
+ )
+
+
+class HyprLiveUpdateGuard(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_running_with_dangerous_pkg_aborts(self):
+ r = run_guard(stdin="mesa\n", running="1")
+ self.assertEqual(r.returncode, 1, r.stderr)
+
+ def test_abort_message_names_the_package_and_tty_remedy(self):
+ r = run_guard(stdin="mesa\n", running="1")
+ self.assertIn("mesa", r.stderr)
+ self.assertIn("TTY", r.stderr)
+
+ def test_not_running_allows(self):
+ r = run_guard(stdin="mesa\n", running="0")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_not_running_is_silent(self):
+ r = run_guard(stdin="mesa\nhyprland\n", running="0")
+ self.assertEqual(r.stderr.strip(), "")
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_multiple_packages_all_listed(self):
+ r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1")
+ self.assertEqual(r.returncode, 1)
+ for pkg in ("mesa", "hyprland", "vulkan-radeon"):
+ self.assertIn(pkg, r.stderr)
+
+ def test_running_with_empty_stdin_still_guards(self):
+ # The hook only fires when dangerous targets exist, so an empty target
+ # list shouldn't normally happen; if Hyprland is up, stay safe (abort).
+ r = run_guard(stdin="", running="1")
+ self.assertEqual(r.returncode, 1)
+
+ # --- Version awareness ------------------------------------------------
+
+ def test_same_version_reinstall_allows(self):
+ # a pure reinstall replaces identical bytes with identical bytes —
+ # no live-swap hazard, the guard must let it through
+ r = run_guard(stdin="hyprland\n", running="1",
+ versions="hyprland 0.55.4-1 0.55.4-1")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertEqual(r.stderr.strip(), "")
+
+ def test_all_same_version_multi_pkg_allows(self):
+ versions = "\n".join(("mesa 26.1.4-1 26.1.4-1",
+ "hyprland 0.55.4-1 0.55.4-1",
+ "vulkan-radeon 26.1.4-1 26.1.4-1"))
+ r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1",
+ versions=versions)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_mixed_blocks_naming_only_the_version_changing(self):
+ versions = "\n".join(("mesa 25.1.0-1 26.0.0-1",
+ "hyprland 0.55.4-1 0.55.4-1"))
+ r = run_guard(stdin="mesa\nhyprland\n", running="1",
+ versions=versions)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("- mesa", r.stderr)
+ # the prose mentions the Hyprland session, so assert on the
+ # package-list line format, not the bare word
+ self.assertNotIn("- hyprland", r.stderr)
+
+ def test_unknown_versions_block_conservatively(self):
+ # seam set but package absent from the map = the lookup failed
+ # (AUR target, -U transaction, expac missing) — stay safe
+ r = run_guard(stdin="mesa\n", running="1", versions="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("- mesa", r.stderr)
+
+ # --- Override / error cases -----------------------------------------
+
+ def test_env_override_proceeds_even_when_running(self):
+ r = run_guard(stdin="mesa\n", running="1", allow="1")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_sentinel_file_override_proceeds(self):
+ with tempfile.NamedTemporaryFile(prefix="guard-allow-") as f:
+ r = run_guard(stdin="mesa\n", running="1", sentinel=f.name)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_sentinel_is_consumed_on_use(self):
+ # one touch = one transaction: if the override's cleanup never runs
+ # (a crashed caller), a leftover sentinel must not keep the guard
+ # disarmed until reboot — the hook deletes it as it honors it
+ fd, path = tempfile.mkstemp(prefix="guard-allow-")
+ os.close(fd)
+ try:
+ r = run_guard(stdin="mesa\n", running="1", sentinel=path)
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertFalse(os.path.exists(path))
+ finally:
+ if os.path.exists(path):
+ os.unlink(path)
+
+ def test_env_override_consumes_nothing(self):
+ # the env override isn't a file; nothing to consume, still proceeds
+ r = run_guard(stdin="mesa\n", running="1", allow="1")
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_override_env_zero_does_not_bypass(self):
+ r = run_guard(stdin="mesa\n", running="1", allow="0")
+ self.assertEqual(r.returncode, 1, r.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/import-wireguard-configs/fake-nmcli b/tests/import-wireguard-configs/fake-nmcli
new file mode 100644
index 0000000..45b88cd
--- /dev/null
+++ b/tests/import-wireguard-configs/fake-nmcli
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Fake nmcli for the import-wireguard-configs tests.
+#
+# Behavior is driven by env vars set by the test harness:
+# FAKE_NMCLI_LOG file every invocation's args are appended to (one line
+# per call; for imports the staged file's basename and
+# content hash context are visible in the args)
+# FAKE_NMCLI_NAMES newline-separated connection names returned by
+# `nmcli -t -f NAME connection show`
+# FAKE_NMCLI_IMPORT_OUT override for the import command's stdout
+# (default: the real NM success line with a per-call
+# deterministic UUID)
+# FAKE_NMCLI_MODIFY_RC exit code for `nmcli connection modify` (default 0)
+#
+# Import calls also copy the staged file into $FAKE_NMCLI_LOG.d/ so tests can
+# assert the temp copy was named wgpvpn.conf and carried the right content.
+set -euo pipefail
+
+echo "$*" >>"$FAKE_NMCLI_LOG"
+
+case "$1 $2" in
+"-t -f")
+ # nmcli -t -f NAME connection show
+ printf '%s\n' "${FAKE_NMCLI_NAMES:-}"
+ ;;
+"connection import")
+ # nmcli connection import type wireguard file <path>
+ file="${6:?}"
+ mkdir -p "$FAKE_NMCLI_LOG.d"
+ n=$(find "$FAKE_NMCLI_LOG.d" -type f | wc -l)
+ cp "$file" "$FAKE_NMCLI_LOG.d/import-$n-$(basename "$file")"
+ if [ -n "${FAKE_NMCLI_IMPORT_OUT:-}" ]; then
+ echo "$FAKE_NMCLI_IMPORT_OUT"
+ else
+ printf "Connection 'wgpvpn' (%08d-aaaa-bbbb-cccc-dddddddddddd) successfully added.\n" "$n"
+ fi
+ ;;
+"connection modify")
+ exit "${FAKE_NMCLI_MODIFY_RC:-0}"
+ ;;
+*)
+ echo "fake-nmcli: unexpected args: $*" >&2
+ exit 99
+ ;;
+esac
diff --git a/tests/import-wireguard-configs/test_import_wireguard_configs.py b/tests/import-wireguard-configs/test_import_wireguard_configs.py
new file mode 100644
index 0000000..0307041
--- /dev/null
+++ b/tests/import-wireguard-configs/test_import_wireguard_configs.py
@@ -0,0 +1,167 @@
+"""Tests for the import-wireguard-configs.sh one-time migration script.
+
+The script imports every assets/wireguard-config/*.conf into NetworkManager
+as a wireguard connection with autoconnect forced off. NM quirks under test:
+the import filename must be a valid interface name (<= 15 chars), so every
+config stages through a temp copy named wgpvpn.conf and is renamed to the
+real config name immediately after import — by the UUID parsed from the
+import output, never by the transient wgpvpn name. A leftover connection
+literally named wgpvpn (an earlier run died between import and rename, so
+it still has autoconnect on) makes the script refuse to run.
+
+nmcli is faked via a stub on PATH (fake-nmcli in this directory) that logs
+every invocation and snapshots the staged import file.
+
+Run from repo root:
+ python3 -m unittest tests.import-wireguard-configs.test_import_wireguard_configs
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "scripts", "import-wireguard-configs.sh")
+FAKE_NMCLI = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fake-nmcli")
+
+
+class ImportWireguardConfigs(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="import-wg-test-")
+ self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
+ self.confdir = os.path.join(self.tmp, "configs")
+ os.mkdir(self.confdir)
+ self.bindir = os.path.join(self.tmp, "bin")
+ os.mkdir(self.bindir)
+ shutil.copy(FAKE_NMCLI, os.path.join(self.bindir, "nmcli"))
+ os.chmod(os.path.join(self.bindir, "nmcli"), 0o755)
+ self.log = os.path.join(self.tmp, "nmcli.log")
+
+ def write_conf(self, name, body="[Interface]\nPrivateKey = k\n"):
+ path = os.path.join(self.confdir, name + ".conf")
+ with open(path, "w") as f:
+ f.write(body)
+ return path
+
+ def run_script(self, confdir=None, names="", env_extra=None):
+ env = dict(os.environ)
+ env["PATH"] = self.bindir + os.pathsep + env["PATH"]
+ env["FAKE_NMCLI_LOG"] = self.log
+ env["FAKE_NMCLI_NAMES"] = names
+ if env_extra:
+ env.update(env_extra)
+ return subprocess.run(
+ ["bash", SCRIPT, confdir or self.confdir],
+ capture_output=True, text=True, timeout=10, env=env,
+ )
+
+ def log_lines(self):
+ if not os.path.exists(self.log):
+ return []
+ with open(self.log) as f:
+ return [ln.strip() for ln in f if ln.strip()]
+
+ # --- Normal cases ----------------------------------------------------
+
+ def test_imports_every_conf_with_autoconnect_off(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(len(modifies), 2)
+ for ln in modifies:
+ self.assertIn("connection.autoconnect no", ln)
+ self.assertIn("imported: USDC", r.stdout)
+ self.assertIn("imported: USNY", r.stdout)
+
+ def test_renames_by_uuid_from_import_output_not_by_name(self):
+ self.write_conf("USNY")
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ modify = [ln for ln in self.log_lines() if ln.startswith("connection modify")][0]
+ # The modify targets the UUID the import printed, and never the
+ # transient wgpvpn name.
+ self.assertIn("00000000-aaaa-bbbb-cccc-dddddddddddd", modify)
+ self.assertIn("connection.id USNY", modify)
+ self.assertNotIn("modify wgpvpn", modify)
+
+ def test_long_name_stages_through_wgpvpn_temp_copy(self):
+ # switzerlan-zurich1 is 18 chars — over NM's 15-char interface-name
+ # limit, the reason the staging copy exists at all.
+ body = "[Interface]\nPrivateKey = long-name-key\n"
+ self.write_conf("switzerlan-zurich1", body)
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ staged = os.listdir(self.log + ".d")
+ self.assertEqual(len(staged), 1)
+ self.assertTrue(staged[0].endswith("wgpvpn.conf"), staged)
+ with open(os.path.join(self.log + ".d", staged[0])) as f:
+ self.assertEqual(f.read(), body)
+ self.assertIn("imported: switzerlan-zurich1", r.stdout)
+
+ # --- Idempotence -----------------------------------------------------
+
+ def test_already_imported_names_skip(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script(names="USNY\nsome-wifi")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn("skip: USNY", r.stdout)
+ self.assertIn("imported: USDC", r.stdout)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(len(modifies), 1)
+
+ def test_all_imported_is_a_clean_noop(self):
+ self.write_conf("USNY")
+ r = self.run_script(names="USNY")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(imports, [])
+
+ # --- Boundary cases --------------------------------------------------
+
+ def test_empty_config_dir_fails_loudly(self):
+ r = self.run_script()
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no .conf files", r.stderr)
+
+ def test_missing_config_dir_fails_loudly(self):
+ r = self.run_script(confdir=os.path.join(self.tmp, "nope"))
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no such config dir", r.stderr)
+
+ # --- Error cases -----------------------------------------------------
+
+ def test_stale_wgpvpn_connection_refuses_to_run(self):
+ self.write_conf("USNY")
+ r = self.run_script(names="wgpvpn\nUSDC")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("stale", r.stderr)
+ self.assertIn("nmcli connection delete wgpvpn", r.stderr)
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(imports, [])
+
+ def test_unparseable_import_output_aborts(self):
+ self.write_conf("USNY")
+ r = self.run_script(env_extra={"FAKE_NMCLI_IMPORT_OUT": "something unexpected"})
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("could not parse a UUID", r.stderr)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(modifies, [])
+
+ def test_modify_failure_aborts_the_run(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script(env_extra={"FAKE_NMCLI_MODIFY_RC": "4"})
+ self.assertNotEqual(r.returncode, 0)
+ # set -e stops at the first failed modify — only one import attempted.
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(len(imports), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py
index e62c198..2a771ba 100644
--- a/tests/installer-steps/test_orchestrators.py
+++ b/tests/installer-steps/test_orchestrators.py
@@ -46,11 +46,13 @@ ORCHESTRATORS = {
"tighten_efi_permissions", "add_nvme_early_module",
"configure_initramfs_hook", "configure_encrypted_autologin",
"configure_tlp_power", "trim_firmware", "configure_grub",
+ "configure_pre_pacman_snapshots",
],
"user_customizations": [
"clone_user_repos", "stow_dotfiles", "prune_waybar_battery",
"refresh_desktop_caches", "configure_dconf_defaults",
- "finalize_dotfiles", "create_user_directories",
+ "finalize_dotfiles", "install_maintenance_config",
+ "create_user_directories",
],
}
@@ -113,5 +115,35 @@ class SnapshotDispatch(unittest.TestCase):
self.assertEqual(result.stdout.split(), [])
+class MaintenanceConfigDispatch(unittest.TestCase):
+ """install_maintenance_config branches on desktop_env; pin each branch.
+
+ The thresholds TOML installs for every environment (the CLI works
+ headless); the scan timers are user units in the hyprland stow tier, so
+ their enablement is hyprland-only.
+ """
+
+ SUBS = ["install_maintenance_thresholds", "enable_maint_timers"]
+
+ def test_hyprland_installs_thresholds_and_enables_timers(self):
+ result = run_orchestrator(
+ "install_maintenance_config", self.SUBS,
+ extra_defs='desktop_env=hyprland',
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(), self.SUBS)
+
+ def test_non_hyprland_installs_thresholds_only(self):
+ for env in ("dwm", "none"):
+ with self.subTest(desktop_env=env):
+ result = run_orchestrator(
+ "install_maintenance_config", self.SUBS,
+ extra_defs=f'desktop_env={env}',
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(),
+ ["install_maintenance_thresholds"])
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/installer-steps/test_pacman_install.py b/tests/installer-steps/test_pacman_install.py
new file mode 100644
index 0000000..28c9a7f
--- /dev/null
+++ b/tests/installer-steps/test_pacman_install.py
@@ -0,0 +1,95 @@
+"""Characterization tests for pacman_install's install-reason handling.
+
+pacman --needed skips a package that is already present as a dependency and
+leaves its install reason alone. A declared package can then sit as asdeps
+on an existing system, show up as an orphan once its accidental dependent
+leaves, and get swept away by an orphan cleanup (expac and lm_sensors nearly
+went this way on 2026-07-08). pacman_install therefore marks every declared
+package explicit after a successful install.
+
+Method mirrors test_orchestrators: sed-extract the real functions from
+`archsetup`, source them with `display`/`error_warn` silenced and `pacman`
+replaced by a recorder, run, and assert the recorded calls.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_pacman_install
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run_pacman_install(pkg, pacman_s_rc=0, pacman_d_rc=0):
+ """Extract retry_install + pacman_install, run against a fake pacman.
+
+ Returns (exit_code, recorded pacman calls as a list of strings).
+ """
+ script = textwrap.dedent("""
+ set -u
+ MAX_INSTALL_RETRIES=3
+ logfile=/dev/null
+ display() { :; }
+ error_warn() { return 1; }
+ pacman() {
+ echo "pacman $*" >> "$CALLS"
+ case "$1" in
+ --noconfirm) return "$PACMAN_S_RC" ;;
+ -D) return "$PACMAN_D_RC" ;;
+ esac
+ }
+ %(functions)s
+ pacman_install "%(pkg)s"
+ """)
+ extract = subprocess.run(
+ ["sed", "-n",
+ "/^retry_install()/,/^}/p;/^pacman_install()/,/^}/p", ARCHSETUP],
+ capture_output=True, text=True, check=True)
+ calls_file = os.path.join(os.environ.get("TMPDIR", "/tmp"),
+ f"pacman-install-calls-{os.getpid()}")
+ if os.path.exists(calls_file):
+ os.unlink(calls_file)
+ open(calls_file, "w").close()
+ env = dict(os.environ, CALLS=calls_file,
+ PACMAN_S_RC=str(pacman_s_rc), PACMAN_D_RC=str(pacman_d_rc))
+ proc = subprocess.run(
+ ["bash", "-c", script % {"functions": extract.stdout, "pkg": pkg}],
+ capture_output=True, text=True, env=env)
+ with open(calls_file) as f:
+ calls = [line.strip() for line in f if line.strip()]
+ os.unlink(calls_file)
+ return proc.returncode, calls
+
+
+class PacmanInstallTests(unittest.TestCase):
+ def test_success_marks_package_explicit(self):
+ rc, calls = run_pacman_install("expac")
+ self.assertEqual(rc, 0)
+ self.assertIn("pacman --noconfirm --needed -S expac", calls)
+ self.assertIn("pacman -D --asexplicit expac", calls)
+ # the mark comes after the install, never before
+ self.assertGreater(calls.index("pacman -D --asexplicit expac"),
+ calls.index("pacman --noconfirm --needed -S expac"))
+
+ def test_failed_install_never_marks(self):
+ rc, calls = run_pacman_install("expac", pacman_s_rc=1)
+ self.assertNotEqual(rc, 0)
+ self.assertNotIn("pacman -D --asexplicit expac", calls)
+ # all three retry attempts happened
+ self.assertEqual(
+ calls.count("pacman --noconfirm --needed -S expac"), 3)
+
+ def test_mark_failure_does_not_fail_the_install(self):
+ # -D can fail in odd corners (readonly db mid-transaction); the
+ # install itself succeeded and must report success
+ rc, calls = run_pacman_install("expac", pacman_d_rc=1)
+ self.assertEqual(rc, 0)
+ self.assertIn("pacman -D --asexplicit expac", calls)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/maint-scenarios/test_scenario_plan.py b/tests/maint-scenarios/test_scenario_plan.py
new file mode 100644
index 0000000..9a72db2
--- /dev/null
+++ b/tests/maint-scenarios/test_scenario_plan.py
@@ -0,0 +1,273 @@
+"""Tests for the maint VM scenario runner's plan layer (no VM needed).
+
+run-maint-scenarios.sh orchestrates break -> `maint fix` -> assert scenario
+scripts over the existing qemu-img snapshot primitives (lib/vm-utils.sh).
+Scenarios are grouped into non-conflicting batches that share one VM boot;
+a stop -> restore -> boot cycle runs only between groups (the spec's
+grouped-batch isolation policy). The runner therefore has a pure planning
+layer -- enumerate scenario files, validate their contract, filter by
+filesystem profile and --group, and print the batch plan -- that runs
+without KVM, a base image, or root.
+
+These tests exercise that layer through the REAL script via `--list`:
+ - against the shipped scenarios directory (contract holds for every file
+ we actually ship);
+ - against fake scenario directories (MAINT_SCENARIO_DIR override) for the
+ validation failures a shipped tree must never contain.
+
+The scenario-file contract validated here:
+ - vars SCENARIO_DESC (non-empty), SCENARIO_GROUP (token),
+ SCENARIO_PROFILES (btrfs/zfs/any, space-separated);
+ - functions scenario_break, scenario_fix, scenario_assert;
+ - defining only -- sourcing a scenario file must not execute commands
+ (the probe sources files in a bare shell with no helpers defined).
+
+Run from repo root:
+ python3 -m unittest tests.maint-scenarios.test_scenario_plan
+"""
+
+import os
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+RUNNER = os.path.join(REPO_ROOT, "scripts", "testing", "run-maint-scenarios.sh")
+SCENARIO_DIR = os.path.join(REPO_ROOT, "scripts", "testing", "maint-scenarios")
+
+GOOD_SCENARIO = """\
+SCENARIO_DESC="{desc}"
+SCENARIO_GROUP="{group}"
+SCENARIO_PROFILES="{profiles}"
+scenario_break() {{ mexec "true"; }}
+scenario_fix() {{ mfix some_remedy; }}
+scenario_assert() {{ mexec "true"; }}
+"""
+
+
+def run_list(extra_args=(), scenario_dir=None, fs_profile=None):
+ # Hermetic against the caller's FS_PROFILE: the Makefile exports it, so
+ # `make test-unit FS_PROFILE=zfs` would otherwise change what --list
+ # shows. Tests that care pass fs_profile explicitly; everything else
+ # runs the runner's own default (btrfs).
+ env = {k: v for k, v in os.environ.items() if k != "FS_PROFILE"}
+ if scenario_dir is not None:
+ env["MAINT_SCENARIO_DIR"] = scenario_dir
+ if fs_profile is not None:
+ env["FS_PROFILE"] = fs_profile
+ return subprocess.run(
+ ["bash", RUNNER, "--list", *extra_args],
+ capture_output=True, text=True, env=env, cwd=REPO_ROOT,
+ )
+
+
+def write_scenario(dirpath, name, desc="a scenario", group="g1",
+ profiles="any", body=None):
+ path = os.path.join(dirpath, name)
+ with open(path, "w") as f:
+ f.write(body if body is not None
+ else GOOD_SCENARIO.format(desc=desc, group=group,
+ profiles=profiles))
+ return path
+
+
+class ShippedScenariosTests(unittest.TestCase):
+ """The scenarios we actually ship satisfy the contract."""
+
+ def test_shipped_dir_exists_and_is_nonempty(self):
+ files = [f for f in os.listdir(SCENARIO_DIR) if f.endswith(".sh")]
+ self.assertTrue(files, "no scenario files shipped")
+
+ def test_list_exits_zero_on_shipped_scenarios(self):
+ proc = run_list()
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+
+ def test_list_names_every_shipped_scenario(self):
+ proc = run_list()
+ for f in os.listdir(SCENARIO_DIR):
+ if not f.endswith(".sh"):
+ continue
+ name = f.split("-", 1)[1][:-3] if "-" in f else f[:-3]
+ self.assertIn(name, proc.stdout,
+ f"scenario {f} missing from --list output")
+
+ def test_list_groups_are_headed(self):
+ proc = run_list()
+ self.assertRegex(proc.stdout, r"(?m)^group \S+:")
+
+ def test_shipped_scenarios_define_contract_without_executing(self):
+ """Sourcing a scenario file in a bare bash defines the contract vars
+ and functions and runs nothing (no helpers exist at source time, so
+ any top-level command would fail loudly)."""
+ probe = (
+ 'set -eu; source "$1"; '
+ ': "${SCENARIO_DESC:?}" "${SCENARIO_GROUP:?}" '
+ '"${SCENARIO_PROFILES:?}"; '
+ 'case " $SCENARIO_PROFILES " in *" btrfs "*|*" zfs "*|*" any "*) '
+ ';; *) echo "bad profiles: $SCENARIO_PROFILES" >&2; exit 1;; esac; '
+ 'declare -f scenario_break scenario_fix scenario_assert >/dev/null'
+ )
+ for f in sorted(os.listdir(SCENARIO_DIR)):
+ if not f.endswith(".sh"):
+ continue
+ path = os.path.join(SCENARIO_DIR, f)
+ proc = subprocess.run(["bash", "-c", probe, "probe", path],
+ capture_output=True, text=True)
+ self.assertEqual(proc.returncode, 0,
+ f"{f}: contract violation\n{proc.stderr}")
+
+
+class PlanFilteringTests(unittest.TestCase):
+ """Profile and --group filtering over a fake scenario dir."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dir = self.tmp.name
+ write_scenario(self.dir, "10-first-btrfs.sh",
+ group="alpha", profiles="btrfs")
+ write_scenario(self.dir, "20-second-any.sh",
+ group="beta", profiles="any")
+ write_scenario(self.dir, "30-third-zfs.sh",
+ group="gamma", profiles="zfs")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def test_btrfs_profile_excludes_zfs_scenarios(self):
+ proc = run_list(scenario_dir=self.dir, fs_profile="btrfs")
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertIn("first-btrfs", proc.stdout)
+ self.assertIn("second-any", proc.stdout)
+ self.assertNotIn("third-zfs", proc.stdout)
+
+ def test_zfs_profile_excludes_btrfs_scenarios(self):
+ proc = run_list(scenario_dir=self.dir, fs_profile="zfs")
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertNotIn("first-btrfs", proc.stdout)
+ self.assertIn("second-any", proc.stdout)
+ self.assertIn("third-zfs", proc.stdout)
+
+ def test_group_filter_selects_one_group(self):
+ proc = run_list(["--group", "alpha"],
+ scenario_dir=self.dir, fs_profile="btrfs")
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ self.assertIn("first-btrfs", proc.stdout)
+ self.assertNotIn("second-any", proc.stdout)
+
+ def test_unknown_group_is_an_error(self):
+ proc = run_list(["--group", "nonesuch"],
+ scenario_dir=self.dir, fs_profile="btrfs")
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("nonesuch", proc.stdout + proc.stderr)
+
+ def test_groups_appear_in_file_order(self):
+ proc = run_list(scenario_dir=self.dir, fs_profile="zfs")
+ out = proc.stdout
+ self.assertLess(out.index("group beta:"), out.index("group gamma:"))
+
+
+class ContractValidationTests(unittest.TestCase):
+ """Malformed scenario files fail the plan, naming the file."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dir = self.tmp.name
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def assert_plan_fails_naming(self, filename):
+ proc = run_list(scenario_dir=self.dir)
+ self.assertNotEqual(proc.returncode, 0,
+ f"plan accepted malformed {filename}")
+ self.assertIn(filename, proc.stdout + proc.stderr)
+
+ def test_missing_desc_rejected(self):
+ write_scenario(self.dir, "10-no-desc.sh", body=(
+ 'SCENARIO_GROUP="g"\nSCENARIO_PROFILES="any"\n'
+ "scenario_break() { :; }\nscenario_fix() { :; }\n"
+ "scenario_assert() { :; }\n"))
+ self.assert_plan_fails_naming("10-no-desc.sh")
+
+ def test_missing_function_rejected(self):
+ write_scenario(self.dir, "10-no-assert.sh", body=(
+ 'SCENARIO_DESC="d"\nSCENARIO_GROUP="g"\nSCENARIO_PROFILES="any"\n'
+ "scenario_break() { :; }\nscenario_fix() { :; }\n"))
+ self.assert_plan_fails_naming("10-no-assert.sh")
+
+ def test_bad_profile_token_rejected(self):
+ write_scenario(self.dir, "10-bad-profile.sh", profiles="ext4")
+ self.assert_plan_fails_naming("10-bad-profile.sh")
+
+ def test_empty_scenario_dir_is_an_error(self):
+ proc = run_list(scenario_dir=self.dir)
+ self.assertNotEqual(proc.returncode, 0)
+
+
+class UsageTests(unittest.TestCase):
+ def test_unknown_flag_is_an_error_with_usage(self):
+ proc = run_list(["--bogus"], scenario_dir=SCENARIO_DIR)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("Usage", proc.stdout + proc.stderr)
+
+
+NSPAWN_RUNNER = os.path.join(REPO_ROOT, "scripts", "testing",
+ "run-maint-nspawn.sh")
+
+
+class NspawnPlanTests(unittest.TestCase):
+ """The nspawn fast lane selects exactly the pacman-level (packages)
+ group from the shared scenario dir."""
+
+ def run_nspawn_list(self, scenario_dir=None):
+ env = dict(os.environ)
+ if scenario_dir is not None:
+ env["MAINT_SCENARIO_DIR"] = scenario_dir
+ return subprocess.run(
+ ["bash", NSPAWN_RUNNER, "--list"],
+ capture_output=True, text=True, env=env, cwd=REPO_ROOT,
+ )
+
+ def test_list_exits_zero(self):
+ proc = self.run_nspawn_list()
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+
+ def test_list_selects_only_the_packages_group(self):
+ proc = self.run_nspawn_list()
+ listed = set()
+ for f in os.listdir(SCENARIO_DIR):
+ if not f.endswith(".sh"):
+ continue
+ name = f.split("-", 1)[1][:-3] if "-" in f else f[:-3]
+ group = subprocess.run(
+ ["bash", "-c", f'source "{os.path.join(SCENARIO_DIR, f)}"; '
+ 'printf %s "$SCENARIO_GROUP"'],
+ capture_output=True, text=True).stdout
+ if group == "packages":
+ self.assertIn(name, proc.stdout,
+ f"packages scenario {f} missing")
+ listed.add(name)
+ else:
+ self.assertNotIn(name, proc.stdout,
+ f"non-packages scenario {f} listed")
+ self.assertTrue(listed, "no packages-group scenarios found")
+
+ def test_unknown_flag_is_an_error_with_usage(self):
+ proc = subprocess.run(["bash", NSPAWN_RUNNER, "--bogus"],
+ capture_output=True, text=True, cwd=REPO_ROOT)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("Usage", proc.stdout + proc.stderr)
+
+ def test_bad_profile_token_rejected_like_the_vm_lane(self):
+ """Both runners enforce the same scenario contract — a profile typo
+ must not pass the nspawn plan and only surface in the VM lane."""
+ with tempfile.TemporaryDirectory() as d:
+ write_scenario(d, "10-bad-profile.sh", group="packages",
+ profiles="ext4")
+ proc = self.run_nspawn_list(scenario_dir=d)
+ self.assertNotEqual(proc.returncode, 0)
+ self.assertIn("10-bad-profile.sh", proc.stdout + proc.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/network-diagnostics/test_network_diagnostics.py b/tests/network-diagnostics/test_network_diagnostics.py
new file mode 100644
index 0000000..1a8073f
--- /dev/null
+++ b/tests/network-diagnostics/test_network_diagnostics.py
@@ -0,0 +1,215 @@
+"""Tests for run_network_diagnostics in the VM testing harness.
+
+run_network_diagnostics is the VM install pre-flight network check. It
+collects read-only facts (interfaces, default route, resolver) first and
+unconditionally, then runs every reachability check -- DNS, HTTP egress,
+TLS egress, Arch mirror, AUR -- accumulating failures and reporting them all
+at the end. Facts are printed regardless of pass/fail, so a failed install
+still leaves the evidence. Generic checks (DNS/egress/TLS) are kept separate
+from Arch-specific checks (mirror/AUR) so a DNS failure is named as DNS, not
+misattributed to the mirror. Returns 0 when all checks pass, non-zero
+otherwise, preserving the caller's success/failure contract.
+
+These tests exercise the REAL function body (sourced out of
+network-diagnostics.sh, not a copy) with:
+ - stub logging functions (section/step/info/success/error/warn) that just
+ echo, so output is assertable;
+ - a fake `sshpass` on PATH that dispatches on the remote command string and
+ returns canned exit codes driven by FAKE_*_FAIL env vars. This is the
+ system boundary -- the real function shells out through
+ `sshpass ... ssh ... "<remote cmd>"`, and the fake stands in for the VM.
+
+Run from repo root:
+ python3 -m unittest tests.network-diagnostics.test_network_diagnostics
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+NETDIAG = os.path.join(
+ REPO_ROOT, "scripts", "testing", "lib", "network-diagnostics.sh"
+)
+
+# A fake sshpass. The real invocation is:
+# sshpass -p <pw> ssh <opts> -p <port> root@<host> "<remote cmd>"
+# so the remote command is always the last argument. This stub inspects it and
+# returns a canned exit code per check, driven by FAKE_*_FAIL env vars. Fact
+# commands (ip/route/resolv) always succeed and print sample output so the
+# evidence-collection path is exercised.
+FAKE_SSHPASS = r"""#!/bin/bash
+cmd="${@: -1}"
+case "$cmd" in
+ *"ip -brief addr"*)
+ echo "lo UNKNOWN 127.0.0.1/8"
+ echo "eth0 UP 10.0.2.15/24"
+ exit 0 ;;
+ *"ip route show default"*)
+ echo "default via 10.0.2.2 dev eth0"
+ exit 0 ;;
+ *"resolv.conf"*)
+ echo "nameserver 10.0.2.3"
+ exit 0 ;;
+ *"getent hosts"*)
+ [ "${FAKE_DNS_FAIL:-0}" = "1" ] && exit 2
+ exit 0 ;;
+ *"https://archlinux.org"*)
+ [ "${FAKE_TLS_FAIL:-0}" = "1" ] && exit 7
+ exit 0 ;;
+ *"http://archlinux.org"*)
+ [ "${FAKE_HTTP_FAIL:-0}" = "1" ] && exit 7
+ exit 0 ;;
+ *"geo.mirror.pkgbuild.com"*)
+ [ "${FAKE_MIRROR_FAIL:-0}" = "1" ] && exit 1
+ exit 0 ;;
+ *"aur.archlinux.org"*)
+ [ "${FAKE_AUR_FAIL:-0}" = "1" ] && exit 1
+ exit 0 ;;
+ *)
+ exit 0 ;;
+esac
+"""
+
+# Stub logging functions plus the sourced real file, then call the function.
+WRAPPER = r"""#!/bin/bash
+section() { echo "=== $1 ==="; }
+step() { echo " -> $1"; }
+info() { echo "[i] $1"; }
+success() { echo "[OK] $1"; }
+warn() { echo "[!] $1" >&2; }
+error() { echo "[X] $1" >&2; }
+source "$1"
+run_network_diagnostics
+"""
+
+
+class NetworkDiagnosticsHarness(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="netdiag-test-")
+ self.fakebin = os.path.join(self.tmp, "bin")
+ os.makedirs(self.fakebin)
+ sshpass = os.path.join(self.fakebin, "sshpass")
+ with open(sshpass, "w") as f:
+ f.write(FAKE_SSHPASS)
+ os.chmod(sshpass, 0o755)
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(WRAPPER)
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_diag(self, results_dir=None, **fail_flags):
+ env = dict(os.environ)
+ env["PATH"] = self.fakebin + os.pathsep + env["PATH"]
+ # Keep the harness deterministic regardless of the host's SSH config.
+ env["SSH_OPTS"] = "-o StrictHostKeyChecking=no"
+ env["ROOT_PASSWORD"] = "archsetup"
+ env["SSH_PORT"] = "22"
+ env["VM_IP"] = "localhost"
+ if results_dir is not None:
+ env["TEST_RESULTS_DIR"] = results_dir
+ for k, v in fail_flags.items():
+ env[k] = v
+ return subprocess.run(
+ ["bash", self.wrapper, NETDIAG],
+ capture_output=True, text=True, timeout=20, env=env,
+ )
+
+ # --- Normal case: everything reachable -----------------------------
+
+ def test_all_checks_pass_returns_zero(self):
+ r = self.run_diag()
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ self.assertIn("all checks passed", r.stdout)
+
+ def test_facts_collected_on_success(self):
+ r = self.run_diag()
+ out = r.stdout + r.stderr
+ self.assertIn("10.0.2.15/24", out) # interface fact
+ self.assertIn("default via 10.0.2.2", out) # route fact
+ self.assertIn("nameserver 10.0.2.3", out) # resolver fact
+
+ # --- DNS-failure case ----------------------------------------------
+
+ def test_dns_failure_returns_nonzero(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ self.assertNotEqual(r.returncode, 0)
+
+ def test_dns_failure_names_dns_not_mirror(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("DNS resolution failed", out)
+ # A DNS failure must not be misreported as a mirror failure. With only
+ # DNS failing, the mirror check still runs and passes.
+ self.assertNotIn("Cannot reach Arch mirrors", out)
+
+ def test_dns_failure_still_collects_evidence(self):
+ # The whole point of the change: evidence is gathered before any check
+ # can bail, so a DNS failure still leaves the facts in the output.
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("10.0.2.15/24", out)
+ self.assertIn("default via 10.0.2.2", out)
+ self.assertIn("nameserver 10.0.2.3", out)
+
+ def test_dns_failure_summary_lists_the_failure(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("found 1 failure", out)
+ self.assertIn("getent hosts archlinux.org", out)
+
+ # --- Mirror-only-failure case --------------------------------------
+
+ def test_mirror_only_failure_returns_nonzero(self):
+ r = self.run_diag(FAKE_MIRROR_FAIL="1")
+ self.assertNotEqual(r.returncode, 0)
+
+ def test_mirror_only_failure_generic_checks_pass(self):
+ r = self.run_diag(FAKE_MIRROR_FAIL="1")
+ out = r.stdout + r.stderr
+ # Generic checks are healthy; only the Arch-specific mirror check fails.
+ self.assertIn("DNS resolution OK", out)
+ self.assertIn("HTTP egress OK", out)
+ self.assertIn("TLS/HTTPS egress OK", out)
+ self.assertIn("Cannot reach Arch mirrors", out)
+ self.assertNotIn("DNS resolution failed", out)
+
+ def test_mirror_only_failure_summary_names_mirror(self):
+ r = self.run_diag(FAKE_MIRROR_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("geo.mirror.pkgbuild.com", out)
+
+ # --- All checks run: multiple failures are all reported ------------
+
+ def test_multiple_failures_all_reported(self):
+ r = self.run_diag(FAKE_DNS_FAIL="1", FAKE_AUR_FAIL="1")
+ out = r.stdout + r.stderr
+ self.assertIn("found 2 failure", out)
+ self.assertIn("getent hosts archlinux.org", out)
+ self.assertIn("aur.archlinux.org", out)
+
+ # --- Raw outputs saved to the results dir --------------------------
+
+ def test_raw_facts_saved_to_results_dir(self):
+ results = os.path.join(self.tmp, "results")
+ os.makedirs(results)
+ self.run_diag(results_dir=results)
+ for slug, needle in (
+ ("ip-addr", "10.0.2.15/24"),
+ ("ip-route", "default via 10.0.2.2"),
+ ("resolv-conf", "nameserver 10.0.2.3"),
+ ):
+ path = os.path.join(results, "netdiag-%s.txt" % slug)
+ self.assertTrue(os.path.exists(path), "missing " + path)
+ with open(path) as f:
+ self.assertIn(needle, f.read())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/nvidia-preflight/test_nvidia_preflight.py b/tests/nvidia-preflight/test_nvidia_preflight.py
new file mode 100644
index 0000000..bdacfd5
--- /dev/null
+++ b/tests/nvidia-preflight/test_nvidia_preflight.py
@@ -0,0 +1,162 @@
+"""Tests for the nvidia_preflight_report helper in the archsetup installer.
+
+nvidia_preflight_report is the pure core of the NVIDIA/Wayland preflight
+check: it scans DRM (then PCI display-class) modalias files for the NVIDIA
+vendor id, and when one matches it prints the Wayland warning + required
+environment variables and checks the repo's candidate nvidia-utils major
+version. Return codes: 0 = no NVIDIA GPU, 10 = NVIDIA and the driver
+requirement (535+) is met, 11 = NVIDIA and the requirement is not met
+(driver too old or unknown). The interactive continue/abort prompt lives in
+preflight_checks, not here, so this core is unit testable.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), against temp modalias trees and a fake
+pacman on PATH.
+
+Run from repo root:
+ python3 -m unittest tests.nvidia-preflight.test_nvidia_preflight
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+NVIDIA_MODALIAS = "pci:v000010DEd00002684sv00001043sd000088E2bc03sc00i00"
+NVIDIA_MODALIAS_LOWER = "pci:v000010ded00002684sv00001043sd000088e2bc03sc00i00"
+AMD_MODALIAS = "pci:v00001002d0000164Esv00001462sd00007D78bc03sc80i00"
+NON_DISPLAY_NVIDIA = "pci:v000010DEd00002684sv00001043sd000088E2bc0Csc03i30"
+
+
+class NvidiaPreflightHarness(unittest.TestCase):
+ """Source nvidia_preflight_report out of the real archsetup script."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="nvidia-preflight-test-")
+ self.drm = os.path.join(self.tmp, "drm")
+ self.pci = os.path.join(self.tmp, "pci")
+ os.makedirs(self.drm)
+ os.makedirs(self.pci)
+ self.fakebin = os.path.join(self.tmp, "bin")
+ os.makedirs(self.fakebin)
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(
+ "#!/bin/bash\n"
+ 'ARCHSETUP="$1"; shift\n'
+ "source <(sed -n "
+ "'/^nvidia_preflight_report() {/,/^}/p' \"$ARCHSETUP\")\n"
+ "nvidia_preflight_report\n"
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def fake_pacman(self, version=None, fail=False):
+ """A pacman stub answering `pacman -Si nvidia-utils`."""
+ path = os.path.join(self.fakebin, "pacman")
+ with open(path, "w") as f:
+ if fail:
+ f.write("#!/bin/sh\nexit 1\n")
+ else:
+ f.write(
+ "#!/bin/sh\n"
+ "printf 'Repository : extra\\n'\n"
+ "printf 'Name : nvidia-utils\\n'\n"
+ "printf 'Version : %s\\n'\n" % version
+ )
+ os.chmod(path, 0o755)
+
+ def add_modalias(self, root, subdir, content):
+ d = os.path.join(root, subdir)
+ os.makedirs(d, exist_ok=True)
+ with open(os.path.join(d, "modalias"), "w") as f:
+ f.write(content + "\n")
+
+ def run_check(self):
+ env = dict(os.environ)
+ env["PATH"] = self.fakebin + os.pathsep + env["PATH"]
+ env["NVIDIA_DRM_GLOB"] = os.path.join(self.drm, "card*", "modalias")
+ env["NVIDIA_PCI_GLOB"] = os.path.join(self.pci, "*", "modalias")
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP],
+ capture_output=True, text=True, env=env,
+ )
+
+ # ---------------------------------------------------------- normal ----
+ def test_no_gpu_files_returns_zero_and_silent(self):
+ self.fake_pacman(version="575.51.02-1")
+ r = self.run_check()
+ self.assertEqual(r.returncode, 0)
+ self.assertNotIn("NVIDIA", r.stdout)
+
+ def test_amd_only_returns_zero(self):
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.drm, "card0", AMD_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 0)
+ self.assertNotIn("NVIDIA", r.stdout)
+
+ def test_nvidia_with_modern_driver_returns_ten_with_guidance(self):
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+ self.assertIn("NVIDIA GPU detected", r.stdout)
+ self.assertIn("LIBVA_DRIVER_NAME=nvidia", r.stdout)
+ self.assertIn("GBM_BACKEND=nvidia-drm", r.stdout)
+ self.assertIn("__GLX_VENDOR_LIBRARY_NAME=nvidia", r.stdout)
+ self.assertIn("575.51.02-1", r.stdout)
+
+ # -------------------------------------------------------- boundary ----
+ def test_lowercase_vendor_id_detected(self):
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS_LOWER)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+
+ def test_exactly_535_meets_requirement(self):
+ self.fake_pacman(version="535.216.01-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+
+ def test_pci_fallback_display_class_only(self):
+ # No DRM entries; PCI holds a display-class NVIDIA device -> detected.
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.pci, "0000:01:00.0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 10)
+
+ def test_pci_non_display_nvidia_ignored(self):
+ # An NVIDIA audio/usb function (bc0C) must not trigger the check.
+ self.fake_pacman(version="575.51.02-1")
+ self.add_modalias(self.pci, "0000:01:00.1", NON_DISPLAY_NVIDIA)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 0)
+
+ # ----------------------------------------------------------- error ----
+ def test_old_driver_returns_eleven_with_error(self):
+ self.fake_pacman(version="470.256.02-1")
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 11)
+ self.assertIn("535", r.stdout)
+ self.assertIn("470.256.02-1", r.stdout)
+
+ def test_pacman_failure_returns_eleven_unknown(self):
+ self.fake_pacman(fail=True)
+ self.add_modalias(self.drm, "card0", NVIDIA_MODALIAS)
+ r = self.run_check()
+ self.assertEqual(r.returncode, 11)
+ self.assertIn("unknown", r.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/zfs-pre-snapshot/fake-zfs b/tests/zfs-pre-snapshot/fake-zfs
new file mode 100755
index 0000000..508c0f3
--- /dev/null
+++ b/tests/zfs-pre-snapshot/fake-zfs
@@ -0,0 +1,14 @@
+#!/bin/sh
+# Fake zfs for the zfs-pre-snapshot unit test. `snapshot` and `destroy` are
+# logged (FAKE_ZFS_LOG); `list` prints a fixture snapshot set (FAKE_ZFS_SNAPSHOTS).
+# Set FAKE_ZFS_SNAPSHOT_FAIL to make snapshot creation fail.
+case "$1" in
+ snapshot)
+ [ -n "$FAKE_ZFS_SNAPSHOT_FAIL" ] && exit 1
+ echo "snapshot $2" >> "$FAKE_ZFS_LOG"; exit 0 ;;
+ destroy)
+ echo "destroy $2" >> "$FAKE_ZFS_LOG"; exit 0 ;;
+ list)
+ cat "$FAKE_ZFS_SNAPSHOTS" 2>/dev/null; exit 0 ;;
+esac
+exit 0
diff --git a/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py b/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py
new file mode 100644
index 0000000..ed7731b
--- /dev/null
+++ b/tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py
@@ -0,0 +1,116 @@
+"""Unit tests for scripts/zfs-pre-snapshot.
+
+The script snapshots the root dataset before a pacman transaction and prunes to
+the most recent KEEP pre-pacman snapshots. These tests drive the real script
+with a fake zfs on PATH (snapshot/destroy logged, list returns a fixture set)
+and env-rooted state, so nothing touches a real pool.
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import time
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "scripts/zfs-pre-snapshot")
+FAKE_ZFS = os.path.join(os.path.dirname(__file__), "fake-zfs")
+
+DATASET = "tank/test"
+# Five pre-pacman snapshots oldest->newest (zfs list -s creation is ascending),
+# plus one autosnap that the grep filter must ignore.
+SNAPSHOTS = "\n".join([
+ f"{DATASET}@autosnap_2026-01-01",
+ f"{DATASET}@pre-pacman_2026-06-01",
+ f"{DATASET}@pre-pacman_2026-06-02",
+ f"{DATASET}@pre-pacman_2026-06-03",
+ f"{DATASET}@pre-pacman_2026-06-04",
+ f"{DATASET}@pre-pacman_2026-06-05",
+]) + "\n"
+
+
+class Harness(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="zfs-pre-snap-")
+ self.bin = os.path.join(self.tmp, "bin")
+ os.makedirs(self.bin)
+ shutil.copy(FAKE_ZFS, os.path.join(self.bin, "zfs"))
+ self.log = os.path.join(self.tmp, "zfs.log")
+ self.snaps = os.path.join(self.tmp, "snaps")
+ with open(self.snaps, "w") as f:
+ f.write(SNAPSHOTS)
+ self.lock = os.path.join(self.tmp, "lock")
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_script(self, keep="3", fail=False, snaps=None):
+ env = os.environ.copy()
+ env["PATH"] = self.bin + os.pathsep + env["PATH"]
+ env["ZFS_PRE_DATASET"] = DATASET
+ env["ZFS_PRE_LOCKFILE"] = self.lock
+ env["ZFS_PRE_KEEP"] = keep
+ env["FAKE_ZFS_LOG"] = self.log
+ env["FAKE_ZFS_SNAPSHOTS"] = snaps if snaps is not None else self.snaps
+ if fail:
+ env["FAKE_ZFS_SNAPSHOT_FAIL"] = "1"
+ return subprocess.run([SCRIPT], env=env, capture_output=True, text=True,
+ timeout=15)
+
+ def log_lines(self):
+ try:
+ with open(self.log) as f:
+ return [ln for ln in f.read().splitlines() if ln.strip()]
+ except FileNotFoundError:
+ return []
+
+
+class TestSnapshot(Harness):
+ def test_creates_a_pre_pacman_snapshot(self):
+ self.run_script()
+ snaps = [ln for ln in self.log_lines() if ln.startswith("snapshot ")]
+ self.assertEqual(len(snaps), 1)
+ self.assertIn(f"snapshot {DATASET}@pre-pacman_", snaps[0])
+
+ def test_skips_when_lockfile_is_fresh(self):
+ # A lockfile newer than MIN_INTERVAL → no snapshot this run.
+ open(self.lock, "w").close()
+ os.utime(self.lock, (time.time(), time.time()))
+ self.run_script()
+ self.assertEqual([ln for ln in self.log_lines()
+ if ln.startswith("snapshot ")], [])
+
+
+class TestPrune(Harness):
+ def test_prunes_oldest_beyond_keep(self):
+ # 5 pre-pacman snapshots, KEEP=3 → the two oldest are destroyed.
+ self.run_script(keep="3")
+ destroyed = [ln.split(" ", 1)[1] for ln in self.log_lines()
+ if ln.startswith("destroy ")]
+ self.assertEqual(destroyed,
+ [f"{DATASET}@pre-pacman_2026-06-01",
+ f"{DATASET}@pre-pacman_2026-06-02"])
+
+ def test_never_destroys_non_pre_pacman_snapshots(self):
+ self.run_script(keep="1")
+ destroyed = [ln for ln in self.log_lines() if ln.startswith("destroy ")]
+ self.assertFalse(any("autosnap" in ln for ln in destroyed))
+
+ def test_no_prune_when_at_or_under_keep(self):
+ # KEEP=5 with exactly 5 pre-pacman snapshots → nothing destroyed.
+ self.run_script(keep="5")
+ self.assertEqual([ln for ln in self.log_lines()
+ if ln.startswith("destroy ")], [])
+
+
+class TestError(Harness):
+ def test_snapshot_failure_skips_prune_and_warns(self):
+ r = self.run_script(fail=True)
+ self.assertIn("Failed to create snapshot", r.stderr)
+ self.assertEqual([ln for ln in self.log_lines()
+ if ln.startswith("destroy ")], [])
+
+
+if __name__ == "__main__":
+ unittest.main()