aboutsummaryrefslogtreecommitdiff
path: root/tests/installer-steps/test_configure_tlp_power.py
blob: 1ddff727291ea081c8491cfd894a3a2932754b4e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""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.

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):
    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
        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; }}
            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")

    # ---------------------------------------------------------- boundary ----
    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")

    @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()