aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps
diff options
context:
space:
mode:
Diffstat (limited to 'tests/installer-steps')
-rw-r--r--tests/installer-steps/test_clone_user_repos.py155
-rw-r--r--tests/installer-steps/test_configure_backlight_access.py139
-rw-r--r--tests/installer-steps/test_configure_framework_amd_internal_mic.py277
-rw-r--r--tests/installer-steps/test_configure_hibernate_delay.py131
-rw-r--r--tests/installer-steps/test_configure_openai_chatgpt_repo.py324
-rw-r--r--tests/installer-steps/test_configure_service_discovery.py85
-rw-r--r--tests/installer-steps/test_configure_tlp_power.py239
-rw-r--r--tests/installer-steps/test_configure_tunnel_dns_over_tls.py147
-rw-r--r--tests/installer-steps/test_dotfiles_dependency_packages.py119
-rw-r--r--tests/installer-steps/test_idempotency_cluster.py27
-rw-r--r--tests/installer-steps/test_install_camera_passthrough_rules.py118
-rw-r--r--tests/installer-steps/test_install_cpu_microcode.py110
-rw-r--r--tests/installer-steps/test_install_gnome_keyring_fix.py209
-rw-r--r--tests/installer-steps/test_mask_fwupd_passim.py79
-rw-r--r--tests/installer-steps/test_mask_tmp_mount_for_zfs.py109
-rw-r--r--tests/installer-steps/test_orchestrators.py15
16 files changed, 2277 insertions, 6 deletions
diff --git a/tests/installer-steps/test_clone_user_repos.py b/tests/installer-steps/test_clone_user_repos.py
new file mode 100644
index 0000000..51d8434
--- /dev/null
+++ b/tests/installer-steps/test_clone_user_repos.py
@@ -0,0 +1,155 @@
+"""Test clone_user_repos: the two user repos are cloned with full history.
+
+archsetup and dotfiles are not build directories. They are the two repos I
+actively develop in on every machine this installer builds, so a shallow clone
+is wrong for both. Velox came back from its 2026-08-13 rebuild with 7 commits
+of history in each instead of 851, and nothing about the tree said so.
+
+The quiet failure is what makes this worth a test rather than a one-line fix.
+`git log -- <path>` against a shallow clone does not error; it answers "no
+commits". So a credential-history check run on that machine reported five
+sensitive files absent from history and exited clean, when the real answer was
+that the clone could not see the history they live in. A security question came
+back falsely reassuring. Everything else it breaks — blame, bisect, any
+archaeology past the graft point — is merely annoying by comparison.
+
+The AUR build clones are a different case and stay shallow: they are throwaway
+build trees, cloned to run `make install` and then discarded, where history has
+no value and the download cost is real. So this suite asserts both halves —
+full history for the two user repos, and depth still pinned on the AUR path —
+because a fix applied with too broad a brush would regress the build clones
+without failing any test that only looked at the user repos.
+
+Method: sed-extract clone_user_repos from the real `archsetup`, fake git /
+mkdir / chown / display / error_warn / error_fatal, and read back the git
+command lines the function issued.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_clone_user_repos
+"""
+
+import os
+import re
+import subprocess
+import tempfile
+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(clone_fails=False, make_git_dir=True):
+ """Drive clone_user_repos with every side effect faked.
+
+ dotfiles_dir is pre-created with a .git so the function's "is this a real
+ checkout?" guard passes on the happy path; make_git_dir=False exercises the
+ guard itself.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ dotfiles_dir = os.path.join(d, "dotfiles")
+ os.makedirs(dotfiles_dir)
+ if make_git_dir:
+ os.makedirs(os.path.join(dotfiles_dir, ".git"))
+ clone_rc = 1 if clone_fails else 0
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ username=testuser
+ archsetup_repo="https://example.invalid/archsetup.git"
+ dotfiles_repo="https://example.invalid/dotfiles.git"
+ dotfiles_branch=main
+ dotfiles_dir="{dotfiles_dir}"
+ display() {{ :; }}
+ mkdir() {{ echo "MKDIR: $*" >> "{d}/calls.log"; return 0; }}
+ chown() {{ echo "CHOWN: $*" >> "{d}/calls.log"; return 0; }}
+ git() {{
+ echo "GIT: $*" >> "{d}/calls.log"
+ case "$1" in
+ clone) return {clone_rc} ;;
+ *) return 0 ;;
+ esac
+ }}
+ error_warn() {{ echo "WARN: $1" >> "{d}/calls.log"; return 1; }}
+ error_fatal() {{ echo "FATAL: $1" >> "{d}/calls.log"; exit 1; }}
+ source <(sed -n '/^clone_user_repos() {{/,/^}}/p' "{ARCHSETUP}")
+ clone_user_repos
+ echo "RC=$?" >> "{d}/calls.log"
+ exit 0
+ """)
+ subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+ with open(os.path.join(d, "calls.log")) as fh:
+ return fh.read()
+
+
+def clone_lines(log):
+ return [ln for ln in log.splitlines() if ln.startswith("GIT: clone")]
+
+
+class CloneUserRepos(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_both_user_repos_are_cloned(self):
+ lines = clone_lines(run())
+ self.assertEqual(len(lines), 2,
+ f"expected an archsetup clone and a dotfiles clone, got: {lines}")
+ self.assertTrue(any("archsetup.git" in ln for ln in lines))
+ self.assertTrue(any("dotfiles.git" in ln for ln in lines))
+
+ def test_archsetup_clone_carries_full_history(self):
+ """A shallow archsetup clone answers history questions wrongly."""
+ line = next(ln for ln in clone_lines(run()) if "archsetup.git" in ln)
+ self.assertNotIn("--depth", line,
+ "archsetup is a working repo, not a build tree — a shallow "
+ "clone makes `git log -- <path>` answer 'no commits' instead "
+ "of failing, which is how a credential-history check came "
+ "back falsely clean on velox")
+
+ def test_dotfiles_clone_carries_full_history(self):
+ line = next(ln for ln in clone_lines(run()) if "dotfiles.git" in ln)
+ self.assertNotIn("--depth", line,
+ "dotfiles is a working repo, not a build tree")
+
+ def test_dotfiles_clone_still_pins_the_branch(self):
+ """Dropping --depth must not disturb the --branch argument beside it."""
+ line = next(ln for ln in clone_lines(run()) if "dotfiles.git" in ln)
+ self.assertIn("--branch main", line)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_no_user_repo_clone_is_shallow_by_any_spelling(self):
+ """--depth, --depth=N and -depth are all shallow; catch the lot."""
+ for line in clone_lines(run()):
+ self.assertNotRegex(line, r"(^|\s)-{1,2}depth(\s|=)",
+ f"user-repo clone must be full: {line}")
+
+ def test_aur_build_clones_stay_shallow(self):
+ """The fix must not over-apply — build trees are throwaway.
+
+ Read against the real file rather than the extracted function, because
+ these clones live in a different function entirely and the risk being
+ guarded is a careless repo-wide sed.
+ """
+ with open(ARCHSETUP) as fh:
+ source = fh.read()
+ build_clones = re.findall(r"^.*git clone.*build_dir.*$", source, re.M)
+ self.assertTrue(build_clones, "expected AUR build clones to exist")
+ for line in build_clones:
+ self.assertIn("--depth 1", line,
+ f"AUR build clone should stay shallow: {line.strip()}")
+
+ # ------------------------------------------------------------- error ----
+ def test_clone_failure_is_reported_not_swallowed(self):
+ log = run(clone_fails=True)
+ self.assertIn("WARN:", log,
+ "a failed clone must surface through error_warn")
+
+ def test_dotfiles_clone_producing_no_checkout_is_fatal(self):
+ """The stow/restore steps downstream need a real checkout."""
+ log = run(make_git_dir=False)
+ self.assertIn("FATAL:", log)
+ self.assertNotIn("RC=", log, "error_fatal must halt, not fall through")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_backlight_access.py b/tests/installer-steps/test_configure_backlight_access.py
new file mode 100644
index 0000000..6fd171a
--- /dev/null
+++ b/tests/installer-steps/test_configure_backlight_access.py
@@ -0,0 +1,139 @@
+"""Test configure_backlight_access — udev rule for brightness writes.
+
+Arch's brightnessctl ships no udev rules: it relies on logind, which grants
+brightness writes only to the *active seat session*. Anything outside that
+session — a script, a remote shell, a panel launched into a different
+session — gets EPERM against root-owned sysfs. Found on velox 2026-08-13:
+on a fresh install the desktop settings panel's screen and keyboard
+brightness sliders were both inert.
+
+The step drops a rule making the brightness attributes group-writable by
+video (the group create_user already adds the user to). leds are granted to
+video as well rather than input, so nobody needs input-group membership —
+and the keylogging surface that carries — just to dim a keyboard.
+
+Method: sed-extract configure_backlight_access from the real `archsetup`,
+point it at a temp rules dir, and assert on the file it writes.
+
+ python3 -m unittest tests.installer-steps.test_configure_backlight_access
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+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(ruledir, marker=None):
+ # The step sends udevadm's own output to $logfile, so the stub records
+ # its argv to a marker file instead of stdout — writing straight to the
+ # file is unaffected by the caller's redirect.
+ marker = marker or os.devnull
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ udevadm() {{ echo "UDEVADM: $*" >> "{marker}"; }}
+ source <(sed -n '/^configure_backlight_access() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_backlight_access "{ruledir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class ConfigureBacklightAccess(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_a_rule_covering_backlight_and_keyboard_leds(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ rule = os.path.join(d, "90-backlight.rules")
+ self.assertTrue(os.path.exists(rule))
+ body = open(rule).read()
+ self.assertIn('SUBSYSTEM=="backlight"', body)
+ self.assertIn('SUBSYSTEM=="leds"', body)
+ self.assertIn('KERNEL=="*kbd_backlight"', body)
+
+ def test_grants_the_video_group_write_access(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ body = open(os.path.join(d, "90-backlight.rules")).read()
+ self.assertIn("chgrp video", body)
+ self.assertIn("chmod g+w", body)
+
+ def test_does_not_use_the_input_group(self):
+ # Granting leds to input would require input-group membership,
+ # which also confers read access to every input device.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ body = open(os.path.join(d, "90-backlight.rules")).read()
+ self.assertNotIn("chgrp input", body)
+
+ def test_rule_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(os.stat(os.path.join(d, "90-backlight.rules")).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_reloads_udev_so_the_rule_applies_without_a_reboot(self):
+ with tempfile.TemporaryDirectory() as d:
+ marker = os.path.join(d, "udevadm.calls")
+ run(d, marker=marker)
+ calls = open(marker).read()
+ self.assertIn("UDEVADM: control --reload", calls)
+ self.assertRegex(calls, r"UDEVADM: trigger .*backlight")
+ self.assertRegex(calls, r"UDEVADM: trigger .*leds")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_correct_rule(self):
+ # Compare whole bodies rather than counting lines: a count encodes
+ # today's line total, so it breaks on a correct edit and passes an
+ # append regression that happens to hit the same number.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ first = open(os.path.join(d, "90-backlight.rules")).read()
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ second = open(os.path.join(d, "90-backlight.rules")).read()
+ self.assertEqual(first, second)
+
+ def test_absent_rules_directory_is_created(self):
+ with tempfile.TemporaryDirectory() as d:
+ nested = os.path.join(d, "etc", "udev", "rules.d")
+ r = run(nested)
+ self.assertEqual(rc_of(r), 0)
+ self.assertTrue(os.path.exists(os.path.join(nested, "90-backlight.rules")))
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_directory_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ ruledir = os.path.join(d, "ro")
+ os.mkdir(ruledir)
+ os.chmod(ruledir, 0o500)
+ try:
+ r = run(ruledir)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(ruledir, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_framework_amd_internal_mic.py b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
new file mode 100644
index 0000000..7a45fb1
--- /dev/null
+++ b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
@@ -0,0 +1,277 @@
+"""Test configure_framework_amd_internal_mic and its DMI gate.
+
+The Framework Laptop 13 on AMD Ryzen AI 300 (Strix Point and Krackan Point
+alike) has a known, still-open kernel bug in the ACP PDM digital-mic path: the
+DMIC card (acp-pdm-mach, hw:acppdmmach) returns a constant full-scale rail
+(rms 0.99999) instead of audio. With UCM on, alsa-ucm-conf links that card
+into the ALC285 codec card, so the only "internal mic" PipeWire offers IS the
+broken DMIC, and the built-in microphone is dead in every app. Found on velox
+2026-10-06 after the 08-13 reinstall; Framework community threads 81866 and
+75748 document it across several HX 370 owners.
+
+The workaround is a WirePlumber drop-in that turns UCM off (the ALC285 falls
+back to its analog-stereo profile, whose Internal Microphone pin carries real
+audio) and disables the standalone PDM card so nothing can pick it. The step
+installs the shipped copy of that drop-in, and only on this hardware: the
+gate reads DMI, not the hostname, because the fix belongs to the board, not
+to velox.
+
+The gate has to be exact in both directions. The same bug does not exist on
+the Intel Framework 13 (SOF stack, not ACP), and the Framework Desktop (ratio,
+"AMD Ryzen AI Max 300") has no internal mic at all, so a vendor-only or
+"AMD"-only match would drop a UCM-off file onto machines whose audio is fine
+and silently flatten their codec profiles.
+
+Method: sed-extract both functions from the real `archsetup`, point the step
+at a temp conf.d dir and a fixture DMI dir, and fake display / error_warn.
+The shipped drop-in is read from the real configs/ tree so the test pins the
+content the installer actually delivers.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_configure_framework_amd_internal_mic
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+DROPIN_SRC = os.path.join(REPO_ROOT, "configs", "wireplumber", "50-disable-ucm.conf")
+DROPIN_NAME = "50-disable-ucm.conf"
+# The broken DMIC card as velox names it, and the codec card that must stay.
+VELOX_PDM_CARD = "alsa_card.pci-0000_c1_00.5-platform-acp-pdm-mach"
+VELOX_CODEC_CARD = "alsa_card.pci-0000_c1_00.6"
+
+# DMI fixtures. The product strings are what the real machines report.
+VELOX = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen AI 300 Series)")
+RATIO = dict(sys_vendor="Framework", product_name="Desktop (AMD Ryzen AI Max 300 Series)")
+INTEL_13 = dict(sys_vendor="Framework", product_name="Laptop (13th Gen Intel Core)")
+AMD_7040_13 = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen 7040Series)")
+AMD_16 = dict(sys_vendor="Framework", product_name="Laptop 16 (AMD Ryzen 7040 Series)")
+OTHER_VENDOR = dict(sys_vendor="LENOVO", product_name="Laptop 13 (AMD Ryzen AI 300 Series)")
+
+
+def write_dmi(dmi_dir, fields):
+ os.makedirs(dmi_dir, exist_ok=True)
+ for name, value in fields.items():
+ with open(os.path.join(dmi_dir, name), "w") as f:
+ f.write(value + "\n")
+
+
+def gate(dmi_dir):
+ script = textwrap.dedent(f"""\
+ source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}")
+ if is_framework_13_ryzen_ai_300 "{dmi_dir}"; then echo "GATE=yes"; else echo "GATE=no"; fi
+ exit 0
+ """)
+ r = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
+ m = re.search(r"^GATE=(yes|no)$", r.stdout, re.M)
+ assert m, "no GATE line in output: %r / %r" % (r.stdout, r.stderr)
+ return m.group(1) == "yes"
+
+
+def run(confd, dmi_dir, src_root=REPO_ROOT):
+ # The step sends install's output to $logfile; the fakes report on stdout
+ # so the test can see the warning and the function's own return code.
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ username=nobody
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ user_archsetup_dir="{src_root}"
+ source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}")
+ source <(sed -n '/^configure_framework_amd_internal_mic() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_framework_amd_internal_mic "{confd}" "{dmi_dir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class Framework13RyzenAi300Gate(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dmi = os.path.join(self.tmp.name, "dmi")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ # ------------------------------------------------------------ normal ----
+ def test_matches_the_framework_13_on_ryzen_ai_300(self):
+ write_dmi(self.dmi, VELOX)
+ self.assertTrue(gate(self.dmi))
+
+ # ---------------------------------------------------------- boundary ----
+ def test_rejects_the_framework_desktop_on_ryzen_ai_max(self):
+ """ratio: same vendor, "AMD Ryzen AI" in the name, no internal mic.
+
+ "AMD Ryzen AI Max 300" must not satisfy an "AMD Ryzen AI 300" match.
+ """
+ write_dmi(self.dmi, RATIO)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_13_on_intel(self):
+ """Intel's SOF stack has no ACP PDM path; the bug does not exist there."""
+ write_dmi(self.dmi, INTEL_13)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_13_on_ryzen_7040(self):
+ """Phoenix is not Ryzen AI 300; the workaround is unverified there."""
+ write_dmi(self.dmi, AMD_7040_13)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_16(self):
+ write_dmi(self.dmi, AMD_16)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_another_vendor_with_the_same_model_string(self):
+ write_dmi(self.dmi, OTHER_VENDOR)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_a_missing_dmi_directory(self):
+ self.assertFalse(gate(os.path.join(self.tmp.name, "nope")))
+
+
+class ConfigureFrameworkAmdInternalMic(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dmi = os.path.join(self.tmp.name, "dmi")
+ self.confd = os.path.join(self.tmp.name, "wireplumber.conf.d")
+ self.dropin = os.path.join(self.confd, DROPIN_NAME)
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ # ------------------------------------------------------------ normal ----
+ def test_framework_13_ryzen_ai_300_gets_the_shipped_dropin(self):
+ write_dmi(self.dmi, VELOX)
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertTrue(os.path.exists(self.dropin), "drop-in not installed")
+ with open(self.dropin) as f, open(DROPIN_SRC) as src:
+ self.assertEqual(f.read(), src.read(),
+ "the installed file must be the shipped file, byte for byte")
+
+ def test_dropin_mode_is_0644(self):
+ """Owner-writable, world-readable, like every file under /etc."""
+ write_dmi(self.dmi, VELOX)
+ run(self.confd, self.dmi)
+ mode = stat.S_IMODE(os.stat(self.dropin).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_shipped_dropin_turns_ucm_off(self):
+ """Pin the shipped content, not just the copy: use-ucm must be an
+ active setting, not a commented example."""
+ with open(DROPIN_SRC) as f:
+ body = f.read()
+ active = [ln for ln in body.splitlines()
+ if "alsa.use-ucm" in ln and not ln.lstrip().startswith("#")]
+ self.assertEqual(len(active), 1, body)
+ self.assertRegex(active[0], r"alsa\.use-ucm\s*=\s*false")
+
+ def test_shipped_dropin_disables_the_standalone_pdm_card(self):
+ """Without UCM the broken DMIC still shows up as its own card; the
+ rule has to catch it and disable it, or an app or the default
+ selection can still land on the full-scale rail.
+
+ The gate fires on every Framework 13 on Ryzen AI 300, so the rule
+ must match the card by name pattern (WirePlumber's ~regex form), not
+ by the one PCI path velox happens to enumerate: a renumbered bus on
+ the same board would otherwise get UCM turned off with the broken
+ card left selectable. WirePlumber's regex match is unanchored, so
+ re.search mirrors it.
+ """
+ with open(DROPIN_SRC) as f:
+ body = f.read()
+ m = re.search(r'device\.name\s*=\s*"~([^"]+)"', body)
+ self.assertIsNotNone(m, "the rule must match device.name with a ~regex, "
+ "not a literal PCI path")
+ assert m is not None # narrows the type; the assertion above is the test
+ pattern = m.group(1)
+ self.assertTrue(re.search(pattern, VELOX_PDM_CARD),
+ f"{pattern!r} must match velox's card {VELOX_PDM_CARD!r}")
+ self.assertTrue(re.search(pattern, "alsa_card.pci-0000_c3_00.5-platform-acp-pdm-mach"),
+ "the same card on another bus number must match too")
+ self.assertFalse(re.search(pattern, VELOX_CODEC_CARD),
+ "the ALC285 codec card carries the working mic and must not be disabled")
+ self.assertRegex(body, r"device\.disabled\s*=\s*true")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_other_hardware_writes_nothing(self):
+ for fixture in (RATIO, INTEL_13, AMD_7040_13, AMD_16, OTHER_VENDOR):
+ with self.subTest(product=fixture["product_name"], vendor=fixture["sys_vendor"]):
+ write_dmi(self.dmi, fixture)
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertFalse(os.path.exists(self.confd),
+ "no conf.d may be created on hardware the step does not apply to")
+ self.assertNotIn("WARN:", r.stdout)
+
+ def test_missing_dmi_directory_is_a_quiet_no_op(self):
+ """A VM or a stripped sysfs reports no DMI; that is not an error."""
+ r = run(self.confd, os.path.join(self.tmp.name, "nope"))
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertFalse(os.path.exists(self.confd))
+ self.assertNotIn("WARN:", r.stdout)
+
+ def test_running_twice_leaves_one_identical_dropin(self):
+ write_dmi(self.dmi, VELOX)
+ run(self.confd, self.dmi)
+ with open(self.dropin) as f:
+ first = f.read()
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ with open(self.dropin) as f:
+ second = f.read()
+ self.assertEqual(first, second)
+ self.assertEqual(os.listdir(self.confd), [DROPIN_NAME])
+
+ def test_absent_confd_is_created(self):
+ """wireplumber's package ships /etc/wireplumber/ but not conf.d/; the
+ step must create the nested path rather than fail on it."""
+ write_dmi(self.dmi, VELOX)
+ nested = os.path.join(self.tmp.name, "etc", "wireplumber", "wireplumber.conf.d")
+ r = run(nested, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertTrue(os.path.exists(os.path.join(nested, DROPIN_NAME)))
+
+ # ------------------------------------------------------------- error ----
+ def test_missing_shipped_file_warns_and_does_not_crash(self):
+ """A partial checkout has no configs/ tree; surface it, keep going."""
+ write_dmi(self.dmi, VELOX)
+ empty_root = os.path.join(self.tmp.name, "empty-checkout")
+ os.makedirs(empty_root)
+ r = run(self.confd, self.dmi, src_root=empty_root)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ self.assertFalse(os.path.exists(self.dropin))
+
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_confd_warns_and_does_not_crash(self):
+ write_dmi(self.dmi, VELOX)
+ os.makedirs(self.confd)
+ os.chmod(self.confd, 0o500)
+ try:
+ r = run(self.confd, self.dmi)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(self.confd, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_hibernate_delay.py b/tests/installer-steps/test_configure_hibernate_delay.py
new file mode 100644
index 0000000..95f2dcb
--- /dev/null
+++ b/tests/installer-steps/test_configure_hibernate_delay.py
@@ -0,0 +1,131 @@
+"""Test configure_hibernate_delay — the suspend-to-hibernate handoff.
+
+The desktop panel can upgrade its idle suspend stage to
+suspend-then-hibernate, but the delay between the two is systemd's
+HibernateDelaySec, which lives in a root-owned drop-in and so cannot be a
+panel setting. Left unset the delay depends on the hardware: a machine
+with a battery hibernates on a low-battery alarm at an hour nobody chose,
+and one without falls back to systemd's 2h default.
+
+90 minutes is the seeded value: long enough that stepping out of a meeting
+costs a screen unlock rather than a passphrase and a resume, short enough
+that a laptop in a bag is not holding its encryption keys in RAM all
+afternoon (a suspended machine is; a hibernated one is not).
+
+Method: sed-extract configure_hibernate_delay from the real `archsetup`,
+point it at a temp config dir, and assert on the file it writes.
+
+ python3 -m unittest tests.installer-steps.test_configure_hibernate_delay
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+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(confdir):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^configure_hibernate_delay() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_hibernate_delay "{confdir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+def body(confdir):
+ with open(os.path.join(confdir, "60-hibernate-delay.conf")) as f:
+ return f.read()
+
+
+class ConfigureHibernateDelay(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_a_sleep_drop_in_with_the_delay(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ text = body(d)
+ self.assertIn("[Sleep]", text)
+ self.assertIn("HibernateDelaySec=90min", text)
+
+ def test_drop_in_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(
+ os.stat(os.path.join(d, "60-hibernate-delay.conf")).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_explains_why_the_value_is_not_left_to_systemd(self):
+ # The number is a judgement call, so the file has to carry its
+ # reasoning: whoever changes it next needs the tradeoff, not just
+ # a number to overwrite. Assert on the tradeoff's own terms —
+ # "contains a hash" would pass on any comment at all.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ text = body(d).lower()
+ self.assertIn("2h default", text) # what unset would give
+ self.assertIn("encryption keys", text) # why not longer
+ self.assertIn("passphrase", text) # why not shorter
+
+ def test_drop_in_sorts_above_vendor_drop_ins(self):
+ # systemd reserves 10-40 for /usr and 60-90 for /etc. A 10- file
+ # in /etc sorts below a vendor 20- file and silently loses to it.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ name = os.listdir(d)[0]
+ prefix = int(name.split("-")[0])
+ self.assertGreaterEqual(prefix, 60)
+ self.assertLessEqual(prefix, 90)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_correct_drop_in(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ first = body(d)
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ self.assertEqual(first, body(d))
+
+ def test_absent_directory_is_created(self):
+ with tempfile.TemporaryDirectory() as d:
+ nested = os.path.join(d, "etc", "systemd", "sleep.conf.d")
+ r = run(nested)
+ self.assertEqual(rc_of(r), 0)
+ self.assertIn("HibernateDelaySec=90min", body(nested))
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_directory_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ confdir = os.path.join(d, "ro")
+ os.mkdir(confdir)
+ os.chmod(confdir, 0o500)
+ try:
+ r = run(confdir)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(confdir, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_openai_chatgpt_repo.py b/tests/installer-steps/test_configure_openai_chatgpt_repo.py
new file mode 100644
index 0000000..cb0e84b
--- /dev/null
+++ b/tests/installer-steps/test_configure_openai_chatgpt_repo.py
@@ -0,0 +1,324 @@
+"""Test configure_openai_chatgpt_repo — OpenAI's signed pacman repo for ChatGPT.
+
+OpenAI ships the ChatGPT (Codex) desktop app for Arch as chatgpt-bin from
+its own signed pacman repository. Its install-arch.sh pins the repository
+signing-key fingerprint, locally signs the key into the pacman keyring,
+writes /etc/pacman.d/openai-chatgpt.conf and adds an Include line to
+pacman.conf; ratio and velox were set up that way on 2026-10-11. The step
+reproduces exactly that configuration so a re-run on those machines is a
+no-op and the app rides normal pacman upgrades under OpenAI's signature.
+The AUR repackage of the same binary (chatgpt-desktop) would conflict with
+chatgpt-bin on any machine already using the repo, so it is not used.
+
+A half-configured repository is worse than none: with the Include in place
+and the key untrusted, SigLevel TrustedOnly makes every later pacman call
+fail on the invalid database. So an untrusted key (fingerprint mismatch or
+failed download) must import nothing and leave pacman.conf untouched.
+
+The pin is on the primary key's fingerprint alone. pacman-key --lsign-key
+resolves a subkey fingerprint to its primary, so a bundle whose subkey or
+second primary carries the pinned fingerprint must be rejected too.
+
+Method: sed-extract configure_openai_chatgpt_repo from the real `archsetup`,
+point it at a temp pacman.conf and repo drop-in, and replace curl, gpg,
+pacman-key and pacman with recorders driven by environment switches.
+
+ python3 -m unittest tests.installer-steps.test_configure_openai_chatgpt_repo
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+FPR = "3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4"
+KEY_URL = "https://persistent.oaistatic.com/codex-app-prod/linux/repository-signing-key.gpg"
+SERVER = "https://persistent.oaistatic.com/codex-app-prod/linux/arch/$arch"
+BASE_CONF = "[core]\nInclude = /etc/pacman.d/mirrorlist\n"
+
+# Recorders. Every fake appends its argv to $MARKER; the pacman fake also
+# records whether the Include line is already in pacman.conf at call time,
+# which is how the tests see that -Sy ran after the repo was wired in.
+STUBS = r"""
+logfile=/dev/null
+action=""
+display() { :; }
+backup_system_file() { :; }
+error_warn() { echo "WARN: $1"; return 1; }
+pacman-key() {
+ echo "PACMAN_KEY: $*" >> "$MARKER"
+ case "$1" in
+ --list-keys) [ "$KEY_PRESENT" = 1 ] ;;
+ *) return 0 ;;
+ esac
+}
+curl() {
+ echo "CURL: $*" >> "$MARKER"
+ [ "$CURL_FAIL" = 1 ] && return 22
+ local out=""
+ while [ $# -gt 0 ]; do
+ case "$1" in -o) out="$2"; shift ;; esac
+ shift
+ done
+ [ -n "$out" ] && printf 'FAKE KEY\n' > "$out"
+ return 0
+}
+gpg() {
+ echo "GPG: $*" >> "$MARKER"
+ case " $* " in
+ *" --show-keys "*)
+ printf 'pub:u:4096:1:4A3B4A566C4660E4:1754362069:::u:::scSC::::::23::0:\n'
+ printf 'fpr:::::::::%s:\n' "$SERVED_FPR"
+ printf 'uid:u::::1754362069::ABC::Codex Linux Repository::::::::::0:\n'
+ if [ -n "$SERVED_SUBKEY_FPR" ]; then
+ printf 'sub:u:4096:1:0123456789ABCDEF:1754362069::::::e::::::23:\n'
+ printf 'fpr:::::::::%s:\n' "$SERVED_SUBKEY_FPR"
+ fi
+ if [ -n "$SERVED_SECOND_PUB_FPR" ]; then
+ printf 'pub:u:4096:1:FEDCBA9876543210:1754362069:::u:::scSC::::::23::0:\n'
+ printf 'fpr:::::::::%s:\n' "$SERVED_SECOND_PUB_FPR"
+ printf 'uid:u::::1754362069::DEF::Somebody Else::::::::::0:\n'
+ fi
+ ;;
+ esac
+ return 0
+}
+pacman() {
+ local n
+ n=$(grep -cxF "Include = $REPO_CONF" "$PACMAN_CONF" 2>/dev/null)
+ echo "PACMAN: $* include=$n" >> "$MARKER"
+ return 0
+}
+"""
+
+
+def paths(d):
+ return (os.path.join(d, "pacman.conf"),
+ os.path.join(d, "pacman.d", "openai-chatgpt.conf"),
+ os.path.join(d, "calls"))
+
+
+def run(d, *, key_present=False, served_fpr=FPR, served_subkey_fpr="",
+ served_second_pub_fpr="", curl_fail=False, pacman_conf_body=BASE_CONF):
+ pacman_conf, repo_conf, marker = paths(d)
+ if pacman_conf_body is not None and not os.path.exists(pacman_conf):
+ with open(pacman_conf, "w") as f:
+ f.write(pacman_conf_body)
+ script = STUBS + textwrap.dedent(f"""\
+ source <(sed -n '/^configure_openai_chatgpt_repo() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_openai_chatgpt_repo "$PACMAN_CONF" "$REPO_CONF"
+ echo "RC=$?"
+ exit 0
+ """)
+ env = dict(os.environ, MARKER=marker, PACMAN_CONF=pacman_conf,
+ REPO_CONF=repo_conf, KEY_PRESENT="1" if key_present else "0",
+ SERVED_FPR=served_fpr, SERVED_SUBKEY_FPR=served_subkey_fpr,
+ SERVED_SECOND_PUB_FPR=served_second_pub_fpr,
+ CURL_FAIL="1" if curl_fail else "0")
+ return subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=10, env=env)
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+def read(path):
+ with open(path) as f:
+ return f.read()
+
+
+def calls(d):
+ marker = paths(d)[2]
+ return read(marker) if os.path.exists(marker) else ""
+
+
+class ConfigureOpenaiChatgptRepo(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_the_published_repo_definition(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ _, repo_conf, _ = paths(d)
+ self.assertTrue(os.path.exists(repo_conf))
+ body = read(repo_conf)
+ self.assertIn("[openai-chatgpt]\n", body)
+ self.assertIn("SigLevel = Required DatabaseRequired TrustedOnly\n", body)
+ # $arch stays literal: pacman expands it, the shell must not.
+ self.assertIn(f"Server = {SERVER}\n", body)
+
+ def test_repo_definition_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(os.stat(paths(d)[1]).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_adds_one_include_line_to_pacman_conf(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ pacman_conf, repo_conf, _ = paths(d)
+ body = read(pacman_conf)
+ self.assertTrue(body.startswith(BASE_CONF), body)
+ self.assertEqual(body.count(f"\nInclude = {repo_conf}\n"), 1)
+
+ def test_imports_and_locally_signs_the_pinned_key(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ c = calls(d)
+ self.assertIn(f" {KEY_URL}", c)
+ self.assertIn("GPG: ", c)
+ self.assertIn("--show-keys", c)
+ self.assertIn("PACMAN_KEY: --add ", c)
+ self.assertIn(f"PACMAN_KEY: --lsign-key {FPR}", c)
+
+ def test_refreshes_databases_after_the_include_is_in_place(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ self.assertIn("PACMAN: -Sy include=1", calls(d))
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_include_and_the_same_files(self):
+ with tempfile.TemporaryDirectory() as d:
+ pacman_conf, repo_conf, _ = paths(d)
+ self.assertEqual(rc_of(run(d)), 0)
+ first_conf, first_repo = read(pacman_conf), read(repo_conf)
+ self.assertEqual(rc_of(run(d)), 0)
+ self.assertEqual(read(pacman_conf), first_conf)
+ self.assertEqual(read(repo_conf), first_repo)
+ self.assertEqual(first_conf.count("Include = " + repo_conf), 1)
+
+ def test_already_trusted_key_is_not_downloaded_or_added_again(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, key_present=True)
+ self.assertEqual(rc_of(r), 0)
+ c = calls(d)
+ self.assertNotIn("CURL:", c)
+ self.assertNotIn("PACMAN_KEY: --add", c)
+ self.assertTrue(os.path.exists(paths(d)[1]))
+
+ def test_pacman_conf_without_trailing_newline_gets_its_own_line(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, pacman_conf_body=BASE_CONF.rstrip("\n"))
+ self.assertEqual(rc_of(r), 0)
+ pacman_conf, repo_conf, _ = paths(d)
+ body = read(pacman_conf)
+ self.assertIn("Include = /etc/pacman.d/mirrorlist\n", body)
+ self.assertRegex(body, re.compile(rf"^Include = {re.escape(repo_conf)}$", re.M))
+
+ def test_existing_differing_repo_definition_is_kept_and_flagged(self):
+ with tempfile.TemporaryDirectory() as d:
+ pacman_conf, repo_conf, _ = paths(d)
+ os.makedirs(os.path.dirname(repo_conf))
+ custom = "[openai-chatgpt]\nSigLevel = Required\nServer = file:///srv/mirror\n"
+ with open(repo_conf, "w") as f:
+ f.write(custom)
+ r = run(d)
+ self.assertIn("WARN:", r.stdout)
+ self.assertEqual(read(repo_conf), custom)
+ # The repository is still configured, just by the user's file.
+ self.assertIn(f"\nInclude = {repo_conf}\n", read(pacman_conf))
+ self.assertEqual(rc_of(r), 0)
+
+ # ------------------------------------------------------------- error ----
+ def assert_nothing_configured(self, d, r):
+ pacman_conf, repo_conf, _ = paths(d)
+ c = calls(d)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotIn("PACMAN_KEY: --add", c)
+ self.assertNotIn("PACMAN_KEY: --lsign-key", c)
+ self.assertNotIn("PACMAN: -Sy", c)
+ self.assertFalse(os.path.exists(repo_conf))
+ self.assertEqual(read(pacman_conf), BASE_CONF)
+ self.assertNotEqual(rc_of(r), 0)
+
+ def test_fingerprint_mismatch_imports_nothing_and_touches_nothing(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr="DEADBEEF" * 5)
+ self.assert_nothing_configured(d, r)
+
+ def test_key_download_failure_imports_nothing_and_touches_nothing(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, curl_fail=True)
+ self.assert_nothing_configured(d, r)
+
+ # The pin is on the PRIMARY key's fingerprint and nothing else. pacman-key
+ # --lsign-key resolves a subkey fingerprint to the primary that carries it,
+ # so a pin that a subkey or a second primary could satisfy would trust
+ # whatever key the bundle smuggled in beside the expected one.
+ def test_subkey_carrying_the_pinned_fingerprint_is_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr="DEADBEEF" * 5, served_subkey_fpr=FPR)
+ self.assert_nothing_configured(d, r)
+
+ def test_second_primary_carrying_the_pinned_fingerprint_is_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr="DEADBEEF" * 5, served_second_pub_fpr=FPR)
+ self.assert_nothing_configured(d, r)
+
+ def test_rogue_second_primary_beside_the_pinned_key_is_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr=FPR, served_second_pub_fpr="DEADBEEF" * 5)
+ self.assert_nothing_configured(d, r)
+
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores file write bits")
+ def test_unwritable_pacman_conf_warns_and_reports_failure(self):
+ with tempfile.TemporaryDirectory() as d:
+ pacman_conf, _, _ = paths(d)
+ with open(pacman_conf, "w") as f:
+ f.write(BASE_CONF)
+ os.chmod(pacman_conf, 0o444)
+ try:
+ r = run(d)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ self.assertNotIn("PACMAN: -Sy", calls(d))
+ finally:
+ os.chmod(pacman_conf, 0o644)
+
+
+class SupplementalSoftwareWiring(unittest.TestCase):
+ """chatgpt-bin is installed only once its repository is trusted."""
+
+ def run_step(self, repo_rc):
+ script = textwrap.dedent(f"""\
+ desktop_env=hyprland
+ display() {{ :; }}
+ run_task() {{ :; }}
+ mask_fwupd_passim() {{ :; }}
+ pacman_install() {{ echo "PACMAN_INSTALL: $1"; }}
+ aur_install() {{ echo "AUR_INSTALL: $1"; }}
+ configure_openai_chatgpt_repo() {{ echo "CONFIGURE_REPO"; return {repo_rc}; }}
+ source <(sed -n '/^supplemental_software() {{/,/^}}/p' "{ARCHSETUP}")
+ supplemental_software
+ """)
+ return subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=10)
+
+ def test_installs_chatgpt_bin_after_configuring_the_repo(self):
+ out = self.run_step(0).stdout
+ self.assertIn("CONFIGURE_REPO\n", out)
+ self.assertLess(out.index("CONFIGURE_REPO"),
+ out.index("PACMAN_INSTALL: chatgpt-bin"))
+
+ def test_skips_chatgpt_bin_when_the_repo_cannot_be_trusted(self):
+ out = self.run_step(1).stdout
+ self.assertNotIn("PACMAN_INSTALL: chatgpt-bin", out)
+ # The failure is local to the app; the rest of the step still runs.
+ self.assertIn("AUR_INSTALL: claude-desktop", out)
+ self.assertIn("AUR_INSTALL: freetube", out)
+
+ def test_does_not_also_pull_the_conflicting_aur_repackage(self):
+ out = self.run_step(0).stdout
+ self.assertNotIn("chatgpt-desktop", out)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_service_discovery.py b/tests/installer-steps/test_configure_service_discovery.py
new file mode 100644
index 0000000..90ce041
--- /dev/null
+++ b/tests/installer-steps/test_configure_service_discovery.py
@@ -0,0 +1,85 @@
+"""Pin configure_service_discovery's source: the WS-Discovery host daemon
+is never enabled.
+
+wsdd.service advertises this machine as a Samba host to Windows clients.
+Nothing the installer sets up runs Samba, so enabled it advertised a share
+server that doesn't exist while listening on every interface, VPN and
+tailscale links included (2026-09-12). Browsing Windows shares is the other
+direction: gvfs-wsdd spawns its own wsdd in discovery mode and needs only
+the package.
+
+Method: the step writes straight to /etc (geoclue.conf, the dbus-broker
+drop-in) with no directory parameter, so unlike the other step tests this
+one can't run the function in a temp dir. It sed-extracts the source and
+asserts on the calls it contains.
+
+ python3 -m unittest tests.installer-steps.test_configure_service_discovery
+"""
+
+import os
+import re
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def function_source(name):
+ with open(ARCHSETUP) as f:
+ text = f.read()
+ m = re.search(r"^%s\(\) \{\n.*?^\}\n" % re.escape(name), text, re.S | re.M)
+ assert m, "function %s not found in archsetup" % name
+ return m.group(0)
+
+
+ENABLE_WSDD = r"(systemctl\s+enable|enable_service)\s+(--now\s+)?wsdd\b"
+
+
+def calls(src):
+ """Non-comment lines — what the function actually runs."""
+ return [l for l in src.splitlines() if l.strip() and not l.strip().startswith("#")]
+
+
+class ConfigureServiceDiscovery(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_does_not_enable_the_wsdd_host_daemon(self):
+ # Both spellings the script uses: a raw systemctl call and the
+ # enable_service helper (which takes the bare unit name).
+ body = "\n".join(calls(function_source("configure_service_discovery")))
+ self.assertNotRegex(body, ENABLE_WSDD)
+
+ def test_turns_off_a_previously_enabled_wsdd_on_rerun(self):
+ # Machines installed before this change still have the unit
+ # enabled; a re-run converges them. The guard keeps a fresh install
+ # (no unit yet) quiet.
+ body = "\n".join(calls(function_source("configure_service_discovery")))
+ self.assertRegex(body, r"systemctl\s+is-enabled\s+(--quiet\s+)?wsdd\.service")
+ self.assertRegex(body, r"systemctl\s+disable\s+--now\s+wsdd\.service")
+
+ def test_still_enables_the_discovery_it_does_want(self):
+ # Characterization: removing wsdd must not have taken avahi (mDNS)
+ # or geoclue with it.
+ body = "\n".join(calls(function_source("configure_service_discovery")))
+ self.assertIn("systemctl enable avahi-daemon.service", body)
+ self.assertIn("systemctl enable geoclue.service", body)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_wsdd_package_still_installed_for_gvfs(self):
+ # The package stays: gvfs-wsdd depends on it and spawns its own
+ # discovery-mode instance. Only the host service is gone.
+ body = "\n".join(calls(function_source("supplemental_software")))
+ self.assertRegex(body, r"pacman_install\s+wsdd\b")
+ self.assertRegex(body, r"pacman_install\s+gvfs-wsdd\b")
+
+ # ------------------------------------------------------------- error ----
+ def test_no_other_step_enables_wsdd_either(self):
+ # A regression that re-enables it from a different step is the same
+ # bug; scan the whole script, not just the one function.
+ with open(ARCHSETUP) as f:
+ lines = [l for l in f if l.strip() and not l.strip().startswith("#")]
+ offenders = [l.rstrip() for l in lines if re.search(ENABLE_WSDD, l)]
+ self.assertEqual(offenders, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_tlp_power.py b/tests/installer-steps/test_configure_tlp_power.py
new file mode 100644
index 0000000..dc46c93
--- /dev/null
+++ b/tests/installer-steps/test_configure_tlp_power.py
@@ -0,0 +1,239 @@
+"""Test configure_tlp_power's radio-enable line, daemon masking, and laptop gating.
+
+systemd-rfkill is masked on laptops because it fights TLP's radio handling —
+which means nothing restores radio state at boot unless TLP is told to. The
+velox 2026-04-10 setup found wifi and bluetooth soft-blocked on first boot for
+exactly this reason. The conf written here must carry
+DEVICES_TO_ENABLE_ON_STARTUP so a fresh install comes up with radios on.
+
+power-profiles-daemon is masked and stopped on laptops for the same class of
+reason. power-profiles-daemon.service declares "Conflicts=tuned.service
+tlp.service auto-cpufreq.service ..." — the line is in ppd's unit, not tlp's —
+so systemd TERMs TLP the instant ppd starts. Leaving ppd merely disabled does
+not prevent that: ppd ships D-Bus activation files, and the desktop-settings
+panel's own powerprofilesctl call activates it on demand. Velox ran that way
+from its 2026-08-13 rebuild until 2026-08-16, with TLP failing at every boot and
+none of its battery policy applied, while the machine looked correctly
+configured. Masking blocks D-Bus activation too, which both keeps TLP alive and
+makes the panel's power control read as unavailable, the behavior the
+package-install site in `archsetup` already documents as intended. The stop is
+what makes a repair re-run take effect on a booted machine, where a mask alone
+would leave a running ppd running.
+
+The bluetooth resume hook installed here must land in
+/usr/lib/systemd/system-sleep/. That is the only directory systemd-sleep scans
+(systemd 262's binary and its man page both name just the one), so a hook under
+/etc/systemd/system-sleep/ is silently dead. Velox ran about fifteen hibernate
+cycles between 2026-08-21 and 2026-10-05 with the hook installed there and it
+never fired once; bluetooth came back rfkilled every time, and TLP's own hook
+(which lives in /usr/lib and does run) carried the block across every later
+cycle.
+
+Method: sed-extract configure_tlp_power from the real `archsetup`, point it at
+a temp tlp.d dir and a temp power-supply dir, and fake pacman_install /
+run_task / display / error_warn / systemctl.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_configure_tlp_power
+"""
+
+import os
+import stat
+import subprocess
+import tempfile
+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(battery=True, bat_name="BAT0", unwritable_tlpd=False, systemctl_fails=False,
+ install_fails=False):
+ with tempfile.TemporaryDirectory() as d:
+ psdir = os.path.join(d, "power_supply")
+ os.makedirs(psdir)
+ if battery:
+ open(os.path.join(psdir, bat_name), "w").close()
+ tlpd = os.path.join(d, "tlp.d")
+ if unwritable_tlpd:
+ os.makedirs(tlpd)
+ os.chmod(tlpd, stat.S_IRUSR | stat.S_IXUSR)
+ # The real mask call redirects stdout into $logfile, so the fake
+ # systemctl records to a side file the test reads back instead.
+ sysrc = 1 if systemctl_fails else 0
+ # The real install_executable warns on its own; the fake only reports
+ # the return code so the test can see whether the step keeps going.
+ instrc = 1 if install_fails else 0
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ pacman_install() {{ echo "INSTALL: $1"; }}
+ run_task() {{ echo "TASK: $1"; }}
+ systemctl() {{ echo "SYSTEMCTL: $*" >> "{d}/systemctl.log"; return {sysrc}; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ user_archsetup_dir=/src
+ install_executable() {{ echo "INSTALL_EXEC: $1 -> $2"; return {instrc}; }}
+ source <(sed -n '/^configure_tlp_power() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_tlp_power "{tlpd}" "{psdir}"
+ echo "RC=$?"
+ echo "CONF:[$(cat "{tlpd}/01-custom.conf" 2>/dev/null)]"
+ [ -f "{d}/systemctl.log" ] && cat "{d}/systemctl.log"
+ exit 0
+ """)
+ r = subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+ if unwritable_tlpd:
+ os.chmod(tlpd, stat.S_IRWXU)
+ return r
+
+
+class ConfigureTlpPower(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_laptop_gets_tlp_with_radio_enable_line(self):
+ r = run(battery=True)
+ self.assertIn("INSTALL: tlp", r.stdout)
+ conf = r.stdout.split("CONF:[")[1]
+ self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"', conf,
+ "radios must be re-enabled at boot: systemd-rfkill is "
+ "masked, so TLP is the only thing that can do it")
+ self.assertIn("CPU_ENERGY_PERF_POLICY_ON_AC", conf)
+ self.assertIn("SYSTEMCTL: mask systemd-rfkill.service systemd-rfkill.socket",
+ r.stdout)
+ self.assertIn("TASK: enabling TLP service", r.stdout)
+
+ def test_laptop_masks_power_profiles_daemon(self):
+ r = run(battery=True)
+ self.assertIn("SYSTEMCTL: mask power-profiles-daemon.service", r.stdout,
+ "ppd's unit declares Conflicts=...tlp.service..., so ppd "
+ "must be masked or it TERMs TLP whenever it is activated")
+
+ def test_laptop_stops_running_power_profiles_daemon(self):
+ """Masking alone leaves an already-running ppd running.
+
+ The installer runs on a booted system, so a repair re-run would
+ otherwise mask ppd, leave it live, and let it keep TLP dead until the
+ next reboot with nothing reporting it.
+ """
+ r = run(battery=True)
+ self.assertIn("SYSTEMCTL: stop power-profiles-daemon.service", r.stdout)
+
+ def test_ppd_is_masked_before_it_is_stopped(self):
+ """Order matters: stopping first leaves a window to re-activate in."""
+ calls = [line for line in run(battery=True).stdout.splitlines()
+ if line.startswith("SYSTEMCTL:") and "power-profiles-daemon" in line]
+ verbs = [line.split()[1] for line in calls]
+ self.assertEqual(verbs, ["mask", "stop"])
+
+ def test_power_profiles_daemon_is_masked_not_merely_disabled(self):
+ """Disabling ppd is not enough — D-Bus activation ignores it.
+
+ This is the whole point of the mask, so assert the verb directly. A
+ `disable` here would pass a naive "ppd is handled" check while leaving
+ the panel's powerprofilesctl call free to start ppd and kill TLP.
+ """
+ r = run(battery=True)
+ ppd_calls = [line for line in r.stdout.splitlines()
+ if line.startswith("SYSTEMCTL:") and "power-profiles-daemon" in line]
+ self.assertTrue(ppd_calls, "configure_tlp_power must act on ppd at all")
+ for line in ppd_calls:
+ self.assertNotIn(" disable ", line,
+ "disable leaves D-Bus activation live; only mask blocks it")
+
+ def test_radio_line_is_active_not_commented(self):
+ r = run(battery=True)
+ conf = r.stdout.split("CONF:[")[1].split("]")[0]
+ for line in conf.splitlines():
+ if "DEVICES_TO_ENABLE_ON_STARTUP" in line:
+ self.assertFalse(line.lstrip().startswith("#"),
+ "the radio-enable line must not be commented out")
+ break
+ else:
+ self.fail("DEVICES_TO_ENABLE_ON_STARTUP line missing from conf")
+
+ def test_laptop_installs_the_resume_hook_where_systemd_sleep_scans(self):
+ """The hook goes to /usr/lib/systemd/system-sleep/, the only scanned dir.
+
+ Assert the full install list, not a substring: a second copy under
+ /etc/ would pass an assertIn and still be the dead hook this test
+ exists to catch.
+ """
+ r = run(battery=True)
+ installs = [line for line in r.stdout.splitlines()
+ if line.startswith("INSTALL_EXEC:")]
+ self.assertEqual(
+ installs,
+ ["INSTALL_EXEC: /src/scripts/zz-bluetooth-resume -> "
+ "/usr/lib/systemd/system-sleep/zz-bluetooth-resume"],
+ "systemd-sleep never scans /etc/systemd/system-sleep/; a hook "
+ "there installs cleanly and never runs")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_desktop_installs_no_resume_hook(self):
+ """No TLP on a desktop means no masked rfkill and no gap to fill."""
+ r = run(battery=False)
+ self.assertNotIn("INSTALL_EXEC:", r.stdout)
+
+ def test_desktop_without_battery_is_a_no_op(self):
+ r = run(battery=False)
+ self.assertNotIn("INSTALL:", r.stdout)
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertIn("CONF:[]", r.stdout, "no conf may be written on a desktop")
+
+ def test_second_battery_index_still_counts_as_laptop(self):
+ r = run(battery=True, bat_name="BAT1")
+ self.assertIn("INSTALL: tlp", r.stdout)
+ self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP', r.stdout)
+
+ def test_desktop_keeps_power_profiles_daemon(self):
+ """A batteryless machine must NOT get ppd masked.
+
+ There is no TLP on a desktop to conflict with it, and the package-install
+ site enables ppd precisely so the settings panel's three-way power
+ control works there. Masking it here would break that control for no gain.
+ """
+ r = run(battery=False)
+ self.assertNotIn("power-profiles-daemon", r.stdout)
+
+ # ------------------------------------------------------------- error ----
+ def test_failed_ppd_mask_warns_and_does_not_crash(self):
+ """A masking failure must surface, not pass silently.
+
+ Silence is the exact failure mode being fixed: velox looked configured
+ while TLP was dead. If the mask cannot be applied, say so.
+
+ Assert on the harness's own RC= line, not on r.returncode. The harness
+ script ends in a literal `exit 0`, so r.returncode is 0 no matter what
+ configure_tlp_power does — asserting it can never fail, which would make
+ this test the same silent no-op it exists to catch.
+ """
+ r = run(battery=True, systemctl_fails=True)
+ self.assertIn("WARN: masking power-profiles-daemon for TLP", r.stdout)
+ self.assertIn("WARN: stopping power-profiles-daemon for TLP", r.stdout)
+ self.assertIn("RC=", r.stdout,
+ "the function must return so the install continues, "
+ "not exit and take the script down with it")
+
+ def test_failed_hook_install_does_not_stop_the_ppd_mask(self):
+ """install_executable warns for itself, so the step must carry on.
+
+ The mask that follows is what keeps TLP alive; a hook copy that fails
+ (source missing on a partial checkout) must not take it with it.
+ """
+ r = run(battery=True, install_fails=True)
+ self.assertIn("INSTALL_EXEC:", r.stdout)
+ self.assertIn("SYSTEMCTL: mask power-profiles-daemon.service", r.stdout)
+ self.assertIn("RC=", r.stdout)
+
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_tlpd_warns_and_does_not_crash(self):
+ r = run(battery=True, unwritable_tlpd=True)
+ self.assertIn("WARN:", r.stdout,
+ "a failed conf write must surface through error_warn")
+ self.assertEqual(r.returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_tunnel_dns_over_tls.py b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py
new file mode 100644
index 0000000..89677d8
--- /dev/null
+++ b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py
@@ -0,0 +1,147 @@
+"""Test configure_tunnel_dns_over_tls — per-link DoT off for VPN tunnels.
+
+The resolved drop-in pins DNSOverTLS=yes globally. Proton VPN (proton0) and
+the static Proton WireGuard profiles (wgpvpn) push an in-tunnel resolver,
+10.2.0.1, that answers plain port 53 and never completes TLS on 853, so
+every lookup through the tunnel hangs. The Proton client recreates its NM
+profile on each connect, so a per-profile setting can't stick; a
+NetworkManager [connection-*] default matched on the interface names is
+what NM pushes to resolved on every activation. Diagnosed 2026-09-09/10,
+verified live on ratio and velox.
+
+Method: sed-extract configure_tunnel_dns_over_tls from the real
+`archsetup`, point it at a temp conf.d, and assert on the file it writes.
+
+ python3 -m unittest tests.installer-steps.test_configure_tunnel_dns_over_tls
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+FILENAME = "tunnel-dns-over-tls.conf"
+
+
+def run(confdir):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^configure_tunnel_dns_over_tls() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_tunnel_dns_over_tls "{confdir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+def body(confdir):
+ with open(os.path.join(confdir, FILENAME)) as f:
+ return f.read()
+
+
+def directives(text):
+ """The non-comment, non-blank lines — what NM actually parses."""
+ return [l.strip() for l in text.splitlines()
+ if l.strip() and not l.lstrip().startswith("#")]
+
+
+class ConfigureTunnelDnsOverTls(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_a_connection_default_that_turns_dot_off(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ lines = directives(body(d))
+ self.assertIn("[connection-tunnel-dot]", lines)
+ self.assertIn("connection.dns-over-tls=0", lines)
+
+ def test_matches_both_proton_interfaces_and_nothing_else(self):
+ # proton0 is the Proton client; wgpvpn is the static WireGuard
+ # profiles. A match-device line without both leaves one tunnel
+ # broken; one with a wildcard would turn DoT off for wifi too.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ match = [l for l in directives(body(d)) if l.startswith("match-device=")]
+ self.assertEqual(len(match), 1)
+ devices = match[0].split("=", 1)[1].split(",")
+ self.assertEqual(sorted(devices),
+ ["interface-name:proton0", "interface-name:wgpvpn"])
+
+ def test_explains_why_the_global_setting_is_overridden_per_link(self):
+ # The file contradicts the resolved drop-in next to it, so it has
+ # to carry the reason: the in-tunnel resolver that never completes
+ # TLS, and why the Proton client can't hold a per-profile setting.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ text = body(d).lower()
+ self.assertIn("10.2.0.1", text)
+ self.assertIn("853", text)
+ self.assertIn("recreates", text)
+
+ def test_drop_in_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(os.stat(os.path.join(d, FILENAME)).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_identical_file(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ first = body(d)
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ self.assertEqual(first, body(d))
+ self.assertEqual(os.listdir(d), [FILENAME])
+
+ def test_absent_directory_is_created(self):
+ with tempfile.TemporaryDirectory() as d:
+ nested = os.path.join(d, "etc", "NetworkManager", "conf.d")
+ r = run(nested)
+ self.assertEqual(rc_of(r), 0)
+ self.assertIn("connection.dns-over-tls=0", directives(body(nested)))
+
+ def test_leaves_sibling_drop_ins_alone(self):
+ # dns.conf, wifi-privacy.conf and wifi-powersave-off.conf share the
+ # directory; this step must add a file, never rewrite the dir.
+ with tempfile.TemporaryDirectory() as d:
+ sibling = os.path.join(d, "dns.conf")
+ with open(sibling, "w") as f:
+ f.write("[main]\ndns=systemd-resolved\n")
+ run(d)
+ with open(sibling) as f:
+ self.assertEqual(f.read(), "[main]\ndns=systemd-resolved\n")
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_directory_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ confdir = os.path.join(d, "ro")
+ os.mkdir(confdir)
+ os.chmod(confdir, 0o500)
+ try:
+ r = run(confdir)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(confdir, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_dotfiles_dependency_packages.py b/tests/installer-steps/test_dotfiles_dependency_packages.py
new file mode 100644
index 0000000..58b7275
--- /dev/null
+++ b/tests/installer-steps/test_dotfiles_dependency_packages.py
@@ -0,0 +1,119 @@
+"""Pin the packages the dotfiles assume are present.
+
+Three packages are not optional extras: something the dotfiles ship depends
+on each one, and when the package is missing the dependent silently does the
+wrong thing rather than failing.
+
+- libreoffice-fresh :: common/.config/mimeapps.list maps presentations,
+ documents and spreadsheets to libreoffice-impress/-writer/-calc. With the
+ package absent those .desktop files don't exist, so xdg-mime falls through
+ to the next application claiming the type — on a machine with the winvm
+ dotfiles that is powerpoint.desktop, which boots a Windows VM to open a
+ deck (2026-09-18).
+- imv :: gui-open --image execs imv, and without it every agent-side image
+ render fails with "required application is unavailable" (2026-09-18).
+- git-lfs :: a repo tracking globs in LFS fails every checkout and merge
+ with "smudge filter lfs failed" (2026-09-20).
+
+All three were installed before the 2026-08-13 rebuild and absent after it,
+which is the regression this pins: they are dependencies of shipped defaults,
+so the installer has to declare them rather than leave them to whatever a
+machine happens to carry.
+
+Method mirrors test_required_software: sed-extract supplemental_software from
+the real `archsetup`, stub pacman_install as a recorder, run it, and assert
+against what it actually invoked. Running the function beats matching its
+source text, because the property under test is "every machine installs this"
+and only a run resolves the conditionals that could make that false. The
+function is run once per desktop environment for the same reason.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_dotfiles_dependency_packages
+"""
+
+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")
+
+DEPENDENCY_PACKAGES = ("libreoffice-fresh", "imv", "git-lfs")
+
+# Every desktop environment the installer branches on inside this function.
+DESKTOP_ENVS = ("dwm", "hyprland")
+
+
+def declared_packages(desktop_env):
+ """Return (exit_code, [pacman package, ...]) for one desktop environment.
+
+ Everything the function calls besides pacman_install is stubbed to a no-op,
+ so the run records package declarations and nothing else. aur_install is
+ deliberately separate: these three are pacman packages, and folding the two
+ recorders together would let an AUR declaration satisfy the pin.
+ """
+ script = textwrap.dedent(f"""\
+ desktop_env={desktop_env}
+ display() {{ :; }}
+ aur_install() {{ :; }}
+ mask_fwupd_passim() {{ :; }}
+ run_task() {{ :; }}
+ error_warn() {{ :; }}
+ pacman_install() {{ echo "$1"; }}
+ source <(sed -n '/^supplemental_software() {{/,/^}}/p' "{ARCHSETUP}")
+ supplemental_software
+ """)
+ result = subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=30,
+ )
+ return result.returncode, result.stdout.split()
+
+
+class DotfilesDependencyPackages(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_each_dependency_package_is_installed(self):
+ rc, pkgs = declared_packages("hyprland")
+ self.assertEqual(rc, 0)
+ for package in DEPENDENCY_PACKAGES:
+ with self.subTest(package=package):
+ self.assertIn(
+ package, pkgs,
+ f"{package} is a dependency of a shipped dotfiles default "
+ "and must be declared",
+ )
+
+ # ---------------------------------------------------------- boundary ----
+ def test_each_is_declared_exactly_once(self):
+ # A second declaration is dead weight and drifts out of sync with the
+ # first when one of them is edited.
+ rc, pkgs = declared_packages("hyprland")
+ self.assertEqual(rc, 0)
+ for package in DEPENDENCY_PACKAGES:
+ with self.subTest(package=package):
+ self.assertEqual(pkgs.count(package), 1)
+
+ # ------------------------------------------------------------- error ----
+ def test_none_is_gated_behind_a_desktop_environment(self):
+ # The dotfiles defaults that need these apply on every DE, so a
+ # declaration reachable under only one of them would leave the same
+ # hole on the other.
+ for env in DESKTOP_ENVS:
+ rc, pkgs = declared_packages(env)
+ self.assertEqual(rc, 0)
+ for package in DEPENDENCY_PACKAGES:
+ with self.subTest(desktop_env=env, package=package):
+ self.assertIn(package, pkgs)
+
+ def test_harness_observes_desktop_environment_gating(self):
+ # The control for the test above: ranger IS gated to dwm on purpose, so
+ # if this run can't see that, the DE-independence assertion is vacuous
+ # and would pass against a gated package too.
+ _, dwm = declared_packages("dwm")
+ _, hyprland = declared_packages("hyprland")
+ self.assertIn("ranger", dwm)
+ self.assertNotIn("ranger", hyprland)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_idempotency_cluster.py b/tests/installer-steps/test_idempotency_cluster.py
index ecb279d..0cca750 100644
--- a/tests/installer-steps/test_idempotency_cluster.py
+++ b/tests/installer-steps/test_idempotency_cluster.py
@@ -114,6 +114,33 @@ class EnableUserService(unittest.TestCase):
out = run("enable_user_service", one).stdout
self.assertIn("RC=0", out)
+ def test_socket_unit_lands_in_sockets_target_wants(self):
+ # A socket unit's [Install] is WantedBy=sockets.target, so enabling it
+ # via default.target.wants would never socket-activate. The optional
+ # fifth arg names the wants target.
+ with tempfile.TemporaryDirectory() as home:
+ body = (
+ f'enable_user_service {ME!r} podman.socket '
+ f'/usr/lib/systemd/user/podman.socket {home!r} sockets.target\n'
+ f'link="{home}/.config/systemd/user/sockets.target.wants/podman.socket"\n'
+ f'[ -L "$link" ] && echo "LINK=yes" || echo "LINK=no"\n'
+ f'echo "TARGET=$(readlink "$link")"'
+ )
+ out = run("enable_user_service", body).stdout
+ self.assertIn("LINK=yes", out)
+ self.assertIn("TARGET=/usr/lib/systemd/user/podman.socket", out)
+
+ def test_omitted_target_still_defaults_to_default_target(self):
+ with tempfile.TemporaryDirectory() as home:
+ body = (
+ f'enable_user_service {ME!r} gamemoded.service '
+ f'/usr/lib/systemd/user/gamemoded.service {home!r}\n'
+ f'[ -L "{home}/.config/systemd/user/default.target.wants/gamemoded.service" ] '
+ f'&& echo "DEFAULT=yes" || echo "DEFAULT=no"'
+ )
+ out = run("enable_user_service", body).stdout
+ self.assertIn("DEFAULT=yes", out)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/installer-steps/test_install_camera_passthrough_rules.py b/tests/installer-steps/test_install_camera_passthrough_rules.py
new file mode 100644
index 0000000..ab28575
--- /dev/null
+++ b/tests/installer-steps/test_install_camera_passthrough_rules.py
@@ -0,0 +1,118 @@
+"""Test install_camera_passthrough_rules — the VM camera udev grant.
+
+usbredirect must open a camera's raw USB node read-write to claim it for the
+Windows VM; the node defaults to root-owned with no group write, so the
+attach fails with a bare "Failed to open device!". The rule grants
+GROUP="video", MODE="0660" (the verified fix) and keeps TAG+="uaccess".
+
+The file NUMBER is load-bearing (winvm correction, 2026-08-08): the uaccess
+ACL is applied by 73-seat-late.rules, so a 99- file adds the tag after that
+already ran. The shipped filename must sort below 73.
+
+Method: sed-extract install_camera_passthrough_rules from the real
+`archsetup`, point it at a temp rules path, fake display / error_warn.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_camera_passthrough_rules
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+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(rules_path, pre=""):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^install_camera_passthrough_rules() {{/,/^}}/p' "{ARCHSETUP}")
+ {pre}
+ install_camera_passthrough_rules "{rules_path}"
+ echo "RC=$?"
+ echo "RULES:[$(cat "{rules_path}" 2>/dev/null)]"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rules_body(r):
+ return r.stdout.split("RULES:[")[1].split("]")[0]
+
+
+class InstallCameraPassthroughRules(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_both_camera_rules_with_group_mode_and_tag(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(os.path.join(d, "72-test.rules"))
+ body = rules_body(r)
+ for vendor, product in (("3564", "ff02"), ("046d", "085e")):
+ line = next((ln for ln in body.splitlines()
+ if f'ATTR{{idVendor}}=="{vendor}"' in ln), None)
+ assert line is not None, f"no rule line for {vendor}:{product}"
+ self.assertIn(f'ATTR{{idProduct}}=="{product}"', line)
+ self.assertIn('GROUP="video"', line)
+ self.assertIn('MODE="0660"', line)
+ self.assertIn('TAG+="uaccess"', line)
+ self.assertIn("RC=0", r.stdout)
+
+ def test_default_filename_sorts_below_seat_late(self):
+ # The rule file's default install path must sort before
+ # 73-seat-late.rules or the uaccess tag lands too late to be ACLed.
+ with open(ARCHSETUP) as f:
+ src = f.read()
+ func = re.search(
+ r'^install_camera_passthrough_rules\(\)\s*{.*?^}', src, re.S | re.M)
+ assert func is not None, "function not found in archsetup"
+ m = re.search(r'\$\{1:-(/etc/udev/rules\.d/[^}]+)\}', func.group(0))
+ assert m is not None, "default rules path not found in the function"
+ basename = os.path.basename(m.group(1))
+ self.assertLess(basename, "73-seat-late.rules",
+ "the rules file must sort below 73-seat-late.rules")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_rerun_is_idempotent(self):
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "72-test.rules")
+ first = run(path)
+ body_one = rules_body(first)
+ second = run(path)
+ self.assertEqual(body_one, rules_body(second))
+ self.assertIn("RC=0", second.stdout)
+
+ def test_overwrites_a_stale_existing_file(self):
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "72-test.rules")
+ with open(path, "w") as f:
+ f.write("# stale content that must not survive\n")
+ r = run(path)
+ self.assertNotIn("stale content", rules_body(r))
+ self.assertIn('GROUP="video"', rules_body(r))
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_dir_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ locked = os.path.join(d, "locked")
+ os.makedirs(locked)
+ os.chmod(locked, stat.S_IRUSR | stat.S_IXUSR)
+ r = run(os.path.join(locked, "72-test.rules"))
+ os.chmod(locked, stat.S_IRWXU)
+ self.assertIn("WARN:", r.stdout)
+ self.assertIn("RC=", r.stdout,
+ "the harness must reach its RC line — the function "
+ "returned rather than aborting the script")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_install_cpu_microcode.py b/tests/installer-steps/test_install_cpu_microcode.py
new file mode 100644
index 0000000..20f1820
--- /dev/null
+++ b/tests/installer-steps/test_install_cpu_microcode.py
@@ -0,0 +1,110 @@
+"""Test install_cpu_microcode — vendor-detected microcode package install.
+
+archsetup never installed intel-ucode/amd-ucode (found on velox 2026-04-10:
+CPU running old microcode, hand-fixed). The step reads vendor_id from
+/proc/cpuinfo and installs the matching package. It must run before
+configure_grub in boot_ux: grub-mkconfig detects /boot/<vendor>-ucode.img for
+its initrd lines, and mkinitcpio's microcode hook embeds it, so the package
+has to exist before either generates.
+
+Method: sed-extract install_cpu_microcode from the real `archsetup`, point it
+at a fixture cpuinfo, fake pacman_install / display / error_warn.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_cpu_microcode
+"""
+
+import os
+import re
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+INTEL = "vendor_id\t: GenuineIntel\n"
+AMD = "vendor_id\t: AuthenticAMD\n"
+
+
+def run(cpuinfo_body, missing=False):
+ with tempfile.TemporaryDirectory() as d:
+ cpuinfo = os.path.join(d, "cpuinfo")
+ if not missing:
+ with open(cpuinfo, "w") as f:
+ f.write(cpuinfo_body)
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ pacman_install() {{ echo "INSTALL: $1"; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^install_cpu_microcode() {{/,/^}}/p' "{ARCHSETUP}")
+ install_cpu_microcode "{cpuinfo}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class InstallCpuMicrocode(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_intel_vendor_installs_intel_ucode(self):
+ r = run(f"processor\t: 0\n{INTEL}model name\t: whatever\n")
+ self.assertIn("INSTALL: intel-ucode", r.stdout)
+ self.assertNotIn("amd-ucode", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_amd_vendor_installs_amd_ucode(self):
+ r = run(f"processor\t: 0\n{AMD}")
+ self.assertIn("INSTALL: amd-ucode", r.stdout)
+ self.assertNotIn("intel-ucode", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_multicore_cpuinfo_installs_exactly_once(self):
+ # /proc/cpuinfo repeats vendor_id per logical CPU.
+ body = "".join(f"processor\t: {i}\n{AMD}\n" for i in range(16))
+ r = run(body)
+ self.assertEqual(r.stdout.count("INSTALL:"), 1,
+ "one package install, not one per core")
+
+ def test_space_separated_vendor_line_parses(self):
+ # Some kernels/arches pad with spaces rather than a tab.
+ r = run("vendor_id : GenuineIntel\n")
+ self.assertIn("INSTALL: intel-ucode", r.stdout)
+
+ def test_vendor_id_substring_elsewhere_does_not_confuse(self):
+ # A flags line or model name mentioning a vendor string must not win
+ # over the real vendor_id line.
+ body = "model name\t: AuthenticAMD emulator\n" + INTEL
+ r = run(body)
+ self.assertIn("INSTALL: intel-ucode", r.stdout)
+ self.assertNotIn("amd-ucode", r.stdout)
+
+ # ------------------------------------------------------------- error ----
+ def test_unknown_vendor_warns_and_installs_nothing(self):
+ r = run("vendor_id\t: CentaurHauls\n")
+ self.assertNotIn("INSTALL:", r.stdout)
+ self.assertIn("WARN:", r.stdout)
+ self.assertEqual(rc_of(r), 1)
+
+ def test_missing_cpuinfo_warns_and_installs_nothing(self):
+ r = run("", missing=True)
+ self.assertNotIn("INSTALL:", r.stdout)
+ self.assertIn("WARN:", r.stdout)
+ self.assertEqual(rc_of(r), 1)
+ self.assertEqual(r.returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_install_gnome_keyring_fix.py b/tests/installer-steps/test_install_gnome_keyring_fix.py
new file mode 100644
index 0000000..1a4115a
--- /dev/null
+++ b/tests/installer-steps/test_install_gnome_keyring_fix.py
@@ -0,0 +1,209 @@
+"""Tests for install_gnome_keyring_fix in the archsetup installer.
+
+gnome-keyring 51.1 (and 48 through 50) aborts when a Secret Service client's
+first OpenSession is dispatched before the daemon has created its per-client
+record. Every abort loses the unlocked state, so the next request pops the
+keyring password dialog; on ratio this fired three times a day once the
+ChatGPT desktop app's codex app-server started bursting keyring reads. The
+fix is upstream merge request !112 plus an alias-lifetime correction, carried
+as a patched rebuild of Arch's own package under packages/gnome-keyring in
+this repo (pkgrel 1.1). install_gnome_keyring_fix builds and installs it with
+makepkg as the user when the stock 1:51.1-1 is what is installed.
+
+Versioning is the whole contract. The rebuild sorts above stock 1:51.1-1 and
+below any future 1:51.1-2 or 51.2, so a normal pacman upgrade replaces it the
+moment Arch or upstream ships a fix, and the step must never pin or hold the
+package. When something newer than the rebuild is already installed, the fix
+is assumed upstream and the step skips with a warning so the assumption is
+visible rather than silent. When the rebuild itself is installed, the step is
+a no-op.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time. pacman, vercmp, makepkg and sudo are replaced by
+recorders driven by environment switches; the package directory is a temp
+copy of the real packages/gnome-keyring tree.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_gnome_keyring_fix
+"""
+
+import os
+import re
+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")
+PACKAGE_DIR = os.path.join(REPO_ROOT, "packages", "gnome-keyring")
+
+STUBS = r"""
+logfile=/dev/null
+action=""
+username=tester
+display() { echo "DISPLAY: $*"; }
+pacman_install() { echo "PACMAN_INSTALL: $1" >> "$MARKER"; }
+error_warn() { echo "WARN:$1"; return 1; }
+# pacman -Q prints the installed version, or fails when INSTALLED is empty.
+pacman() {
+ echo "PACMAN: $*" >> "$MARKER"
+ case "$1" in
+ -Q) [ -n "$INSTALLED" ] && echo "gnome-keyring $INSTALLED" ;;
+ *) return 0 ;;
+ esac
+}
+# The real vercmp is used so the ordering under test is pacman's own.
+makepkg() {
+ echo "MAKEPKG: $* (pwd=$PWD)" >> "$MARKER"
+ [ "$MAKEPKG_FAILS" = 1 ] && return 2
+ return 0
+}
+# sudo -u user cmd... runs cmd in place; the recorder notes the user.
+sudo() {
+ echo "SUDO: $*" >> "$MARKER"
+ shift 2
+ "$@"
+}
+safe_rm_rf() { rm -rf "$1"; }
+# chown needs root; the tests only care that the copy was staged.
+chown() { echo "CHOWN: $*" >> "$MARKER"; }
+"""
+
+
+def run(pkg_dir, installed, makepkg_fails=False, source_dir=None):
+ marker = tempfile.NamedTemporaryFile(prefix="keyring-fix-", delete=False)
+ marker.close()
+ source_dir = source_dir or tempfile.mkdtemp(prefix="keyring-src-")
+ script = (
+ STUBS
+ + "source <(sed -n '/^install_gnome_keyring_fix() {/,/^}/p' \"%s\")\n"
+ + 'user_archsetup_dir="%s"\n'
+ + 'source_dir="%s"\n'
+ + "install_gnome_keyring_fix\n"
+ + 'echo "RC=$?"\n'
+ ) % (ARCHSETUP, pkg_dir, source_dir)
+ env = dict(os.environ, MARKER=marker.name, INSTALLED=installed,
+ MAKEPKG_FAILS="1" if makepkg_fails else "0")
+ result = subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=30, env=env)
+ with open(marker.name) as f:
+ calls = f.read().splitlines()
+ os.remove(marker.name)
+ return result, calls, source_dir
+
+
+class InstallGnomeKeyringFix(unittest.TestCase):
+ def setUp(self):
+ # A fake archsetup checkout holding a copy of the real package tree.
+ self.checkout = tempfile.mkdtemp(prefix="archsetup-checkout-")
+ self.addCleanup(shutil.rmtree, self.checkout, ignore_errors=True)
+ shutil.copytree(PACKAGE_DIR, os.path.join(self.checkout, "packages", "gnome-keyring"))
+ self.source_dir = tempfile.mkdtemp(prefix="keyring-src-")
+ self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True)
+
+ def _run(self, installed, **kw):
+ return run(self.checkout, installed, source_dir=self.source_dir, **kw)
+
+ @staticmethod
+ def _makepkg_calls(calls):
+ return [c for c in calls if c.startswith("MAKEPKG:")]
+
+ # ---------------------------------------------------------- normal ----
+ def test_stock_51_1_gets_the_rebuild(self):
+ result, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1, calls)
+ self.assertIn("RC=0", result.stdout)
+ self.assertNotIn("WARN:", result.stdout)
+
+ def test_builds_as_the_user_with_syncdeps_and_install(self):
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertTrue(any(c.startswith("SUDO: -u tester makepkg") for c in calls), calls)
+ makepkg = self._makepkg_calls(calls)[0]
+ self.assertIn("--noconfirm", makepkg)
+ # -s (sync makedepends) and -i (install), spelled separately or combined.
+ short = "".join(t[1:] for t in makepkg.split() if re.fullmatch(r"-[a-zA-Z]+", t))
+ self.assertIn("s", short, makepkg)
+ self.assertIn("i", short, makepkg)
+
+ def test_builds_from_a_user_owned_copy_not_the_checkout(self):
+ # makepkg writes into its directory; the checkout must stay clean.
+ _, calls, _ = self._run("1:51.1-1")
+ makepkg = self._makepkg_calls(calls)[0]
+ self.assertIn("pwd=" + self.source_dir, makepkg)
+ self.assertNotIn("pwd=" + self.checkout, makepkg)
+ self.assertTrue(os.path.exists(os.path.join(self.source_dir, "gnome-keyring", "PKGBUILD")))
+
+ def test_older_stock_releases_also_get_the_rebuild(self):
+ # 50.0 carries the same bug; the rebuild is 51.1 so it is an upgrade.
+ _, calls, _ = self._run("1:50.0-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+
+ # -------------------------------------------------------- boundary ----
+ def test_rebuild_already_installed_is_a_noop(self):
+ result, calls, _ = self._run("1:51.1-1.1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+ self.assertIn("RC=0", result.stdout)
+ self.assertNotIn("WARN:", result.stdout)
+
+ def test_newer_package_skips_visibly_without_counting_an_error(self):
+ for newer in ("1:51.1-2", "1:51.2-1", "1:52.0-1"):
+ with self.subTest(installed=newer):
+ result, calls, _ = self._run(newer)
+ self.assertEqual(self._makepkg_calls(calls), [], newer)
+ self.assertIn("newer than the rebuild", result.stdout,
+ "a newer package means the fix is assumed upstream; say so")
+ self.assertNotIn("WARN:", result.stdout,
+ "a deliberate skip must not count as an installer error")
+ self.assertIn("RC=0", result.stdout)
+
+ def test_rerun_after_success_does_not_rebuild(self):
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+ _, calls, _ = self._run("1:51.1-1.1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+
+ def test_missing_package_installs_stock_first_then_builds(self):
+ # With no gnome-keyring present, stock is installed as the baseline
+ # and the rebuild then upgrades it; both happen in that order.
+ result, calls, _ = self._run("")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+ stock = [i for i, c in enumerate(calls) if c.startswith("PACMAN_INSTALL: gnome-keyring")]
+ build = [i for i, c in enumerate(calls) if c.startswith("MAKEPKG:")]
+ self.assertEqual(len(stock), 1, calls)
+ self.assertLess(stock[0], build[0], "stock install must precede the build")
+ self.assertIn("RC=0", result.stdout)
+
+ def test_rerun_with_rebuild_installed_never_touches_stock(self):
+ # pacman's --needed only skips an equal version, so a stock install
+ # ahead of the rebuild would downgrade it on every re-run. The step
+ # must not call the stock install at all once anything is installed.
+ for installed in ("1:51.1-1.1", "1:51.1-1", "1:51.2-1"):
+ with self.subTest(installed=installed):
+ _, calls, _ = self._run(installed)
+ self.assertFalse(any(c.startswith("PACMAN_INSTALL:") for c in calls), calls)
+
+ # ----------------------------------------------------------- error ----
+ def test_failed_build_warns_and_leaves_stock_in_place(self):
+ result, calls, _ = self._run("1:51.1-1", makepkg_fails=True)
+ self.assertIn("WARN:", result.stdout)
+ self.assertNotIn("RC=0", result.stdout)
+ self.assertFalse(any(c.startswith("PACMAN: -U") for c in calls),
+ "nothing may be installed by hand after a failed build")
+
+ def test_missing_recipe_warns_instead_of_building(self):
+ shutil.rmtree(os.path.join(self.checkout, "packages", "gnome-keyring"))
+ result, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+ self.assertIn("WARN:", result.stdout)
+
+ def test_never_pins_or_holds_the_package(self):
+ # No IgnorePkg edit and no pacman -D hold: the next upstream fix must
+ # replace the rebuild on a normal upgrade.
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertFalse(any("IgnorePkg" in c for c in calls))
+ self.assertFalse(any(c.startswith("PACMAN: -D") for c in calls))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_mask_fwupd_passim.py b/tests/installer-steps/test_mask_fwupd_passim.py
new file mode 100644
index 0000000..8977504
--- /dev/null
+++ b/tests/installer-steps/test_mask_fwupd_passim.py
@@ -0,0 +1,79 @@
+"""Test mask_fwupd_passim — keep fwupd's LAN metadata daemon off.
+
+fwupd pulls in passim, a daemon that shares firmware metadata with other
+machines on the LAN by listening publicly on 0.0.0.0:27500. Any fwupdmgr
+run D-Bus-activates it, and because the unit is static (no [Install]
+section) `systemctl disable` is a no-op: it came back on velox the next
+time fwupdmgr ran (2026-09-12). Masking is what holds, and it is how
+ratio has carried it since 2026-07-21.
+
+Method: sed-extract mask_fwupd_passim from the real `archsetup`; fake
+run_task (minus error_warn, so the failure test sees the function's own
+return path) and systemctl, and assert on the call.
+
+ python3 -m unittest tests.installer-steps.test_mask_fwupd_passim
+"""
+
+import os
+import re
+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(systemctl_body='echo "SYSTEMCTL: $*";'):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ run_task() {{ echo "TASK: $1"; shift; "$@"; }}
+ systemctl() {{ {systemctl_body} }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^mask_fwupd_passim() {{/,/^}}/p' "{ARCHSETUP}")
+ mask_fwupd_passim
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class MaskFwupdPassim(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_masks_the_passim_unit(self):
+ r = run()
+ self.assertIn("SYSTEMCTL: mask passim.service", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_masks_rather_than_disables(self):
+ # disable is a no-op on a static unit and is the mistake this step
+ # exists to avoid; a mask is the only call that sticks.
+ r = run()
+ calls = [l for l in r.stdout.splitlines() if l.startswith("SYSTEMCTL:")]
+ self.assertEqual(calls, ["SYSTEMCTL: mask passim.service"])
+
+ # ---------------------------------------------------------- boundary ----
+ # Re-running is idempotent because `systemctl mask` on an already-masked
+ # unit exits 0 and changes nothing; that property lives in systemctl, so
+ # there is no stateless test here that could tell it apart from a pass.
+
+ # ------------------------------------------------------------- error ----
+ def test_failed_mask_reports_nonzero(self):
+ # The fake run_task returns the command's status without the real
+ # error_warn wiring, so this checks the function's own return path.
+ r = run(systemctl_body='echo "SYSTEMCTL: $*"; return 1;')
+ self.assertNotEqual(rc_of(r), 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_mask_tmp_mount_for_zfs.py b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py
new file mode 100644
index 0000000..f03086f
--- /dev/null
+++ b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py
@@ -0,0 +1,109 @@
+"""Test mask_tmp_mount_for_zfs — the tmpfs-over-ZFS /tmp guard.
+
+When the pool carries a /tmp dataset, systemd's tmp.mount (tmpfs) races it at
+boot; when tmpfs wins, the dataset is shadowed and systemd-tmpfiles-clean
+fails repeatedly with "Protocol driver not attached" (velox, 2026-04-10). The
+fix is masking tmp.mount so the dataset owns /tmp — but only when such a
+dataset actually exists, and never on a machine without zfs at all.
+
+Method: sed-extract mask_tmp_mount_for_zfs from the real `archsetup`; fake
+zfs / run_task / systemctl. The zfs-binary-absent case runs with a stripped
+PATH containing only the tools the function itself needs.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_mask_tmp_mount_for_zfs
+"""
+
+import os
+import re
+import subprocess
+import tempfile
+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(zfs_body=None, strip_zfs_from_path=False):
+ """zfs_body: bash body for a fake zfs function, or None for no fake.
+ strip_zfs_from_path: run with a minimal PATH that has grep/sed but no zfs.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ path_setup = ""
+ if strip_zfs_from_path:
+ fakebin = os.path.join(d, "bin")
+ os.makedirs(fakebin)
+ for tool in ("grep", "sed", "cat"):
+ src = subprocess.run(["bash", "-lc", f"command -v {tool}"],
+ capture_output=True, text=True).stdout.strip()
+ if src:
+ os.symlink(src, os.path.join(fakebin, tool))
+ path_setup = f'PATH="{fakebin}"'
+ zfs_fake = f"zfs() {{ {zfs_body} }}" if zfs_body is not None else ""
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ run_task() {{ echo "TASK: $1"; shift; "$@"; }}
+ systemctl() {{ echo "SYSTEMCTL: $*"; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ {zfs_fake}
+ source <(sed -n '/^mask_tmp_mount_for_zfs() {{/,/^}}/p' "{ARCHSETUP}")
+ {path_setup}
+ mask_tmp_mount_for_zfs
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class MaskTmpMountForZfs(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_tmp_dataset_present_masks_tmp_mount(self):
+ r = run(zfs_body='printf "/\\n/home\\n/tmp\\n";')
+ self.assertIn("SYSTEMCTL: mask tmp.mount", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_no_tmp_dataset_is_a_no_op(self):
+ r = run(zfs_body='printf "/\\n/home\\n/var\\n";')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_tmp_prefix_dataset_does_not_match(self):
+ # /tmp/scratch or /tmpfoo must not trigger the mask — exact match only.
+ r = run(zfs_body='printf "/\\n/tmp/scratch\\n/tmpfoo\\n";')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_legacy_and_none_mountpoints_do_not_match(self):
+ r = run(zfs_body='printf "legacy\\nnone\\n-\\n";')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ # ------------------------------------------------------------- error ----
+ def test_zfs_binary_absent_is_a_silent_no_op(self):
+ r = run(zfs_body=None, strip_zfs_from_path=True)
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertNotIn("WARN:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_zfs_list_failure_is_a_no_op_not_a_crash(self):
+ r = run(zfs_body='echo "no pools available" >&2; return 1;')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+ self.assertNotIn("no pools available", r.stdout,
+ "zfs stderr noise must not leak into output")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py
index a2b235b..c2eb710 100644
--- a/tests/installer-steps/test_orchestrators.py
+++ b/tests/installer-steps/test_orchestrators.py
@@ -28,6 +28,7 @@ ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
ORCHESTRATORS = {
"essential_services": [
"configure_randomness", "configure_networking", "configure_power",
+ "configure_hibernate_delay", "configure_backlight_access",
"configure_ssh_server", "configure_fail2ban", "configure_firewall",
"configure_service_discovery", "configure_job_scheduling",
"configure_package_cache", "configure_snapshots",
@@ -43,10 +44,10 @@ ORCHESTRATORS = {
"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",
+ "install_cpu_microcode", "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",
@@ -88,7 +89,8 @@ class OrchestratorSequence(unittest.TestCase):
class SnapshotDispatch(unittest.TestCase):
"""configure_snapshots branches on filesystem; pin each branch."""
- SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots"]
+ SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots",
+ "mask_tmp_mount_for_zfs"]
def test_zfs_root_runs_zfs_snapshots(self):
result = run_orchestrator(
@@ -96,7 +98,8 @@ class SnapshotDispatch(unittest.TestCase):
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"])
+ self.assertEqual(result.stdout.split(),
+ ["configure_zfs_snapshots", "mask_tmp_mount_for_zfs"])
def test_btrfs_root_runs_btrfs_snapshots(self):
result = run_orchestrator(