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
252 lines
8.5 KiB
Python
252 lines
8.5 KiB
Python
"""
|
|
Tests for coordinator/sessions.py — tmux session enumeration and helpers.
|
|
All 6 acceptance-criteria tests are defined here.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import coordinator.sessions as sessions_mod
|
|
from coordinator.sessions import (
|
|
capture_pane,
|
|
enumerate_sessions,
|
|
get_snapshots,
|
|
get_session_list,
|
|
run_tmux,
|
|
snapshot_all,
|
|
update_session_cache,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers for mocking asyncio.create_subprocess_exec
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_mock_process(stdout: str, stderr: str = "", returncode: int = 0):
|
|
"""Return a mock process whose communicate() returns encoded strings."""
|
|
proc = MagicMock()
|
|
proc.returncode = returncode
|
|
proc.communicate = AsyncMock(return_value=(stdout.encode(), stderr.encode()))
|
|
return proc
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_subprocess():
|
|
"""Fixture factory: returns a context-manager patch for asyncio.create_subprocess_exec.
|
|
|
|
Usage::
|
|
|
|
with mock_subprocess(stdout="...") as mock_create:
|
|
await some_function()
|
|
"""
|
|
|
|
def _factory(stdout: str = "", stderr: str = "", returncode: int = 0):
|
|
proc = _make_mock_process(stdout, stderr, returncode)
|
|
return patch("asyncio.create_subprocess_exec", new=AsyncMock(return_value=proc))
|
|
|
|
return _factory
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_tmux tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_run_tmux_calls_correct_command(mock_subprocess):
|
|
"""run_tmux('list-sessions', '-F', '#{session_name}') must call tmux
|
|
with exactly those positional arguments via asyncio.create_subprocess_exec."""
|
|
with mock_subprocess("session1\nsession2\n") as mock_create:
|
|
await run_tmux("list-sessions", "-F", "#{session_name}")
|
|
|
|
# First positional arg must be 'tmux'; rest must be the args we passed.
|
|
call_args = mock_create.call_args[0]
|
|
assert call_args[0] == "tmux"
|
|
assert call_args[1] == "list-sessions"
|
|
assert call_args[2] == "-F"
|
|
assert call_args[3] == "#{session_name}"
|
|
|
|
|
|
async def test_run_tmux_raises_on_nonzero_exit(mock_subprocess):
|
|
"""run_tmux() must raise RuntimeError when the subprocess exits non-zero."""
|
|
with mock_subprocess(
|
|
stdout="", stderr="no server running on /tmp/tmux-1000/default", returncode=1
|
|
):
|
|
with pytest.raises(RuntimeError, match="no server running"):
|
|
await run_tmux("list-sessions", "-F", "#{session_name}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# enumerate_sessions tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_enumerate_sessions_parses_newline_output(mock_subprocess):
|
|
"""enumerate_sessions() splits newline-separated output into a list of names."""
|
|
with mock_subprocess("alpha\nbeta\ngamma\n"):
|
|
result = await enumerate_sessions()
|
|
|
|
assert result == ["alpha", "beta", "gamma"]
|
|
|
|
|
|
async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess):
|
|
"""enumerate_sessions() returns [] when tmux output is empty."""
|
|
with mock_subprocess(""):
|
|
result = await enumerate_sessions()
|
|
|
|
assert result == []
|
|
|
|
|
|
async def test_enumerate_sessions_strips_whitespace(mock_subprocess):
|
|
"""enumerate_sessions() strips leading/trailing whitespace from each name."""
|
|
with mock_subprocess(" session1 \n session2 \n"):
|
|
result = await enumerate_sessions()
|
|
|
|
assert result == ["session1", "session2"]
|
|
|
|
|
|
async def test_enumerate_sessions_handles_tmux_error(mock_subprocess):
|
|
"""enumerate_sessions() returns [] when run_tmux raises RuntimeError
|
|
(e.g. tmux server not running)."""
|
|
with mock_subprocess(stdout="", stderr="no server running", returncode=1):
|
|
result = await enumerate_sessions()
|
|
|
|
assert result == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# capture_pane tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_capture_pane_returns_output(mock_subprocess):
|
|
"""capture_pane() returns the text output from tmux capture-pane."""
|
|
with mock_subprocess("line1\nline2\nline3\n"):
|
|
result = await capture_pane("my-session")
|
|
|
|
assert result == "line1\nline2\nline3\n"
|
|
|
|
|
|
async def test_capture_pane_returns_empty_string_on_error(mock_subprocess):
|
|
"""capture_pane() returns '' when tmux exits with an error."""
|
|
with mock_subprocess(
|
|
stdout="", stderr="can't find session my-session", returncode=1
|
|
):
|
|
result = await capture_pane("my-session")
|
|
|
|
assert result == ""
|
|
|
|
|
|
async def test_capture_pane_calls_correct_tmux_args(mock_subprocess):
|
|
"""capture_pane() calls tmux with: capture-pane -p -t <name> -S -<lines>.
|
|
|
|
Uses -S -N (start N lines from bottom) to limit output.
|
|
Does NOT pass -e (escape sequences) or -l (invalid in tmux 3.4).
|
|
"""
|
|
with mock_subprocess("output text\n") as mock_create:
|
|
await capture_pane("target-session", lines=50)
|
|
|
|
call_args = mock_create.call_args[0]
|
|
assert call_args[0] == "tmux"
|
|
assert call_args[1] == "capture-pane"
|
|
assert call_args[2] == "-p"
|
|
assert call_args[3] == "-t"
|
|
assert call_args[4] == "target-session"
|
|
assert call_args[5] == "-S"
|
|
assert call_args[6] == "-50"
|
|
assert len(call_args) == 7, "No extra args — -e and -l must not be present"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# snapshot_all tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_snapshot_all_returns_dict_keyed_by_name():
|
|
"""snapshot_all() returns a dict mapping each session name to its pane output."""
|
|
|
|
async def mock_capture(name, lines=30):
|
|
return f"output-for-{name}"
|
|
|
|
with patch("coordinator.sessions.capture_pane", side_effect=mock_capture):
|
|
result = await snapshot_all(["alpha", "beta", "gamma"])
|
|
|
|
assert result == {
|
|
"alpha": "output-for-alpha",
|
|
"beta": "output-for-beta",
|
|
"gamma": "output-for-gamma",
|
|
}
|
|
|
|
|
|
async def test_snapshot_all_returns_empty_dict_for_empty_input():
|
|
"""snapshot_all([]) returns an empty dict without calling capture_pane."""
|
|
with patch("coordinator.sessions.capture_pane", new=AsyncMock()) as mock_capture:
|
|
result = await snapshot_all([])
|
|
|
|
assert result == {}
|
|
mock_capture.assert_not_called()
|
|
|
|
|
|
async def test_snapshot_all_returns_empty_string_on_individual_failure():
|
|
"""snapshot_all() maps '' for a failing session while others still succeed."""
|
|
|
|
async def mock_capture(name, lines=30):
|
|
if name == "bad-session":
|
|
raise RuntimeError("pane not found")
|
|
return f"output-for-{name}"
|
|
|
|
with patch("coordinator.sessions.capture_pane", side_effect=mock_capture):
|
|
result = await snapshot_all(["session-a", "bad-session", "session-b"])
|
|
|
|
assert result == {
|
|
"session-a": "output-for-session-a",
|
|
"bad-session": "",
|
|
"session-b": "output-for-session-b",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# update_session_cache tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_update_session_cache_populates_snapshots():
|
|
"""update_session_cache(names, snapshots) must replace _snapshots with provided dict.
|
|
|
|
This is the RED test for Critical Issue #1: previously, update_session_cache
|
|
only accepted names and never received the snapshots dict, so _snapshots
|
|
stayed empty forever.
|
|
"""
|
|
# Reset module state to simulate a fresh start
|
|
sessions_mod._snapshots = {}
|
|
sessions_mod._session_list = []
|
|
|
|
update_session_cache(
|
|
["sess1", "sess2"], {"sess1": "line1\nline2", "sess2": "hello"}
|
|
)
|
|
|
|
result = get_snapshots()
|
|
assert result == {"sess1": "line1\nline2", "sess2": "hello"}
|
|
|
|
|
|
def test_update_session_cache_updates_session_list():
|
|
"""update_session_cache() must also replace _session_list with the given names."""
|
|
sessions_mod._snapshots = {}
|
|
sessions_mod._session_list = ["old-session"]
|
|
|
|
update_session_cache(["alpha", "beta"], {"alpha": "a", "beta": "b"})
|
|
|
|
assert get_session_list() == ["alpha", "beta"]
|
|
|
|
|
|
def test_update_session_cache_empty_names_clears_caches():
|
|
"""update_session_cache([], {}) clears both caches."""
|
|
sessions_mod._snapshots = {"stale": "text"}
|
|
sessions_mod._session_list = ["stale"]
|
|
|
|
update_session_cache([], {})
|
|
|
|
assert get_session_list() == []
|
|
assert get_snapshots() == {}
|