aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_configure_tlp_power.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/installer-steps/test_configure_tlp_power.py')
-rw-r--r--tests/installer-steps/test_configure_tlp_power.py239
1 files changed, 239 insertions, 0 deletions
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()