aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/bluetooth-resume/test_bluetooth_resume.py139
-rw-r--r--tests/post-rebuild-check/test_post_rebuild_check.py129
2 files changed, 265 insertions, 3 deletions
diff --git a/tests/bluetooth-resume/test_bluetooth_resume.py b/tests/bluetooth-resume/test_bluetooth_resume.py
new file mode 100644
index 0000000..6d8ed87
--- /dev/null
+++ b/tests/bluetooth-resume/test_bluetooth_resume.py
@@ -0,0 +1,139 @@
+"""Tests for scripts/zz-bluetooth-resume.
+
+Two things break bluetooth across a sleep cycle on a TLP laptop, and nothing
+else on the machine fixes either.
+
+The rfkill soft-block is not restored. systemd-rfkill would do it, but it is
+masked deliberately -- it fights TLP's radio handling, so TLP owns radios
+instead. TLP's own sleep hook runs `tlp resume`, and its setting is
+DEVICES_TO_ENABLE_ON_STARTUP: startup, not resume. There is no ON_RESUME in
+TLP's vocabulary, so the resume edge has no owner at all. WiFi survives only
+because NetworkManager unblocks itself; bluetooth has no equivalent.
+
+The controller also comes back wedged from a hibernate. It reports powered and
+unblocked while scanning finds nothing whatever -- zero devices where the same
+room gave seventeen a minute later. bluetoothd logs "Failed to set mode" and
+"Failed to add device <mac>" at the instant of resume. Reloading btusb clears
+it.
+
+Both observed on velox 2026-08-21, on its first suspend-then-hibernate cycle
+after hibernate was switched back on.
+
+The hook re-asserts TLP's own declared intent rather than inventing a policy,
+so a machine that deliberately blocks bluetooth keeps it blocked.
+
+Run from repo root:
+ python3 -m unittest tests.bluetooth-resume.test_bluetooth_resume
+"""
+
+import os
+import stat
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+HOOK = os.path.join(REPO_ROOT, "scripts", "zz-bluetooth-resume")
+
+TLP_WANTS_BT = 'DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"\n'
+TLP_WIFI_ONLY = 'DEVICES_TO_ENABLE_ON_STARTUP="wifi"\n'
+
+
+def run(phase="post", kind="suspend-then-hibernate", tlp_conf=TLP_WANTS_BT,
+ conf_present=True):
+ """Drive the hook with rfkill and modprobe faked, and read back the calls."""
+ with tempfile.TemporaryDirectory() as d:
+ calls = os.path.join(d, "calls.log")
+ bindir = os.path.join(d, "bin")
+ os.makedirs(bindir)
+ for tool in ("rfkill", "modprobe"):
+ p = os.path.join(bindir, tool)
+ with open(p, "w") as fh:
+ fh.write(f'#!/bin/sh\necho "{tool} $*" >> "{calls}"\nexit 0\n')
+ os.chmod(p, 0o755)
+ conf = os.path.join(d, "tlp.conf")
+ if conf_present:
+ with open(conf, "w") as fh:
+ fh.write(tlp_conf)
+ env = dict(os.environ)
+ env.update({
+ "BTR_RFKILL": os.path.join(bindir, "rfkill"),
+ "BTR_MODPROBE": os.path.join(bindir, "modprobe"),
+ "BTR_TLP_CONF": conf,
+ "BTR_TLP_CONF_DIR": os.path.join(d, "tlp.d"),
+ "BTR_SETTLE": "0",
+ })
+ r = subprocess.run(["sh", HOOK, phase, kind], env=env,
+ capture_output=True, text=True, timeout=20)
+ log = ""
+ if os.path.exists(calls):
+ with open(calls) as fh:
+ log = fh.read()
+ return r, log
+
+
+class BluetoothResume(unittest.TestCase):
+ # --- Normal ---------------------------------------------------------
+ def test_hibernate_reloads_the_driver_and_unblocks(self):
+ _, log = run(kind="suspend-then-hibernate")
+ self.assertIn("modprobe -r btusb", log)
+ self.assertIn("modprobe btusb", log)
+ self.assertIn("rfkill unblock bluetooth", log)
+
+ def test_the_unblock_comes_after_the_reload(self):
+ # A freshly loaded btusb can come up soft-blocked, so unblocking first
+ # would be undone by the reload that follows it.
+ _, log = run()
+ self.assertLess(log.index("modprobe btusb"),
+ log.index("rfkill unblock"))
+
+ def test_plain_suspend_unblocks_without_reloading(self):
+ # The wedge was seen coming out of hibernate, which reinitialises the
+ # controller from a saved image. A plain suspend restores USB intact,
+ # so reloading there would cost a working adapter for nothing.
+ _, log = run(kind="suspend")
+ self.assertIn("rfkill unblock bluetooth", log)
+ self.assertNotIn("btusb", log)
+
+ # --- Boundary -------------------------------------------------------
+ def test_the_pre_phase_does_nothing(self):
+ _, log = run(phase="pre")
+ self.assertEqual(log, "")
+
+ def test_a_tlp_policy_without_bluetooth_is_left_alone(self):
+ # The hook re-asserts TLP's stated intent. It must not invent one, or
+ # a machine that deliberately keeps bluetooth off gets it turned on at
+ # every wakeup.
+ _, log = run(tlp_conf=TLP_WIFI_ONLY)
+ self.assertEqual(log, "")
+
+ def test_a_commented_out_policy_does_not_count(self):
+ _, log = run(tlp_conf='#DEVICES_TO_ENABLE_ON_STARTUP="bluetooth"\n')
+ self.assertEqual(log, "")
+
+ def test_hibernate_proper_also_reloads(self):
+ _, log = run(kind="hibernate")
+ self.assertIn("modprobe -r btusb", log)
+
+ # --- Error ----------------------------------------------------------
+ def test_a_missing_tlp_config_is_left_alone(self):
+ # No declared policy means no intent to re-assert. Failing safe here
+ # means doing nothing, not guessing.
+ _, log = run(conf_present=False)
+ self.assertEqual(log, "")
+
+ def test_the_hook_always_exits_zero(self):
+ # systemd-sleep logs a failing hook and the noise outlives the cause.
+ # Nothing here is worth delaying or alarming a resume over.
+ for kind in ("suspend", "hibernate", "suspend-then-hibernate"):
+ with self.subTest(kind=kind):
+ r, _ = run(kind=kind)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_it_is_executable(self):
+ self.assertTrue(os.stat(HOOK).st_mode & stat.S_IXUSR,
+ "systemd-sleep only runs executables")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/post-rebuild-check/test_post_rebuild_check.py b/tests/post-rebuild-check/test_post_rebuild_check.py
index 9902299..bc887c6 100644
--- a/tests/post-rebuild-check/test_post_rebuild_check.py
+++ b/tests/post-rebuild-check/test_post_rebuild_check.py
@@ -31,6 +31,9 @@ probe"):
running; "MISSING" = not installed on this machine)
PRC_REPO_REMOTES newline list of "path<space>origin-url" for the
push-capability check ("" = no repos to check)
+ PRC_UNITS_EXPECTED_DISABLED
+ newline list of units whose not-enabled state is
+ deliberate on this machine ("" = no exemptions)
Run from repo root:
python3 -m unittest tests.post-rebuild-check.test_post_rebuild_check
@@ -51,7 +54,8 @@ CHECK = os.path.join(REPO_ROOT, "scripts", "post-rebuild-check")
def run_check(failed_units="", unit_states="", local_roots="",
project_roots="", signal_accounts="+15045551234",
ntp_sources="162.159.200.1\npool.ntp.org",
- idle_daemon="4242", repo_remotes=""):
+ idle_daemon="4242", repo_remotes="",
+ units_expected_disabled=""):
"""Run the script with every probe stubbed; defaults are all-clean.
Roots are newline-separated. Empty means "the seam is set and names no
@@ -67,6 +71,7 @@ def run_check(failed_units="", unit_states="", local_roots="",
env["PRC_NTP_SOURCES"] = ntp_sources
env["PRC_IDLE_DAEMON"] = idle_daemon
env["PRC_REPO_REMOTES"] = repo_remotes
+ env["PRC_UNITS_EXPECTED_DISABLED"] = units_expected_disabled
return subprocess.run(
["sh", CHECK], capture_output=True, text=True, timeout=30, env=env,
)
@@ -165,6 +170,7 @@ class NtpBootstrap(unittest.TestCase):
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
"PRC_IDLE_DAEMON": "4242",
"PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"PRC_CHRONY_CONF": chrony_conf})
env.pop("PRC_NTP_SOURCES", None)
return subprocess.run(["sh", CHECK], capture_output=True, text=True,
@@ -281,6 +287,100 @@ class IdleDaemon(unittest.TestCase):
run_check(idle_daemon=pids).stdout.lower())
+class UnitsExpectedDisabled(unittest.TestCase):
+ """Check 2 — units nothing intends to enable on this machine.
+
+ "Enabled" is the check's proxy for "will actually run", and the proxy is
+ wrong for a unit nobody means to enable here. velox carries four such
+ units, for four different reasons: geoclue-agent is redundant because
+ hyprland's exec-once starts the binary directly, emacs is started on demand
+ by emacsclient, obs-record-watchdog only matters while recording, and
+ obsbot-wb-guard needs an OBSBOT the machine doesn't have.
+
+ Left unexempted they report at every run, which is the standing-findings
+ problem check 4's own comment already argues against: four permanent lines
+ in front of every real one teach you to skim the output.
+
+ The exemption is machine-local rather than a marker in the shared unit
+ file, because obsbot-wb-guard is correctly ENABLED on ratio. Same unit,
+ different right answer per machine.
+ """
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_an_exempt_unit_is_not_flagged(self):
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled="emacs.service")
+ self.assertEqual(r.returncode, 0, r.stdout)
+ self.assertNotIn("emacs.service", r.stdout)
+
+ def test_a_non_exempt_unit_still_flags(self):
+ r = run_check(unit_states="roam-sync.timer linked",
+ units_expected_disabled="emacs.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("roam-sync.timer", r.stdout)
+
+ def test_several_exemptions_all_apply(self):
+ r = run_check(
+ unit_states=("emacs.service linked\n"
+ "geoclue-agent.service linked\n"
+ "obsbot-wb-guard.service linked"),
+ units_expected_disabled=("emacs.service\n"
+ "geoclue-agent.service\n"
+ "obsbot-wb-guard.service"))
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_an_exemption_that_is_actually_enabled_is_a_finding(self):
+ # A stale exemption must surface rather than sit there suppressing
+ # nothing. Otherwise the list rots into a place real findings go to
+ # die, which is worse than the noise it was added to remove.
+ r = run_check(unit_states="obsbot-wb-guard.service enabled",
+ units_expected_disabled="obsbot-wb-guard.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("obsbot-wb-guard.service", r.stdout)
+
+ def test_comments_and_blank_lines_are_ignored(self):
+ # The reason a unit is exempt is the most useful thing about the
+ # entry, so the format has to hold a comment next to it.
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled=("# started on demand\n"
+ "\n"
+ "emacs.service # not by systemd\n"))
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_no_exemptions_flags_everything_as_before(self):
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("emacs.service", r.stdout)
+
+ def test_an_exemption_does_not_suppress_a_dangling_link(self):
+ # A stowed unit pointing at a missing target is a different finding,
+ # decided on the filesystem. Exempting the name must not hide that.
+ d = tempfile.mkdtemp(prefix="prc-units-")
+ self.addCleanup(shutil.rmtree, d, True)
+ unit_dir = os.path.join(d, "systemd", "user")
+ os.makedirs(unit_dir)
+ link = os.path.join(unit_dir, "emacs.service")
+ os.symlink(os.path.join(d, "gone.service"), link)
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "",
+ "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_NTP_SOURCES": "162.159.200.1",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "emacs.service",
+ "XDG_CONFIG_HOME": d})
+ env.pop("PRC_UNIT_STATES", None)
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertIn("points at a missing target", r.stdout)
+ self.assertEqual(r.returncode, 1)
+
+
class RepoPushCapability(unittest.TestCase):
"""Check 8 — a working repo cloned from the read-only endpoint.
@@ -665,12 +765,31 @@ class ProjectTooling(unittest.TestCase):
def test_ignored_but_absent_tooling_flags(self):
with tempfile.TemporaryDirectory() as root:
- self.project(root, [".ai/", ".claude/", "todo.org"])
+ self.project(root, [".ai/", "todo.org"])
r = run_check(project_roots=root)
self.assertEqual(r.returncode, 1)
- for missing in (".ai", ".claude", "todo.org"):
+ for missing in (".ai", "todo.org"):
self.assertIn(missing, r.stdout)
+ def test_claude_dir_absence_never_flags(self):
+ # Same shape as CLAUDE.md below, and proven the same way. The bootstrap
+ # and the gitignore sweep write `.claude/` into the ignore set of every
+ # gitignore-mode project whether or not one ever exists there, so the
+ # entry is aspirational rather than a promise. Three projects tripped
+ # this on velox, and ratio is missing the identical directory in the
+ # identical three, which is what proves it is the steady state and not
+ # reinstall drift.
+ #
+ # Nor does dropping it lose a real signal. A project that genuinely
+ # carries one (rules and hooks from a language bundle) has it re-synced
+ # by sync-language-bundle.sh at every session start, so a true absence
+ # heals itself before this check would ever run.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", ".claude/"], present=(".ai/",))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+ self.assertNotIn(".claude", r.stdout)
+
def test_ignored_and_present_tooling_passes(self):
with tempfile.TemporaryDirectory() as root:
self.project(root, [".ai/", "CLAUDE.md"],
@@ -797,6 +916,7 @@ class SignalAccount(unittest.TestCase):
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
"PRC_IDLE_DAEMON": "4242",
"PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"signal_missing": "1"})
r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
timeout=30, env=env)
@@ -867,6 +987,7 @@ class ProbeFailure(unittest.TestCase):
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
"PRC_IDLE_DAEMON": "4242",
"PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"TMPDIR": "/nonexistent-tmp-dir"})
r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
timeout=30, env=env)
@@ -890,6 +1011,7 @@ class RealUnitDirEnumeration(unittest.TestCase):
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
"PRC_IDLE_DAEMON": "4242",
"PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"XDG_CONFIG_HOME": config_home})
env.pop("PRC_UNIT_STATES", None)
return subprocess.run(["sh", CHECK], capture_output=True, text=True,
@@ -945,6 +1067,7 @@ class WedgedSystemctl(unittest.TestCase):
"PRC_SIGNAL_ACCOUNTS": "+15045551234",
"PRC_IDLE_DAEMON": "4242",
"PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
"PRC_SYSTEMCTL": fake,
"PRC_SYSTEMCTL_TIMEOUT": timeout_s,
"XDG_CONFIG_HOME": d})