513871ef96
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>
190 lines
6.7 KiB
Python
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()
|