8234e2ec05
- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt, docs/ into muxplex/ subdir in prep for packaging/sharing - Add brand assets to frontend/: favicon.ico, pwa-192/512.png, apple-touch-icon.png, wordmark-on-dark.svg - Update app: title → muxplex, header → wordmark SVG, brand color tokens in style.css, manifest.json updated with muxplex name and brand icons - Add design system: assets/branding/tokens.css (101 CSS custom properties), tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec) - Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG) - Add scripts/render-brand-assets.py for reproducible PNG generation - Add muxplex/README.md
233 lines
7.8 KiB
Python
233 lines
7.8 KiB
Python
"""
|
|
Tests for coordinator/ttyd.py — ttyd process lifecycle management.
|
|
All 11 acceptance-criteria tests are defined here.
|
|
"""
|
|
|
|
import signal
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import coordinator.ttyd as ttyd_mod
|
|
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# autouse fixture — redirect PID paths to tmp_path for every test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def use_tmp_pid_dir(tmp_path, monkeypatch):
|
|
"""Redirect PID file I/O to a fresh temp directory for every test."""
|
|
tmp_pid_dir = tmp_path / "ttyd"
|
|
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
|
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper for mocking asyncio.create_subprocess_exec
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_ttyd_process(pid: int = 12345):
|
|
"""Return a mock ttyd process with the given PID."""
|
|
proc = MagicMock()
|
|
proc.pid = pid
|
|
return proc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# spawn_ttyd tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_spawn_ttyd_writes_pid_file():
|
|
"""spawn_ttyd() must write the process PID to TTYD_PID_PATH."""
|
|
mock_proc = _make_mock_ttyd_process(pid=99999)
|
|
|
|
with patch(
|
|
"asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=mock_proc),
|
|
):
|
|
await spawn_ttyd("my-session")
|
|
|
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
|
assert pid_path.exists(), "PID file was not created"
|
|
assert pid_path.read_text().strip() == "99999"
|
|
|
|
|
|
async def test_spawn_ttyd_uses_correct_command():
|
|
"""spawn_ttyd() must call ttyd with args: -W -m 3 -p 7682 tmux attach -t <name>."""
|
|
mock_proc = _make_mock_ttyd_process(pid=54321)
|
|
|
|
with patch(
|
|
"asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=mock_proc),
|
|
) as mock_create:
|
|
await spawn_ttyd("test-session")
|
|
|
|
call_args = mock_create.call_args[0]
|
|
assert list(call_args) == [
|
|
"ttyd",
|
|
"-W",
|
|
"-m",
|
|
"3",
|
|
"-p",
|
|
"7682",
|
|
"tmux",
|
|
"attach",
|
|
"-t",
|
|
"test-session",
|
|
]
|
|
|
|
|
|
async def test_spawn_ttyd_returns_process_object():
|
|
"""spawn_ttyd() must return the process object from create_subprocess_exec."""
|
|
mock_proc = _make_mock_ttyd_process(pid=11111)
|
|
|
|
with patch(
|
|
"asyncio.create_subprocess_exec",
|
|
new=AsyncMock(return_value=mock_proc),
|
|
):
|
|
result = await spawn_ttyd("another-session")
|
|
|
|
assert result is mock_proc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# kill_ttyd tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_kill_ttyd_returns_false_when_no_pid_file():
|
|
"""kill_ttyd() returns False when no PID file exists."""
|
|
# autouse fixture ensures no PID file is present
|
|
result = await kill_ttyd()
|
|
assert result is False
|
|
|
|
|
|
async def test_kill_ttyd_reads_pid_file_and_sends_sigterm():
|
|
"""kill_ttyd() reads the PID file and sends SIGTERM to the running process."""
|
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pid_path.write_text("12345")
|
|
|
|
kill_calls = []
|
|
|
|
def mock_os_kill(pid, sig):
|
|
kill_calls.append((pid, sig))
|
|
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
|
|
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
|
|
raise ProcessLookupError
|
|
|
|
with patch("os.kill", side_effect=mock_os_kill):
|
|
result = await kill_ttyd()
|
|
|
|
assert result is True
|
|
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
|
assert len(sigterm_calls) == 1
|
|
assert sigterm_calls[0][0] == 12345
|
|
|
|
|
|
async def test_kill_ttyd_removes_pid_file():
|
|
"""kill_ttyd() removes the PID file regardless of whether process was alive."""
|
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pid_path.write_text("12345")
|
|
|
|
def mock_os_kill(pid, sig):
|
|
# All os.kill calls raise ProcessLookupError — simulates already-dead process
|
|
raise ProcessLookupError
|
|
|
|
with patch("os.kill", side_effect=mock_os_kill):
|
|
result = await kill_ttyd()
|
|
|
|
assert result is True
|
|
assert not pid_path.exists(), "PID file should be removed after kill_ttyd()"
|
|
|
|
|
|
async def test_kill_ttyd_handles_process_already_dead():
|
|
"""kill_ttyd() returns True and clears state when process is already gone."""
|
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pid_path.write_text("99999")
|
|
|
|
# Simulate process already dead: os.kill(pid, 0) raises ProcessLookupError
|
|
with patch("os.kill", side_effect=ProcessLookupError):
|
|
result = await kill_ttyd()
|
|
|
|
assert result is True
|
|
assert not pid_path.exists(), (
|
|
"PID file should be removed when process was already dead"
|
|
)
|
|
assert ttyd_mod._active_process is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# kill_orphan_ttyd tests
|
|
#
|
|
# kill_orphan_ttyd() is a thin delegation to kill_ttyd(). These tests verify
|
|
# both the delegation wiring and that behaviour is consistent with kill_ttyd().
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_kill_orphan_ttyd_returns_false_when_no_pid_file():
|
|
"""kill_orphan_ttyd() returns False when no PID file exists (no orphan)."""
|
|
# autouse fixture ensures no PID file is present
|
|
result = await kill_orphan_ttyd()
|
|
assert result is False
|
|
|
|
|
|
async def test_kill_orphan_ttyd_kills_running_process():
|
|
"""kill_orphan_ttyd() kills a running orphan process and returns True."""
|
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pid_path.write_text("55555")
|
|
|
|
kill_calls = []
|
|
|
|
def mock_os_kill(pid, sig):
|
|
kill_calls.append((pid, sig))
|
|
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
|
|
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
|
|
raise ProcessLookupError
|
|
|
|
with patch("os.kill", side_effect=mock_os_kill):
|
|
result = await kill_orphan_ttyd()
|
|
|
|
assert result is True
|
|
assert not pid_path.exists(), "PID file should be removed after kill_orphan_ttyd()"
|
|
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
|
|
assert len(sigterm_calls) == 1
|
|
assert sigterm_calls[0][0] == 55555
|
|
|
|
|
|
async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process():
|
|
"""kill_orphan_ttyd() handles a stale PID file whose process is already gone."""
|
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pid_path.write_text("77777")
|
|
|
|
with patch("os.kill", side_effect=ProcessLookupError):
|
|
result = await kill_orphan_ttyd()
|
|
|
|
assert result is True
|
|
assert not pid_path.exists(), "PID file should be removed after orphan cleanup"
|
|
|
|
|
|
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
|
|
"""kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
|
|
pid_path = ttyd_mod.TTYD_PID_PATH
|
|
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
pid_path.write_text("not-a-pid")
|
|
|
|
# Should not raise and should clean up the file.
|
|
# kill_ttyd() calls pid_path.unlink(missing_ok=True) before returning False
|
|
# on invalid content, so the file is removed even though no kill occurred.
|
|
result = await kill_orphan_ttyd()
|
|
|
|
assert result is False
|
|
assert not pid_path.exists(), "Invalid PID file should be removed"
|