aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/backup-system-file/test_backup_system_file.py161
-rw-r--r--tests/gallery-probes/README.md26
-rw-r--r--tests/gallery-probes/probe-fams.mjs64
-rw-r--r--tests/gallery-probes/probe.mjs120
-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.py149
-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
-rw-r--r--tests/run-task/test_run_task.py172
-rwxr-xr-xtests/zfs-pre-snapshot/fake-zfs14
-rw-r--r--tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py116
17 files changed, 2336 insertions, 0 deletions
diff --git a/tests/backup-system-file/test_backup_system_file.py b/tests/backup-system-file/test_backup_system_file.py
new file mode 100644
index 0000000..5d48d03
--- /dev/null
+++ b/tests/backup-system-file/test_backup_system_file.py
@@ -0,0 +1,161 @@
+"""Tests for the backup_system_file helper in the archsetup installer.
+
+backup_system_file snapshots a pre-existing system file to
+`<path>.archsetup.bak` before archsetup edits it in place, so a botched
+in-place edit (fstab, mkinitcpio.conf, sudoers, ...) is recoverable. It is
+idempotent: it never overwrites an existing backup, so the pristine original
+survives repeated edits within a run and across re-runs of the installer. It
+no-ops (success) when the target does not exist.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time (not a copy), so the production code path is what runs.
+Edits run against real temp files the test creates and tears down.
+
+Run from repo root:
+ python3 -m unittest tests.backup-system-file.test_backup_system_file
+"""
+
+import os
+import shutil
+import stat
+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")
+
+
+class BackupHarness(unittest.TestCase):
+ """Source backup_system_file out of the real archsetup script and invoke it."""
+
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="backup-system-file-test-")
+ # A bash wrapper that extracts just the backup_system_file function from
+ # the real installer and invokes it with the test's arg. Sourcing the
+ # sed-extracted function means we test the production code path, not a
+ # reimplementation. The helper is self-contained (prints its own
+ # warnings), so no logger stub is needed.
+ 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 '/^backup_system_file() {/,/^}/p' \"$ARCHSETUP\")\n"
+ 'backup_system_file "$@"\n'
+ )
+ os.chmod(self.wrapper, 0o755)
+
+ def tearDown(self):
+ # Restore writability in case a test made a dir read-only.
+ for root, dirs, _ in os.walk(self.tmp):
+ for d in dirs:
+ os.chmod(os.path.join(root, d), 0o755)
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def run_backup(self, target):
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP, target],
+ capture_output=True, text=True, timeout=10,
+ )
+
+ def write(self, name, content, mode=None):
+ path = os.path.join(self.tmp, name)
+ with open(path, "w") as f:
+ f.write(content)
+ if mode is not None:
+ os.chmod(path, mode)
+ return path
+
+
+# -----------------------------------------------------------------------------
+# Normal cases
+# -----------------------------------------------------------------------------
+
+class TestBackupNormal(BackupHarness):
+
+ def test_existing_file_is_backed_up_with_same_content(self):
+ target = self.write("fstab", "UUID=abc / ext4 defaults 0 1\n")
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ backup = target + ".archsetup.bak"
+ self.assertTrue(os.path.isfile(backup), "backup should be created")
+ with open(backup) as f:
+ self.assertEqual(f.read(), "UUID=abc / ext4 defaults 0 1\n")
+
+ def test_backup_preserves_mode(self):
+ # sudoers ships 0440; a restored backup must keep restrictive perms.
+ target = self.write("sudoers", "root ALL=(ALL) ALL\n", mode=0o440)
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ backup = target + ".archsetup.bak"
+ self.assertEqual(stat.S_IMODE(os.stat(backup).st_mode), 0o440)
+
+
+# -----------------------------------------------------------------------------
+# Boundary cases
+# -----------------------------------------------------------------------------
+
+class TestBackupBoundary(BackupHarness):
+
+ def test_existing_backup_is_not_overwritten(self):
+ # The pristine original must survive a later edit + second backup call.
+ target = self.write("pacman.conf", "PRISTINE\n")
+ self.assertEqual(self.run_backup(target).returncode, 0)
+ # Simulate archsetup editing the file in place, then backing up again.
+ with open(target, "w") as f:
+ f.write("EDITED\n")
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ with open(target + ".archsetup.bak") as f:
+ self.assertEqual(f.read(), "PRISTINE\n", "backup must stay pristine")
+
+ def test_missing_target_is_a_quiet_noop(self):
+ target = os.path.join(self.tmp, "never-existed.conf")
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertFalse(os.path.exists(target + ".archsetup.bak"))
+
+ def test_second_call_same_run_is_a_noop(self):
+ # A file edited twice in one run (e.g. mkinitcpio MODULES then HOOKS)
+ # gets backed up once; the second call must not error or re-copy.
+ target = self.write("mkinitcpio.conf", "HOOKS=(base udev)\n")
+ self.assertEqual(self.run_backup(target).returncode, 0)
+ backup = target + ".archsetup.bak"
+ first_mtime = os.stat(backup).st_mtime_ns
+ result = self.run_backup(target)
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+ self.assertEqual(os.stat(backup).st_mtime_ns, first_mtime,
+ "backup must not be rewritten on the second call")
+
+
+# -----------------------------------------------------------------------------
+# Error cases
+# -----------------------------------------------------------------------------
+
+class TestBackupErrors(BackupHarness):
+
+ def test_empty_target_is_refused(self):
+ result = self.run_backup("")
+ self.assertNotEqual(result.returncode, 0)
+
+ def test_copy_failure_returns_nonzero(self):
+ # Target exists but its directory is read-only, so the .bak can't be
+ # written. The helper must report failure rather than silently skip.
+ subdir = os.path.join(self.tmp, "ro")
+ os.makedirs(subdir)
+ target = os.path.join(subdir, "fstab")
+ with open(target, "w") as f:
+ f.write("data\n")
+ os.chmod(subdir, 0o500) # r-x: owner cannot create the .bak here
+ try:
+ result = self.run_backup(target)
+ finally:
+ os.chmod(subdir, 0o755)
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse(os.path.exists(target + ".archsetup.bak"))
+
+
+if __name__ == "__main__":
+ unittest.main()
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..99b4e92
--- /dev/null
+++ b/tests/gallery-probes/probe-fams.mjs
@@ -0,0 +1,64 @@
+// 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);
+
+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
+ok('5 screen chip rows', await evl(`[...document.querySelectorAll('.famchips .lab')].filter(l=>l.textContent==='screen').length`) === 5);
+
+// 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);
+
+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');
+chrome.kill();
+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..17b3d73
--- /dev/null
+++ b/tests/gallery-probes/probe.mjs
@@ -0,0 +1,120 @@
+// 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('109 cards', cards === 109, `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}'`);
+
+ // 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
new file mode 100644
index 0000000..2a771ba
--- /dev/null
+++ b/tests/installer-steps/test_orchestrators.py
@@ -0,0 +1,149 @@
+"""Characterization tests for the decomposed installer step orchestrators.
+
+The 2026 decomposition turned the giant step functions into thin
+orchestrators that call one named sub-function per concern. These tests pin
+the call SEQUENCE of each orchestrator: a dropped, added, or reordered
+sub-step call fails the test. They guard the wiring, not the sub-functions'
+own behavior (those mutate the system and are exercised by the VM harness).
+
+Method: sed-extract the orchestrator from the real `archsetup` (its body is
+now just `display` + sub-function calls), source it with `display` silenced
+and every sub-function replaced by a recorder that echoes its own name, run
+it, and assert stdout is the expected ordered list.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_orchestrators
+"""
+
+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")
+
+# orchestrator -> exact ordered sub-step calls
+ORCHESTRATORS = {
+ "essential_services": [
+ "configure_randomness", "configure_networking", "configure_power",
+ "configure_ssh_server", "configure_fail2ban", "configure_firewall",
+ "configure_service_discovery", "configure_job_scheduling",
+ "configure_package_cache", "configure_snapshots",
+ "configure_user_lingering",
+ ],
+ "prerequisites": [
+ "bootstrap_pacman_keyring", "install_required_software",
+ "configure_build_environment", "configure_package_mirrors",
+ ],
+ "developer_workstation": [
+ "install_programming_languages", "install_editors",
+ "install_android_utilities", "install_vpn_tools",
+ "install_devops_utilities",
+ ],
+ "boot_ux": [
+ "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", "install_maintenance_config",
+ "create_user_directories",
+ ],
+}
+
+
+def run_orchestrator(func, stubs, extra_defs=""):
+ """Source `func` from archsetup with `stubs` recording their names."""
+ stub_defs = "\n".join(f"{s}() {{ echo {s}; }}" for s in stubs)
+ script = textwrap.dedent(f"""\
+ display() {{ :; }}
+ {stub_defs}
+ {extra_defs}
+ source <(sed -n '/^{func}() {{/,/^}}/p' "{ARCHSETUP}")
+ {func}
+ """)
+ result = subprocess.run(
+ ["bash", "-c", script],
+ capture_output=True, text=True, timeout=10,
+ )
+ return result
+
+
+class OrchestratorSequence(unittest.TestCase):
+ def test_each_orchestrator_calls_substeps_in_order(self):
+ for func, expected in ORCHESTRATORS.items():
+ with self.subTest(orchestrator=func):
+ result = run_orchestrator(func, expected)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ got = result.stdout.split()
+ self.assertEqual(got, expected,
+ f"{func} call sequence drifted")
+
+
+class SnapshotDispatch(unittest.TestCase):
+ """configure_snapshots branches on filesystem; pin each branch."""
+
+ SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots"]
+
+ def test_zfs_root_runs_zfs_snapshots(self):
+ result = run_orchestrator(
+ "configure_snapshots", self.SUBS,
+ extra_defs="is_zfs_root() { return 0; }\nis_btrfs_root() { return 1; }",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(), ["configure_zfs_snapshots"])
+
+ def test_btrfs_root_runs_btrfs_snapshots(self):
+ result = run_orchestrator(
+ "configure_snapshots", self.SUBS,
+ extra_defs="is_zfs_root() { return 1; }\nis_btrfs_root() { return 0; }",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.split(), ["configure_btrfs_snapshots"])
+
+ def test_other_filesystem_runs_neither(self):
+ result = run_orchestrator(
+ "configure_snapshots", self.SUBS,
+ extra_defs="is_zfs_root() { return 1; }\nis_btrfs_root() { return 1; }",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ 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/run-task/test_run_task.py b/tests/run-task/test_run_task.py
new file mode 100644
index 0000000..35036dd
--- /dev/null
+++ b/tests/run-task/test_run_task.py
@@ -0,0 +1,172 @@
+"""Tests for the run_task / enable_service helpers in the archsetup installer.
+
+run_task is the installer's describe-run-warn primitive. It replaces the
+hand-written idiom that recurs ~100 times across the script:
+
+ action="enabling rngd service" && display "task" "$action"
+ systemctl enable rngd >> "$logfile" 2>&1 || error_warn "$action" "$?"
+
+as a single call:
+
+ run_task "enabling rngd service" systemctl enable rngd
+
+It announces the task via display, runs the command with stdout+stderr
+appended to $logfile, and on failure calls error_warn with the command's
+real exit code (non-fatal). enable_service is a thin wrapper that enables
+one or more systemd units with the conventional "enabling <unit> service"
+wording.
+
+These tests exercise the REAL function bodies, extracted from the
+`archsetup` script at run time (not a copy), with recording stubs standing
+in for display, error_warn, and systemctl. The command run by run_task is
+genuinely executed.
+
+Run from repo root:
+ python3 -m unittest tests.run-task.test_run_task
+"""
+
+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")
+
+# A bash harness that sources the real run_task + enable_service out of the
+# installer, with recording stubs for their dependencies. Each stub appends a
+# tab-separated record to a file named by an env var, so the Python side can
+# assert what was called. The real command passed to run_task still runs.
+WRAPPER = r"""#!/bin/bash
+ARCHSETUP="$1"; shift
+logfile="$LOGFILE"
+
+display() { printf '%s\t%s\n' "$1" "$2" >> "$DISPLAY_LOG"; }
+error_warn() { printf '%s\t%s\n' "$1" "$2" >> "$ERRWARN_LOG"; return 1; }
+systemctl() { printf 'systemctl %s\n' "$*"; }
+
+source <(sed -n '/^run_task() {/,/^}/p' "$ARCHSETUP")
+source <(sed -n '/^enable_service() {/,/^}/p' "$ARCHSETUP")
+
+"$@"
+"""
+
+
+class RunTaskHarness(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="run-task-test-")
+ self.wrapper = os.path.join(self.tmp, "run.sh")
+ with open(self.wrapper, "w") as f:
+ f.write(WRAPPER)
+ os.chmod(self.wrapper, 0o755)
+ self.logfile = os.path.join(self.tmp, "install.log")
+ self.display_log = os.path.join(self.tmp, "display.log")
+ self.errwarn_log = os.path.join(self.tmp, "errwarn.log")
+
+ def tearDown(self):
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ def call(self, *args):
+ env = dict(os.environ)
+ env["LOGFILE"] = self.logfile
+ env["DISPLAY_LOG"] = self.display_log
+ env["ERRWARN_LOG"] = self.errwarn_log
+ return subprocess.run(
+ ["bash", self.wrapper, ARCHSETUP, *args],
+ capture_output=True, text=True, timeout=10, env=env,
+ )
+
+ def read(self, path):
+ if not os.path.exists(path):
+ return ""
+ with open(path) as f:
+ return f.read()
+
+ # --- Normal cases -----------------------------------------------------
+
+ def test_run_task_success_announces_and_runs(self):
+ result = self.call("run_task", "doing a thing", "true")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ # Announced as a "task" with the exact description.
+ self.assertEqual(self.read(self.display_log), "task\tdoing a thing\n")
+ # No warning on success.
+ self.assertEqual(self.read(self.errwarn_log), "")
+
+ def test_run_task_captures_command_output_to_logfile(self):
+ result = self.call("run_task", "echo something", "echo", "hello-from-cmd")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("hello-from-cmd", self.read(self.logfile))
+ # Command output is logged, not printed to the terminal.
+ self.assertNotIn("hello-from-cmd", result.stdout)
+
+ def test_run_task_captures_stderr_to_logfile(self):
+ # `ls` of a missing path writes to stderr; it must land in the logfile.
+ missing = os.path.join(self.tmp, "no-such-path")
+ self.call("run_task", "listing", "ls", missing)
+ self.assertIn("no-such-path", self.read(self.logfile))
+
+ def test_run_task_preserves_multiple_arguments(self):
+ self.call("run_task", "multi-arg", "printf", "%s|%s|%s", "a", "b", "c")
+ self.assertIn("a|b|c", self.read(self.logfile))
+
+ def test_run_task_preserves_arguments_with_spaces(self):
+ self.call("run_task", "spacey", "printf", "[%s]", "two words")
+ self.assertIn("[two words]", self.read(self.logfile))
+
+ # --- enable_service ---------------------------------------------------
+
+ def test_enable_service_single_unit(self):
+ self.call("enable_service", "rngd")
+ self.assertEqual(self.read(self.display_log), "task\tenabling rngd service\n")
+ self.assertIn("systemctl enable rngd", self.read(self.logfile))
+
+ def test_enable_service_multiple_units(self):
+ self.call("enable_service", "foo", "bar", "baz")
+ disp = self.read(self.display_log)
+ self.assertIn("task\tenabling foo service\n", disp)
+ self.assertIn("task\tenabling bar service\n", disp)
+ self.assertIn("task\tenabling baz service\n", disp)
+ log = self.read(self.logfile)
+ self.assertIn("systemctl enable foo", log)
+ self.assertIn("systemctl enable bar", log)
+ self.assertIn("systemctl enable baz", log)
+
+ # --- Error cases ------------------------------------------------------
+
+ def test_run_task_failure_warns_with_description(self):
+ result = self.call("run_task", "failing thing", "false")
+ self.assertNotEqual(result.returncode, 0)
+ self.assertEqual(self.read(self.errwarn_log), "failing thing\t1\n")
+
+ def test_run_task_failure_propagates_real_exit_code(self):
+ # `bash -c 'exit 42'` must surface 42 to error_warn, not a clobbered 0.
+ self.call("run_task", "exit-42", "bash", "-c", "exit 42")
+ self.assertEqual(self.read(self.errwarn_log), "exit-42\t42\n")
+
+ def test_enable_service_failure_warns_per_unit(self):
+ # Override systemctl to fail; each unit should produce a warning.
+ env = dict(os.environ)
+ env["LOGFILE"] = self.logfile
+ env["DISPLAY_LOG"] = self.display_log
+ env["ERRWARN_LOG"] = self.errwarn_log
+ # Re-create wrapper with a failing systemctl stub for this case.
+ failing = os.path.join(self.tmp, "run-fail.sh")
+ with open(failing, "w") as f:
+ f.write(WRAPPER.replace(
+ "systemctl() { printf 'systemctl %s\\n' \"$*\"; }",
+ "systemctl() { printf 'systemctl %s\\n' \"$*\"; return 1; }",
+ ))
+ os.chmod(failing, 0o755)
+ subprocess.run(
+ ["bash", failing, ARCHSETUP, "enable_service", "alpha", "beta"],
+ capture_output=True, text=True, timeout=10, env=env,
+ )
+ warns = self.read(self.errwarn_log)
+ self.assertIn("enabling alpha service\t1\n", warns)
+ self.assertIn("enabling beta service\t1\n", warns)
+
+
+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()