"""Tests for the hypr-live-update-guard pacman PreTransaction hook script. The guard aborts a live pacman upgrade of GPU/compositor runtime libraries (mesa, hyprland, wayland, GPU drivers) while a Hyprland session is running, so the compositor doesn't SIGABRT when a now-"(deleted)" library is next called. It reads the triggering package names on stdin (pacman NeedsTargets) and exits non-zero to abort the transaction (AbortOnFail) before any package is swapped. When Hyprland isn't running, or an override is set, it exits 0 and the upgrade proceeds. Test seams (env vars the production script honors): HYPR_GUARD_RUNNING 1/0 forces the Hyprland-running check (default: pgrep) HYPR_ALLOW_LIVE_UPDATE 1 overrides the guard (proceed anyway) HYPR_GUARD_SENTINEL path whose existence also overrides the guard HYPR_GUARD_VERSIONS "pkg installed candidate" lines replacing the pacman -Q / expac -S version lookups; when set, a package absent from the map reads as unknown (conservative block) Run from repo root: python3 -m unittest tests.hypr-live-update-guard.test_hypr_live_update_guard """ import os import subprocess import tempfile import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) GUARD = os.path.join(REPO_ROOT, "scripts", "hypr-live-update-guard") # Every package the legacy tests feed, mapped to a version CHANGE — those # tests describe real upgrades, and the map keeps them hermetic (no # pacman/expac calls against the test host). CHANGING_VERSIONS = "\n".join(( "mesa 25.1.0-1 26.0.0-1", "hyprland 0.55.3-1 0.55.4-1", "vulkan-radeon 25.1.0-1 26.0.0-1", )) def run_guard(stdin="mesa\n", running="1", allow=None, sentinel=None, versions=CHANGING_VERSIONS): env = dict(os.environ) env["HYPR_GUARD_RUNNING"] = running if allow is not None: env["HYPR_ALLOW_LIVE_UPDATE"] = allow # Point the sentinel at a path that does not exist unless a test sets one, # so the host's real /run state can't leak into the result. env["HYPR_GUARD_SENTINEL"] = sentinel if sentinel else "/nonexistent/guard-sentinel" env["HYPR_GUARD_VERSIONS"] = versions return subprocess.run( ["sh", GUARD], input=stdin, capture_output=True, text=True, timeout=10, env=env, ) class HyprLiveUpdateGuard(unittest.TestCase): # --- Normal cases --------------------------------------------------- def test_running_with_dangerous_pkg_aborts(self): r = run_guard(stdin="mesa\n", running="1") self.assertEqual(r.returncode, 1, r.stderr) def test_abort_message_names_the_package_and_tty_remedy(self): r = run_guard(stdin="mesa\n", running="1") self.assertIn("mesa", r.stderr) self.assertIn("TTY", r.stderr) def test_not_running_allows(self): r = run_guard(stdin="mesa\n", running="0") self.assertEqual(r.returncode, 0, r.stderr) def test_not_running_is_silent(self): r = run_guard(stdin="mesa\nhyprland\n", running="0") self.assertEqual(r.stderr.strip(), "") # --- Boundary cases ------------------------------------------------- def test_multiple_packages_all_listed(self): r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1") self.assertEqual(r.returncode, 1) for pkg in ("mesa", "hyprland", "vulkan-radeon"): self.assertIn(pkg, r.stderr) def test_running_with_empty_stdin_still_guards(self): # The hook only fires when dangerous targets exist, so an empty target # list shouldn't normally happen; if Hyprland is up, stay safe (abort). r = run_guard(stdin="", running="1") self.assertEqual(r.returncode, 1) # --- Version awareness ------------------------------------------------ def test_same_version_reinstall_allows(self): # a pure reinstall replaces identical bytes with identical bytes — # no live-swap hazard, the guard must let it through r = run_guard(stdin="hyprland\n", running="1", versions="hyprland 0.55.4-1 0.55.4-1") self.assertEqual(r.returncode, 0, r.stderr) self.assertEqual(r.stderr.strip(), "") def test_all_same_version_multi_pkg_allows(self): versions = "\n".join(("mesa 26.1.4-1 26.1.4-1", "hyprland 0.55.4-1 0.55.4-1", "vulkan-radeon 26.1.4-1 26.1.4-1")) r = run_guard(stdin="mesa\nhyprland\nvulkan-radeon\n", running="1", versions=versions) self.assertEqual(r.returncode, 0, r.stderr) def test_mixed_blocks_naming_only_the_version_changing(self): versions = "\n".join(("mesa 25.1.0-1 26.0.0-1", "hyprland 0.55.4-1 0.55.4-1")) r = run_guard(stdin="mesa\nhyprland\n", running="1", versions=versions) self.assertEqual(r.returncode, 1) self.assertIn("- mesa", r.stderr) # the prose mentions the Hyprland session, so assert on the # package-list line format, not the bare word self.assertNotIn("- hyprland", r.stderr) def test_unknown_versions_block_conservatively(self): # seam set but package absent from the map = the lookup failed # (AUR target, -U transaction, expac missing) — stay safe r = run_guard(stdin="mesa\n", running="1", versions="") self.assertEqual(r.returncode, 1) self.assertIn("- mesa", r.stderr) # --- Override / error cases ----------------------------------------- def test_env_override_proceeds_even_when_running(self): r = run_guard(stdin="mesa\n", running="1", allow="1") self.assertEqual(r.returncode, 0, r.stderr) def test_sentinel_file_override_proceeds(self): with tempfile.NamedTemporaryFile(prefix="guard-allow-") as f: r = run_guard(stdin="mesa\n", running="1", sentinel=f.name) self.assertEqual(r.returncode, 0, r.stderr) def test_sentinel_is_consumed_on_use(self): # one touch = one transaction: if the override's cleanup never runs # (a crashed caller), a leftover sentinel must not keep the guard # disarmed until reboot — the hook deletes it as it honors it fd, path = tempfile.mkstemp(prefix="guard-allow-") os.close(fd) try: r = run_guard(stdin="mesa\n", running="1", sentinel=path) self.assertEqual(r.returncode, 0, r.stderr) self.assertFalse(os.path.exists(path)) finally: if os.path.exists(path): os.unlink(path) def test_env_override_consumes_nothing(self): # the env override isn't a file; nothing to consume, still proceeds r = run_guard(stdin="mesa\n", running="1", allow="1") self.assertEqual(r.returncode, 0, r.stderr) def test_override_env_zero_does_not_bypass(self): r = run_guard(stdin="mesa\n", running="1", allow="0") self.assertEqual(r.returncode, 1, r.stderr) if __name__ == "__main__": unittest.main()