Files
Pyra/tests/unit/test_daemon_service.py
T
curo1305 513871ef96 feat(daemon): add OS service install/uninstall module
Generates launchd plist (macOS), systemd user unit (Linux), and Task
Scheduler XML (Windows). Auto-detects platform; finds pyra executable
via shutil.which with venv-sibling fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:23:11 +02:00

190 lines
6.7 KiB
Python

"""Unit tests for daemon service file generation and platform detection."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from pyra.daemon.service import (
detect_platform,
find_pyra_executable,
render_launchd_plist,
render_systemd_unit,
render_schtasks_xml,
)
# ── Template rendering ────────────────────────────────────────────────────────
def test_render_launchd_plist_contains_exe() -> None:
xml = render_launchd_plist("/usr/local/bin/pyra", "~/.pyra/daemon.log", "~/.pyra/daemon.pid")
assert "/usr/local/bin/pyra" in xml
assert "<string>daemon</string>" in xml
assert "<string>run</string>" in xml
assert "com.pyra.daemon" in xml
assert "<true/>" in xml # KeepAlive and RunAtLoad
def test_render_launchd_plist_expands_log_tilde() -> None:
xml = render_launchd_plist("/bin/pyra", "~/.pyra/daemon.log", "~/.pyra/daemon.pid")
assert "~" not in xml
def test_render_systemd_unit_contains_exe() -> None:
unit = render_systemd_unit("/usr/local/bin/pyra", "~/.pyra/daemon.log")
assert "ExecStart=/usr/local/bin/pyra daemon run" in unit
assert "Restart=on-failure" in unit
assert "Type=simple" in unit
assert "WantedBy=default.target" in unit
def test_render_systemd_unit_expands_log_tilde() -> None:
unit = render_systemd_unit("/bin/pyra", "~/.pyra/daemon.log")
assert "~" not in unit
def test_render_schtasks_xml_contains_exe() -> None:
xml = render_schtasks_xml("C:\\Users\\test\\pyra.exe")
assert "C:\\Users\\test\\pyra.exe" in xml
assert "LogonTrigger" in xml
assert "daemon run" in xml
assert "IgnoreNew" in xml
def test_render_schtasks_xml_no_time_limit() -> None:
xml = render_schtasks_xml("pyra.exe")
assert "PT0S" in xml # ExecutionTimeLimit=PT0S means unlimited
# ── Platform detection ────────────────────────────────────────────────────────
def test_detect_platform_returns_known_value() -> None:
result = detect_platform()
assert result in ("macos", "linux", "windows")
@pytest.mark.parametrize("system,expected", [
("Darwin", "macos"),
("Linux", "linux"),
("Windows", "windows"),
])
def test_detect_platform_maps_correctly(system: str, expected: str) -> None:
with patch("platform.system", return_value=system):
assert detect_platform() == expected
def test_detect_platform_raises_on_unknown() -> None:
with patch("platform.system", return_value="FreeBSD"):
with pytest.raises(RuntimeError, match="Unsupported platform"):
detect_platform()
# ── Executable detection ──────────────────────────────────────────────────────
def test_find_pyra_executable_returns_string() -> None:
result = find_pyra_executable()
assert isinstance(result, str)
assert len(result) > 0
def test_find_pyra_executable_uses_which_when_available(tmp_path: Path) -> None:
fake_pyra = tmp_path / "pyra"
fake_pyra.touch()
with patch("shutil.which", return_value=str(fake_pyra)):
assert find_pyra_executable() == str(fake_pyra)
def test_find_pyra_executable_falls_back_to_sibling(tmp_path: Path) -> None:
fake_python = tmp_path / "python3"
fake_pyra = tmp_path / "pyra"
fake_pyra.touch()
with patch("shutil.which", return_value=None):
with patch("sys.executable", str(fake_python)):
assert find_pyra_executable() == str(fake_pyra)
def test_find_pyra_executable_falls_back_to_module(tmp_path: Path) -> None:
fake_python = tmp_path / "python3"
with patch("shutil.which", return_value=None):
with patch("sys.executable", str(fake_python)):
result = find_pyra_executable()
assert result == f"{fake_python} -m pyra"
# ── Install / uninstall (subprocess mocked) ───────────────────────────────────
@pytest.mark.skipif(sys.platform == "win32", reason="launchd install is macOS-only")
def test_install_launchd_writes_plist_and_calls_launchctl(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import pyra.daemon.service as svc
plist_path = tmp_path / "Library" / "LaunchAgents" / "com.pyra.daemon.plist"
monkeypatch.setattr(svc, "_PLIST_PATH", plist_path)
calls: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
svc._install_launchd("/usr/local/bin/pyra", "~/.pyra/daemon.log", "~/.pyra/daemon.pid")
assert plist_path.exists()
assert "com.pyra.daemon" in plist_path.read_text()
assert any("launchctl" in c[0] for c in calls)
@pytest.mark.skipif(sys.platform == "win32", reason="systemd install is Linux-only")
def test_install_systemd_writes_unit_and_calls_systemctl(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import pyra.daemon.service as svc
unit_path = tmp_path / ".config" / "systemd" / "user" / "pyra.service"
monkeypatch.setattr(svc, "_SYSTEMD_UNIT", unit_path)
calls: list[list[str]] = []
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd))
svc._install_systemd("/usr/local/bin/pyra", "~/.pyra/daemon.log")
assert unit_path.exists()
assert "ExecStart" in unit_path.read_text()
assert any("systemctl" in c[0] for c in calls)
@pytest.mark.skipif(sys.platform == "win32", reason="launchd uninstall is macOS-only")
def test_uninstall_launchd_removes_plist(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import pyra.daemon.service as svc
plist_path = tmp_path / "Library" / "LaunchAgents" / "com.pyra.daemon.plist"
plist_path.parent.mkdir(parents=True)
plist_path.write_text("<plist/>")
monkeypatch.setattr(svc, "_PLIST_PATH", plist_path)
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: None)
svc._uninstall_launchd()
assert not plist_path.exists()
@pytest.mark.skipif(sys.platform == "win32", reason="systemd uninstall is Linux-only")
def test_uninstall_systemd_removes_unit(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import pyra.daemon.service as svc
unit_path = tmp_path / ".config" / "systemd" / "user" / "pyra.service"
unit_path.parent.mkdir(parents=True)
unit_path.write_text("[Service]")
monkeypatch.setattr(svc, "_SYSTEMD_UNIT", unit_path)
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: None)
svc._uninstall_systemd()
assert not unit_path.exists()