diff --git a/pyproject.toml b/pyproject.toml index 6642d0d..52616f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,3 +93,13 @@ where = ["src"] [tool.setuptools.package-data] compartment = ["models/**/*", "data/**/*"] + +# Running the suite must cost the machine it runs on nothing. A test marked +# real_launchctl talks to the live launchd of whoever started pytest, so it +# is deselected by default and asked for by name: +# pytest -m real_launchctl +[tool.pytest.ini_options] +markers = [ + "real_launchctl: talks to the real launchd; opt in with -m real_launchctl", +] +addopts = "-m 'not real_launchctl'" diff --git a/tests/conftest.py b/tests/conftest.py index a8d44d3..94a345e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,9 +21,9 @@ def vault_path(tmp_path): @pytest.fixture(autouse=True) -def no_real_supervisors(monkeypatch, tmp_path): - """No test talks to the machine's own systemd or Task Scheduler, or - writes a start-at-login entry into the real home directory. +def no_real_supervisors(monkeypatch, tmp_path, request): + """No test talks to the machine's own launchd, systemd or Task Scheduler, + or writes a start-at-login entry into the real home directory. Running the suite must not change the computer it runs on. Without this, a Linux box with a user manager would have `set_login` tests enable a @@ -32,11 +32,25 @@ def no_real_supervisors(monkeypatch, tmp_path): the autostart tests, which patch only sys.platform, would write into the real ~/.config on any machine at all. + launchd is the same fault with a worse ending, and it was live until + this: a fake HOME moves the plist but not the domain, because + `_gui_domain` asks the real uid and LAUNCH_AGENT_LABEL is a module + constant. So a macOS run of tests/test_status_bar_install.py ran + `launchctl bootout gui//` and left the developer's + own login item deregistered with its plist still on disk, then + bootstrapped a job under the same label whose RunAtLoad started a second + menu bar app. + Tests that are about those code paths override this with their own fake or their own XDG_CONFIG_HOME, which is what monkeypatch ordering gives - them for free. + them for free. The one test that is about the live launchd asks for it by + name with the real_launchctl marker, and is deselected by default. """ - from compartment import systray + from compartment import menubar, systray + if request.node.get_closest_marker("real_launchctl") is None: + monkeypatch.setattr(menubar, "_launchctl", + lambda *a: (1, "launchctl: the test suite does " + "not talk to the real launchd")) monkeypatch.setattr(systray, "_systemctl", lambda *a: (1, "Failed to connect to bus: No such " "file or directory")) diff --git a/tests/test_status_bar_install.py b/tests/test_status_bar_install.py index 49a4568..efebba7 100644 --- a/tests/test_status_bar_install.py +++ b/tests/test_status_bar_install.py @@ -6,6 +6,7 @@ started the app, and on macOS start-at-login was impossible without the .app bundle. """ +import os import sys import tomllib from pathlib import Path @@ -58,10 +59,36 @@ def test_one_command_covers_every_platform(): @pytest.fixture() def fake_home(tmp_path, monkeypatch): + """A home of our own for everything the login item writes. + + USER_APP_BUNDLE is worked out at import, while HOME was still the real + one, so the small bundle the login item needs would be written into the + developer's own ~/Applications however faked the home is. And whether + this machine has a Compartment.app installed decides what goes in the + plist, which is not something a test should be reading off the machine + it happens to run on. + """ monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(menubar, "USER_APP_BUNDLE", + tmp_path / "Applications" / "Compartment.app") + monkeypatch.setattr(menubar, "installed_app_bundle", lambda: None) return tmp_path +@pytest.fixture() +def launchctl(monkeypatch): + """launchctl, written down rather than run. Returns the calls. + + The autouse guard in conftest.py has already made the real launchd + unreachable; this is the same protection with the answers a round trip + needs, and the record of what was asked is what gets asserted. + """ + calls: list[tuple[str, ...]] = [] + monkeypatch.setattr(menubar, "_launchctl", + lambda *a: (calls.append(a), (0, ""))[1]) + return calls + + @pytest.mark.skipif(sys.platform != "darwin", reason="macOS login item") def test_a_pip_install_is_not_mistaken_for_the_app_bundle(): """The regression that would put PYTHON in the user's login items. @@ -74,7 +101,7 @@ def test_a_pip_install_is_not_mistaken_for_the_app_bundle(): @pytest.mark.skipif(sys.platform != "darwin", reason="macOS login item") -def test_start_at_login_round_trips_without_a_bundle(fake_home): +def test_start_at_login_round_trips_without_a_bundle(fake_home, launchctl): plist = menubar._agent_plist() assert menubar.login_status() == "off" assert menubar.set_login(True) == "on" @@ -83,16 +110,69 @@ def test_start_at_login_round_trips_without_a_bundle(fake_home): assert menubar.set_login(False) == "off" assert not plist.exists() assert menubar.login_status() == "off" + # The plist by itself starts nothing, and one left registered after "off" + # is a job launchd may still bring back. So: booted out before it is + # bootstrapped, confirmed with launchd rather than with the exit status + # of the verb, and deregistered before the file is deleted. The lone + # print in the middle is `login_status` asking launchd the same way. + assert [c[0] for c in launchctl] == ["bootout", "bootstrap", "print", + "print", + "bootout", "unload"], launchctl + # And every one of them named our own job. Nothing else on the machine. + for call in launchctl: + assert menubar.LAUNCH_AGENT_LABEL in " ".join(call), call @pytest.mark.skipif(sys.platform != "darwin", reason="macOS login item") -def test_the_login_agent_names_a_real_launcher(fake_home): +def test_the_login_agent_names_a_real_launcher(fake_home, launchctl): menubar.set_login(True) body = menubar._agent_plist().read_text(encoding="utf-8") assert menubar.LAUNCH_AGENT_LABEL in body assert "RunAtLoad" in body + # Not a one-shot: a crash has to bring the icon back, an intentional Quit + # must not. That is KeepAlive as a dict, never a bare true or false. + assert "KeepAlive" in body + assert "SuccessfulExit" in body assert "menubar" in body menubar.set_login(False) + assert not menubar._agent_plist().exists() + + +@pytest.mark.real_launchctl +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS login item") +def test_launchd_really_accepts_the_agent_we_write(fake_home, monkeypatch): + """The one test that talks to the live launchd. + + Left out of every ordinary run, and asked for by name: + + pytest -m real_launchctl + + Even then it registers a label of its own with a program that does + nothing, so the job being booted in and out is never the developer's real + login item and no second menu bar app appears at RunAtLoad. + + It is written with a vault, because launchd reading the file back is the + only check that the plist is well formed, and the vault is the part of it + a unit test can only ever compare against itself. + """ + label = f"{menubar.BUNDLE_ID}.pytest.{os.getpid()}" + monkeypatch.setattr(menubar, "LAUNCH_AGENT_LABEL", label) + monkeypatch.setattr(menubar, "_launcher_argv", lambda: ["/usr/bin/true"]) + job = f"{menubar._gui_domain()}/{label}" + try: + assert menubar.set_login(True, "/tmp/compartment-pytest.vault") == "on" + assert menubar._agent_plist().is_file() + # launchd is holding it, not merely the filesystem - and holding the + # vault with it, which is how the app finds its way back to the right + # one when it is started from a bundle that takes no arguments. + code, out = menubar._launchctl("print", job) + assert code == 0, out + assert "/tmp/compartment-pytest.vault" in out, out + assert menubar.set_login(False) == "off" + assert not menubar._agent_plist().exists() + assert menubar._launchctl("print", job)[0] != 0 + finally: + menubar._launchctl("bootout", job) # --- Windows autostart -----------------------------------------------------