refactor: restructure project into muxplex/ subdir with brand integration

- 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
This commit is contained in:
Brian Krabach
2026-03-27 15:06:00 -07:00
commit 8234e2ec05
76 changed files with 17006 additions and 0 deletions
View File
+645
View File
@@ -0,0 +1,645 @@
"""
Tests for coordinator/main.py — FastAPI skeleton, lifespan, /health endpoint.
"""
import pytest
from fastapi.testclient import TestClient
from coordinator.main import app
# ---------------------------------------------------------------------------
# autouse fixture — redirect state/PID files, mock startup side-effects
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def patch_startup_and_state(tmp_path, monkeypatch):
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op."""
# Redirect state files
tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path)
# Redirect PID files
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)
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
async def _mock_kill_orphan():
return False
monkeypatch.setattr("coordinator.main.kill_orphan_ttyd", _mock_kill_orphan)
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("coordinator.main._poll_loop", noop_poll_loop)
# ---------------------------------------------------------------------------
# Client fixture — TestClient with lifespan enabled
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
"""Return a TestClient that triggers the app lifespan on entry/exit."""
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_health_returns_200(client):
"""GET /health must return HTTP 200."""
response = client.get("/health")
assert response.status_code == 200
def test_health_returns_ok_status(client):
"""GET /health must return JSON body {status: 'ok'}."""
response = client.get("/health")
assert response.json() == {"status": "ok"}
# ---------------------------------------------------------------------------
# GET /api/state
# ---------------------------------------------------------------------------
def test_get_state_returns_full_state(client):
"""GET /api/state must return a dict with all 4 top-level keys."""
response = client.get("/api/state")
assert response.status_code == 200
data = response.json()
assert "active_session" in data
assert "session_order" in data
assert "sessions" in data
assert "devices" in data
def test_get_state_active_session_is_none_initially(client):
"""GET /api/state active_session must be None on a fresh state."""
response = client.get("/api/state")
assert response.status_code == 200
data = response.json()
assert data["active_session"] is None
# ---------------------------------------------------------------------------
# PATCH /api/state
# ---------------------------------------------------------------------------
def test_patch_state_updates_session_order(client):
"""PATCH /api/state updates session_order and persists the change."""
from coordinator.state import load_state, save_state
# Write initial state with a known session order
initial_state = {
"active_session": None,
"session_order": ["alpha", "beta"],
"sessions": {},
"devices": {},
}
save_state(initial_state)
# Patch with reversed order
response = client.patch("/api/state", json={"session_order": ["beta", "alpha"]})
assert response.status_code == 200
data = response.json()
assert data["session_order"] == ["beta", "alpha"]
# Verify the update was persisted to disk
persisted = load_state()
assert persisted["session_order"] == ["beta", "alpha"]
def test_patch_state_rejects_non_list_session_order(client):
"""PATCH /api/state rejects non-list session_order with HTTP 422."""
response = client.patch("/api/state", json={"session_order": "not-a-list"})
assert response.status_code == 422
def test_patch_state_ignores_unknown_fields(client):
"""PATCH /api/state ignores unknown fields in the request body."""
response = client.patch(
"/api/state",
json={"session_order": ["a", "b"], "unknown_field": "should_be_ignored"},
)
assert response.status_code == 200
data = response.json()
assert "unknown_field" not in data
assert data["session_order"] == ["a", "b"]
# ---------------------------------------------------------------------------
# GET /api/sessions
# ---------------------------------------------------------------------------
def test_get_sessions_returns_list(client, monkeypatch):
"""GET /api/sessions must return a JSON list."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
monkeypatch.setattr(
"coordinator.main.get_snapshots", lambda: {"alpha": "some text"}
)
response = client.get("/api/sessions")
assert response.status_code == 200
items = response.json()
assert isinstance(items, list)
assert items[0]["name"] == "alpha"
def test_get_sessions_each_item_has_required_fields(client, monkeypatch):
"""Each item in GET /api/sessions must have name, snapshot, and bell fields."""
from coordinator.state import save_state
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["beta"])
monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {"beta": "output"})
save_state(
{
"active_session": None,
"session_order": ["beta"],
"sessions": {
"beta": {
"bell": {"last_fired_at": None, "seen_at": None, "unseen_count": 0}
}
},
"devices": {},
}
)
response = client.get("/api/sessions")
assert response.status_code == 200
items = response.json()
assert len(items) == 1
item = items[0]
assert "name" in item
assert "snapshot" in item
assert "bell" in item
def test_get_sessions_includes_snapshot_text(client, monkeypatch):
"""GET /api/sessions snapshot field must contain the cached capture-pane text."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["gamma"])
monkeypatch.setattr(
"coordinator.main.get_snapshots",
lambda: {"gamma": "hello from tmux pane"},
)
response = client.get("/api/sessions")
assert response.status_code == 200
items = response.json()
assert len(items) == 1
assert items[0]["name"] == "gamma"
assert items[0]["snapshot"] == "hello from tmux pane"
def test_get_sessions_includes_bell_state(client, monkeypatch):
"""GET /api/sessions bell field must include unseen_count from persistent state."""
from coordinator.state import save_state
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["delta"])
monkeypatch.setattr(
"coordinator.main.get_snapshots", lambda: {"delta": "pane text"}
)
save_state(
{
"active_session": None,
"session_order": ["delta"],
"sessions": {
"delta": {
"bell": {
"last_fired_at": 1234567890.0,
"seen_at": None,
"unseen_count": 3,
}
}
},
"devices": {},
}
)
response = client.get("/api/sessions")
assert response.status_code == 200
items = response.json()
assert len(items) == 1
assert items[0]["bell"]["unseen_count"] == 3
def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch):
"""GET /api/sessions must return an empty list when there are no sessions."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: [])
monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {})
response = client.get("/api/sessions")
assert response.status_code == 200
assert response.json() == []
# ---------------------------------------------------------------------------
# POST /api/sessions/{name}/connect
# ---------------------------------------------------------------------------
def test_connect_session_returns_200(client, monkeypatch):
"""POST /api/sessions/{name}/connect returns 200 and correct body when session exists."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
async def mock_kill():
return True
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
async def mock_spawn(name):
pass
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn)
response = client.post("/api/sessions/alpha/connect")
assert response.status_code == 200
data = response.json()
assert data["active_session"] == "alpha"
assert data["ttyd_port"] == 7682
def test_connect_session_sets_active_session(client, monkeypatch):
"""POST /api/sessions/{name}/connect persists active_session to state."""
from coordinator.state import load_state
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
async def mock_kill():
return True
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
async def mock_spawn(name):
pass
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn)
client.post("/api/sessions/alpha/connect")
state = load_state()
assert state["active_session"] == "alpha"
def test_connect_session_kills_existing_ttyd(client, monkeypatch):
"""POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"])
call_order = []
async def mock_kill():
call_order.append("kill")
return True
async def mock_spawn(name):
call_order.append(("spawn", name))
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn)
response = client.post("/api/sessions/alpha/connect")
assert response.status_code == 200
assert call_order == ["kill", ("spawn", "alpha")]
def test_connect_nonexistent_session_returns_404(client, monkeypatch):
"""POST /api/sessions/{name}/connect returns 404 when session is not in list."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha", "beta"])
response = client.post("/api/sessions/gamma/connect")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# DELETE /api/sessions/current
# ---------------------------------------------------------------------------
def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
"""DELETE /api/sessions/current kills ttyd and clears active_session."""
from coordinator.state import load_state, save_state
# Set up initial state with active session
save_state(
{
"active_session": "alpha",
"session_order": ["alpha"],
"sessions": {},
"devices": {},
}
)
kill_called = []
async def mock_kill():
kill_called.append(True)
return True
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill)
response = client.delete("/api/sessions/current")
assert response.status_code == 200
data = response.json()
assert data["active_session"] is None
assert len(kill_called) == 1
# Verify state was persisted
state = load_state()
assert state["active_session"] is None
# ---------------------------------------------------------------------------
# POST /api/heartbeat
# ---------------------------------------------------------------------------
def test_heartbeat_returns_200(client):
"""POST /api/heartbeat must return HTTP 200 with device_id and status 'ok'."""
response = client.post(
"/api/heartbeat",
json={
"device_id": "dev-abc",
"label": "My Laptop",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": 1234567890.0,
},
)
assert response.status_code == 200
data = response.json()
assert data["device_id"] == "dev-abc"
assert data["status"] == "ok"
def test_heartbeat_registers_new_device(client):
"""POST /api/heartbeat registers a new device visible in GET /api/state."""
client.post(
"/api/heartbeat",
json={
"device_id": "dev-new",
"label": "Test Device",
"viewing_session": "mysession",
"view_mode": "fullscreen",
"last_interaction_at": 1111111111.0,
},
)
state_response = client.get("/api/state")
assert state_response.status_code == 200
state = state_response.json()
assert "dev-new" in state["devices"]
device = state["devices"]["dev-new"]
assert device["label"] == "Test Device"
assert device["viewing_session"] == "mysession"
assert device["view_mode"] == "fullscreen"
assert device["last_interaction_at"] == 1111111111.0
def test_heartbeat_updates_existing_device(client):
"""Two POST /api/heartbeat calls: second values are persisted."""
# First heartbeat
client.post(
"/api/heartbeat",
json={
"device_id": "dev-update",
"label": "Old Label",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": 1000000000.0,
},
)
# Second heartbeat with updated values
client.post(
"/api/heartbeat",
json={
"device_id": "dev-update",
"label": "New Label",
"viewing_session": "session-x",
"view_mode": "fullscreen",
"last_interaction_at": 2000000000.0,
},
)
state_response = client.get("/api/state")
state = state_response.json()
device = state["devices"]["dev-update"]
assert device["label"] == "New Label"
assert device["viewing_session"] == "session-x"
assert device["view_mode"] == "fullscreen"
assert device["last_interaction_at"] == 2000000000.0
def test_heartbeat_missing_device_id_returns_422(client):
"""POST /api/heartbeat without device_id must return HTTP 422."""
response = client.post(
"/api/heartbeat",
json={
"label": "My Laptop",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": 1234567890.0,
},
)
assert response.status_code == 422
def test_heartbeat_invalid_view_mode_returns_422(client):
"""POST /api/heartbeat with invalid view_mode must return HTTP 422."""
response = client.post(
"/api/heartbeat",
json={
"device_id": "dev-abc",
"label": "My Laptop",
"viewing_session": None,
"view_mode": "invalid_mode",
"last_interaction_at": 1234567890.0,
},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# POST /api/sessions/{name}/bell
# ---------------------------------------------------------------------------
def test_receive_bell_returns_ok_and_session_name(client):
"""POST /api/sessions/{name}/bell returns {"ok": True, "session": name}."""
response = client.post("/api/sessions/web-tmux/bell")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["session"] == "web-tmux"
def test_receive_bell_increments_unseen_count(client):
"""POST /api/sessions/{name}/bell increments unseen_count in state."""
from coordinator.state import load_state
client.post("/api/sessions/my-session/bell")
state = load_state()
bell = state["sessions"]["my-session"]["bell"]
assert bell["unseen_count"] == 1
def test_receive_bell_creates_session_entry_if_absent(client):
"""POST /api/sessions/{name}/bell creates session/bell entries if missing."""
from coordinator.state import load_state
# Ensure session does not exist in state yet
client.post("/api/sessions/brand-new/bell")
state = load_state()
assert "brand-new" in state["sessions"]
assert "bell" in state["sessions"]["brand-new"]
def test_receive_bell_multiple_calls_accumulate(client):
"""Three POST calls to the bell endpoint accumulate unseen_count to 3."""
from coordinator.state import load_state
for _ in range(3):
client.post("/api/sessions/multi-session/bell")
state = load_state()
bell = state["sessions"]["multi-session"]["bell"]
assert bell["unseen_count"] == 3
def test_receive_bell_sets_last_fired_at(client):
"""POST /api/sessions/{name}/bell sets last_fired_at to a recent timestamp."""
import time
from coordinator.state import load_state
before = time.time()
client.post("/api/sessions/timed-session/bell")
after = time.time()
state = load_state()
bell = state["sessions"]["timed-session"]["bell"]
assert bell["last_fired_at"] is not None
assert before <= bell["last_fired_at"] <= after
# ---------------------------------------------------------------------------
# POST /api/internal/setup-hooks
# ---------------------------------------------------------------------------
def test_setup_hooks_returns_ok(client, monkeypatch):
"""POST /api/internal/setup-hooks returns {"ok": True} when tmux hook registers."""
from unittest.mock import AsyncMock
monkeypatch.setattr("coordinator.main.run_tmux", AsyncMock(return_value=""))
response = client.post("/api/internal/setup-hooks")
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
def test_setup_hooks_returns_ok_false_on_error(client, monkeypatch):
"""POST /api/internal/setup-hooks returns {"ok": False} when tmux raises."""
from unittest.mock import AsyncMock
monkeypatch.setattr(
"coordinator.main.run_tmux",
AsyncMock(side_effect=RuntimeError("tmux not found")),
)
response = client.post("/api/internal/setup-hooks")
assert response.status_code == 200
data = response.json()
assert data["ok"] is False
assert "error" in data
def test_setup_hooks_curl_discards_response_body(client, monkeypatch):
"""POST /api/internal/setup-hooks passes curl with -o /dev/null to discard response."""
from unittest.mock import AsyncMock
mock_run_tmux = AsyncMock(return_value="")
monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux)
response = client.post("/api/internal/setup-hooks")
assert response.status_code == 200
# Verify run_tmux was called with the correct hook command
assert mock_run_tmux.called
call_args = mock_run_tmux.call_args
# Positional args are: "set-hook", "-g", "alert-bell", <hook_command>
hook_command = call_args[0][3] if len(call_args[0]) > 3 else None
assert hook_command is not None
# Should have -sfo /dev/null, not just -sf
assert "-sfo /dev/null" in hook_command
def test_lifespan_alert_bell_hook_discards_response(monkeypatch):
"""Lifespan startup registers alert-bell hook with curl -o /dev/null to discard response."""
from unittest.mock import AsyncMock
from fastapi.testclient import TestClient
from coordinator.main import app
# Mock run_tmux to capture the hook command
mock_run_tmux = AsyncMock(return_value="")
monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux)
# Trigger lifespan by creating a TestClient
with TestClient(app) as _:
pass
# Verify run_tmux was called during lifespan startup
assert mock_run_tmux.called
# Find the call that sets the alert-bell hook
hook_calls = [
call
for call in mock_run_tmux.call_args_list
if len(call[0]) > 3 and call[0][2] == "alert-bell"
]
assert len(hook_calls) > 0, "alert-bell hook was not set during lifespan"
# Check the first hook call
hook_command = hook_calls[0][0][3]
assert "-sfo /dev/null" in hook_command
# ---------------------------------------------------------------------------
# Static file serving tests
# ---------------------------------------------------------------------------
def test_root_serves_html(client):
"""GET / must return 200 with text/html content-type."""
response = client.get("/")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
def test_style_css_served(client):
"""GET /style.css must return 200 with text/css content-type."""
response = client.get("/style.css")
assert response.status_code == 200
assert "text/css" in response.headers["content-type"]
def test_api_routes_not_shadowed(client):
"""GET /api/sessions must still return 200 with JSON list (not shadowed by StaticFiles)."""
response = client.get("/api/sessions")
assert response.status_code == 200
assert isinstance(response.json(), list)
+318
View File
@@ -0,0 +1,318 @@
"""
Tests for coordinator/bells.py — bell flag polling and unseen_count tracking.
All 17 acceptance-criteria tests are defined here.
"""
import time
from unittest.mock import AsyncMock, patch
import pytest
from coordinator.bells import (
_bell_seen,
apply_bell_clear_rule,
poll_bell_flag,
process_bell_flags,
should_clear_bell,
)
from coordinator.state import empty_bell, empty_state
# ---------------------------------------------------------------------------
# autouse fixture — clear _bell_seen before/after each test
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_bell_seen():
"""Clear _bell_seen before and after each test for isolation."""
_bell_seen.clear()
yield
_bell_seen.clear()
# ---------------------------------------------------------------------------
# poll_bell_flag tests
# ---------------------------------------------------------------------------
async def test_poll_bell_flag_returns_true_when_flag_is_1():
"""poll_bell_flag returns True when tmux reports window_bell_flag=1."""
with patch("coordinator.bells.run_tmux", new=AsyncMock(return_value="1\n")):
result = await poll_bell_flag("my-session")
assert result is True
async def test_poll_bell_flag_returns_false_when_flag_is_0():
"""poll_bell_flag returns False when tmux reports window_bell_flag=0."""
with patch("coordinator.bells.run_tmux", new=AsyncMock(return_value="0\n")):
result = await poll_bell_flag("my-session")
assert result is False
async def test_poll_bell_flag_returns_false_on_error():
"""poll_bell_flag returns False when run_tmux raises RuntimeError."""
with patch(
"coordinator.bells.run_tmux",
new=AsyncMock(side_effect=RuntimeError("session not found")),
):
result = await poll_bell_flag("my-session")
assert result is False
# ---------------------------------------------------------------------------
# process_bell_flags tests
# ---------------------------------------------------------------------------
async def test_process_bell_flags_increments_unseen_count_on_new_bell():
"""process_bell_flags increments unseen_count on a 0→1 transition."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)):
changed = await process_bell_flags(["session-a"], state)
assert changed is True
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 1
assert state["sessions"]["session-a"]["bell"]["last_fired_at"] is not None
async def test_process_bell_flags_does_not_double_count_persistent_flag():
"""process_bell_flags does not increment unseen_count if flag stays at 1."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)):
# First poll — 0→1 transition
await process_bell_flags(["session-a"], state)
# Second poll — 1→1 (persistent), should NOT increment again
changed = await process_bell_flags(["session-a"], state)
assert changed is False
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 1
async def test_process_bell_flags_resets_tracking_when_flag_clears():
"""1→0→1 sequence counts as two separate bells."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
# side_effect drives three sequential calls: 0→1, 1→0, 0→1
with patch(
"coordinator.bells.poll_bell_flag",
new=AsyncMock(side_effect=[True, False, True]),
):
for _ in range(3):
await process_bell_flags(["session-a"], state)
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 2
async def test_process_bell_flags_no_change_returns_false():
"""process_bell_flags returns False when no bell state changed."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)):
changed = await process_bell_flags(["session-a"], state)
assert changed is False
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 0
async def test_process_bell_flags_creates_bell_entry_if_missing():
"""process_bell_flags creates the bell sub-dict if session has no bell key."""
state = empty_state()
state["sessions"]["session-a"] = {} # no 'bell' key
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)):
await process_bell_flags(["session-a"], state)
assert "bell" in state["sessions"]["session-a"]
assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 0
# ---------------------------------------------------------------------------
# should_clear_bell tests
# ---------------------------------------------------------------------------
def test_should_clear_bell_returns_true_for_fullscreen_recent_interaction():
"""should_clear_bell returns True when a device is fullscreen and interacted recently."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-a",
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 10.0, # 10 seconds ago
"last_heartbeat_at": time.time(),
}
assert should_clear_bell("session-a", state) is True
def test_should_clear_bell_returns_false_for_grid_mode():
"""should_clear_bell returns False when device is in grid mode."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-a",
"view_mode": "grid",
"last_interaction_at": time.time() - 10.0, # recent interaction
"last_heartbeat_at": time.time(),
}
assert should_clear_bell("session-a", state) is False
def test_should_clear_bell_returns_false_when_interaction_too_old():
"""should_clear_bell returns False when last interaction was more than 60s ago."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-a",
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 90.0, # 90 seconds ago (> 60s window)
"last_heartbeat_at": time.time(),
}
assert should_clear_bell("session-a", state) is False
def test_should_clear_bell_returns_false_when_device_viewing_different_session():
"""should_clear_bell returns False when device is viewing a different session."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-b", # different session
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 10.0,
"last_heartbeat_at": time.time(),
}
assert should_clear_bell("session-a", state) is False
def test_should_clear_bell_returns_false_when_no_devices():
"""should_clear_bell returns False when there are no connected devices."""
state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()}
# No devices in state["devices"]
assert should_clear_bell("session-a", state) is False
# ---------------------------------------------------------------------------
# apply_bell_clear_rule tests
# ---------------------------------------------------------------------------
def test_apply_bell_clear_rule_clears_matching_sessions():
"""apply_bell_clear_rule resets unseen_count to 0 and sets seen_at for qualifying sessions."""
state = empty_state()
state["sessions"]["session-a"] = {
"bell": {
"unseen_count": 3,
"last_fired_at": time.time() - 30.0,
"seen_at": None,
}
}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-a",
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 10.0,
"last_heartbeat_at": time.time(),
}
before = time.time()
apply_bell_clear_rule(state)
after = time.time()
bell = state["sessions"]["session-a"]["bell"]
assert bell["unseen_count"] == 0
assert bell["seen_at"] is not None
assert before <= bell["seen_at"] <= after
def test_apply_bell_clear_rule_skips_sessions_with_zero_unseen():
"""apply_bell_clear_rule does not modify sessions that already have unseen_count == 0."""
state = empty_state()
state["sessions"]["session-a"] = {
"bell": {
"unseen_count": 0,
"last_fired_at": None,
"seen_at": None,
}
}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-a",
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 10.0,
"last_heartbeat_at": time.time(),
}
result = apply_bell_clear_rule(state)
assert result == []
assert state["sessions"]["session-a"]["bell"]["seen_at"] is None
def test_apply_bell_clear_rule_returns_list_of_cleared_session_names():
"""apply_bell_clear_rule returns the names of sessions that were cleared."""
state = empty_state()
state["sessions"]["session-a"] = {
"bell": {"unseen_count": 2, "last_fired_at": time.time() - 5.0, "seen_at": None}
}
state["sessions"]["session-b"] = {
"bell": {"unseen_count": 1, "last_fired_at": time.time() - 5.0, "seen_at": None}
}
state["sessions"]["session-c"] = {
"bell": {"unseen_count": 0, "last_fired_at": None, "seen_at": None}
}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-a",
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 10.0,
"last_heartbeat_at": time.time(),
}
state["devices"]["device-2"] = {
"label": "Device 2",
"viewing_session": "session-b",
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 10.0,
"last_heartbeat_at": time.time(),
}
result = apply_bell_clear_rule(state)
assert sorted(result) == ["session-a", "session-b"]
def test_apply_bell_clear_rule_resets_bell_seen_tracking():
"""apply_bell_clear_rule resets _bell_seen[name] = False for cleared sessions."""
state = empty_state()
state["sessions"]["session-a"] = {
"bell": {"unseen_count": 1, "last_fired_at": time.time() - 5.0, "seen_at": None}
}
state["devices"]["device-1"] = {
"label": "Device 1",
"viewing_session": "session-a",
"view_mode": "fullscreen",
"last_interaction_at": time.time() - 10.0,
"last_heartbeat_at": time.time(),
}
# Pre-seed _bell_seen as if the bell was previously seen
_bell_seen["session-a"] = True
apply_bell_clear_rule(state)
assert _bell_seen.get("session-a") is False
+92
View File
@@ -0,0 +1,92 @@
"""Tests for frontend/style.css — design tokens and dark theme."""
import pathlib
CSS_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "style.css"
def read_css() -> str:
return CSS_PATH.read_text(encoding="utf-8")
def test_css_design_tokens():
css = read_css()
assert "--bg:" in css
assert "--bell:" in css
assert "--font-mono:" in css
assert "--tile-height:" in css
assert "--t-zoom:" in css
def test_css_session_grid(css=None):
css = read_css()
assert "auto-fill" in css
assert "minmax" in css
def test_css_tile_height(css=None):
css = read_css()
assert ".session-tile" in css
assert "var(--tile-height)" in css
def test_css_bell_indicator(css=None):
css = read_css()
assert "bell-pulse" in css
assert ".session-tile--bell" in css
assert ".tile-bell" in css
def test_css_breakpoints():
css = read_css()
assert "599px" in css
assert "899px" in css
def test_css_zoom_transition():
css = read_css()
assert ".session-tile--expanding" in css
assert "session-tile--expanded" in css
assert ".session-grid--dimming" in css
def test_css_bell_count_and_toast():
css = read_css()
assert ".tile-bell-count" in css
assert ".connection-status--ok" in css
assert ".connection-status--warn" in css
assert ".connection-status--err" in css
assert ".toast" in css
def test_css_mobile_tiers():
css = read_css()
assert "session-tile--tier-bell" in css
assert "session-tile--tier-active" in css
assert "session-tile--tier-idle" in css
def test_css_command_palette():
css = read_css()
assert ".command-palette__dialog" in css
assert ".command-palette__input" in css
assert ".palette-item" in css
assert ".palette-item--selected" in css
def test_css_bottom_sheet():
css = read_css()
assert ".bottom-sheet__panel" in css
assert ".bottom-sheet__handle" in css
assert ".sheet-item" in css
def test_css_session_pill():
css = read_css()
assert ".session-pill" in css
assert ".session-pill__label" in css
def test_css_reduced_motion():
css = read_css()
assert "prefers-reduced-motion" in css
+165
View File
@@ -0,0 +1,165 @@
"""Tests for frontend/index.html — verifies presence of all required DOM elements."""
import pathlib
from bs4 import BeautifulSoup
HTML_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "index.html"
# Parse once per module — tests are read-only so sharing is safe.
_SOUP: BeautifulSoup = BeautifulSoup(HTML_PATH.read_text(), "html.parser")
def test_html_pwa_meta() -> None:
"""apple-mobile-web-app-capable, rel=manifest, theme-color, apple-mobile-web-app-status-bar-style."""
soup = _SOUP
# rel=manifest
assert soup.find("link", rel="manifest"), "Missing <link rel='manifest'>"
# theme-color
assert soup.find("meta", attrs={"name": "theme-color"}), (
"Missing <meta name='theme-color'>"
)
# apple-mobile-web-app-capable
assert soup.find("meta", attrs={"name": "apple-mobile-web-app-capable"}), (
"Missing <meta name='apple-mobile-web-app-capable'>"
)
# apple-mobile-web-app-status-bar-style
assert soup.find("meta", attrs={"name": "apple-mobile-web-app-status-bar-style"}), (
"Missing <meta name='apple-mobile-web-app-status-bar-style'>"
)
def test_html_viewport_suppresses_pinch_zoom() -> None:
"""viewport must include maximum-scale=1.0 and user-scalable=no."""
soup = _SOUP
viewport = soup.find("meta", attrs={"name": "viewport"})
assert viewport, "Missing <meta name='viewport'>"
content = str(viewport.get("content", "")) # type: ignore[union-attr]
assert "maximum-scale=1.0" in content, (
f"viewport missing maximum-scale=1.0: {content!r}"
)
assert "user-scalable=no" in content, (
f"viewport missing user-scalable=no: {content!r}"
)
def test_html_required_views() -> None:
"""id=view-overview, view-expanded, session-grid, terminal-container, empty-state."""
soup = _SOUP
for id_ in (
"view-overview",
"view-expanded",
"session-grid",
"terminal-container",
"empty-state",
):
assert soup.find(id=id_), f"Missing element with id='{id_}'"
def test_html_expanded_view_elements() -> None:
"""id=back-btn, expanded-session-name, palette-trigger, reconnect-overlay."""
soup = _SOUP
for id_ in (
"back-btn",
"expanded-session-name",
"palette-trigger",
"reconnect-overlay",
):
assert soup.find(id=id_), f"Missing element with id='{id_}'"
def test_html_command_palette() -> None:
"""id=command-palette, palette-input, palette-list, palette-backdrop."""
soup = _SOUP
for id_ in ("command-palette", "palette-input", "palette-list", "palette-backdrop"):
assert soup.find(id=id_), f"Missing element with id='{id_}'"
def test_html_bottom_sheet() -> None:
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell."""
soup = _SOUP
for id_ in (
"bottom-sheet",
"sheet-list",
"sheet-backdrop",
"session-pill",
"session-pill-label",
"session-pill-bell",
):
assert soup.find(id=id_), f"Missing element with id='{id_}'"
def test_html_toast() -> None:
"""id=toast, aria-live=polite."""
soup = _SOUP
toast = soup.find(id="toast")
assert toast, "Missing element with id='toast'"
assert toast.get("aria-live") == "polite", ( # type: ignore[union-attr]
f"toast missing aria-live=polite, got: {toast.get('aria-live')!r}" # type: ignore[union-attr]
)
def test_html_scripts() -> None:
"""src=/app.js, src=/terminal.js, xterm."""
soup = _SOUP
scripts = soup.find_all("script")
srcs = [str(s.get("src", "")) for s in scripts]
assert any("/app.js" in s for s in srcs), (
f"Missing script src=/app.js; found: {srcs}"
)
assert any("/terminal.js" in s for s in srcs), (
f"Missing script src=/terminal.js; found: {srcs}"
)
assert any("xterm" in s for s in srcs), f"Missing xterm script; found: {srcs}"
def test_html_xterm_css() -> None:
"""xterm.css CDN link present."""
soup = _SOUP
links = soup.find_all("link", rel="stylesheet")
hrefs = [str(lnk.get("href", "")) for lnk in links]
assert any("xterm.css" in h for h in hrefs), (
f"Missing xterm.css link; found: {hrefs}"
)
def test_html_style_css() -> None:
"""href=/style.css present."""
soup = _SOUP
links = soup.find_all("link", rel="stylesheet")
hrefs = [str(lnk.get("href", "")) for lnk in links]
assert any("/style.css" in h for h in hrefs), (
f"Missing /style.css link; found: {hrefs}"
)
def test_html_element_classes() -> None:
"""Critical and important elements must carry their CSS styling classes."""
soup = _SOUP
cases = [
# (element_id, required_class, reason)
("terminal-container", "terminal-container", "xterm.js needs flex:1 to render"),
(
"reconnect-overlay",
"reconnect-overlay",
"needs position:absolute to overlay terminal",
),
("session-pill", "session-pill", "needs position:fixed to float"),
("toast", "toast", "needs position:fixed and animation"),
("back-btn", "back-btn", "needs border and hover styles"),
("palette-trigger", "palette-trigger", "needs border and hover styles"),
(
"expanded-session-name",
"expanded-session-name",
"needs text-overflow:ellipsis",
),
("session-pill-label", "session-pill__label", "needs max-width truncation"),
("session-pill-bell", "session-pill__bell", "needs amber var(--bell) color"),
]
for el_id, expected_class, reason in cases:
el = soup.find(id=el_id)
assert el is not None, f"#{el_id} not found in HTML"
classes = el.get("class") or []
assert expected_class in classes, (
f"#{el_id} is missing class '{expected_class}'{reason}. Has: {classes}"
)
+213
View File
@@ -0,0 +1,213 @@
"""
Integration tests for the tmux-web coordinator.
These tests require a real tmux installation and spin up an isolated tmux
server on socket 'test-server' for the duration of the module.
Run with:
pytest -m integration -v
Default test run (unit tests only):
pytest -v
"""
import asyncio
import json
import subprocess
from unittest.mock import patch
import pytest
import coordinator.state as state_mod
from coordinator.bells import poll_bell_flag
from coordinator.main import _run_poll_cycle
from coordinator.sessions import enumerate_sessions, get_snapshots
# ---------------------------------------------------------------------------
# Helper function
# ---------------------------------------------------------------------------
def tmux(socket: str, *args: str) -> str:
"""Run a tmux command against the specified socket and return stdout."""
result = subprocess.run(
["tmux", "-L", socket, *args],
capture_output=True,
text=True,
)
return result.stdout
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def tmux_server():
"""Start an isolated tmux server on socket 'test-server', create session 'test' (220x50).
Sets monitor-bell on so that bell characters sent to the session are detected.
Tears down the server after all module tests complete.
"""
socket = "test-server"
# Start a new tmux server with an isolated socket and create the test session
subprocess.run(
[
"tmux",
"-L",
socket,
"new-session",
"-d",
"-s",
"test",
"-x",
"220",
"-y",
"50",
],
check=True,
)
# Enable bell monitoring so window_bell_flag is set when a bell is received
subprocess.run(
["tmux", "-L", socket, "set-window-option", "-t", "test", "monitor-bell", "on"],
check=True,
)
yield socket
# Teardown: kill the isolated server (suppress errors if already dead)
subprocess.run(
["tmux", "-L", socket, "kill-server"],
capture_output=True,
)
@pytest.fixture(autouse=True)
def use_tmp_state(tmp_path, monkeypatch):
"""Redirect state and PID files to tmp_path for test isolation."""
tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path)
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)
# ---------------------------------------------------------------------------
# Internal helper: patched run_tmux that uses the isolated test socket
# ---------------------------------------------------------------------------
def make_run_tmux_for_socket(socket: str):
"""Return an async run_tmux substitute that routes all tmux calls through *socket*.
Prepends ``-L <socket>`` to every tmux invocation so the test server
is used instead of the default server.
"""
async def patched_run_tmux(*args: str) -> str:
proc = await asyncio.create_subprocess_exec(
"tmux",
"-L",
socket,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout_bytes, stderr_bytes = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(stderr_bytes.decode("utf-8", errors="replace"))
return stdout_bytes.decode("utf-8", errors="replace")
return patched_run_tmux
# ---------------------------------------------------------------------------
# Integration tests
# ---------------------------------------------------------------------------
@pytest.mark.integration
async def test_enumerate_sessions_finds_test_session(tmux_server):
"""enumerate_sessions discovers the 'test' session on the isolated tmux server."""
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux):
sessions = await enumerate_sessions()
assert "test" in sessions
@pytest.mark.integration
async def test_capture_pane_returns_content(tmux_server):
"""tmux capture-pane returns output that includes what was echoed to the session."""
tmux(tmux_server, "send-keys", "-t", "test", "echo hello-world", "Enter")
await asyncio.sleep(0.5)
# Use the tmux helper directly: capture-pane -p captures the pane content to stdout
content = tmux(tmux_server, "capture-pane", "-p", "-t", "test")
assert "hello-world" in content
@pytest.mark.integration
async def test_bell_flag_detected_after_printf_bell(tmux_server):
"""poll_bell_flag returns True after a bell character is sent to the test session."""
tmux(tmux_server, "send-keys", "-t", "test", r"printf '\a'", "Enter")
# Allow tmux time to propagate the bell and set window_bell_flag
await asyncio.sleep(1.0)
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux):
result = await poll_bell_flag("test")
assert result is True
@pytest.mark.integration
async def test_full_poll_cycle_via_api(tmux_server):
"""_run_poll_cycle with patched run_tmux adds 'test' to session_order in state
and populates the in-memory snapshot cache with non-empty content."""
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with (
patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux),
patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux),
):
await _run_poll_cycle()
state = state_mod.load_state()
assert "test" in state["session_order"]
# Verify snapshots were captured and stored — Critical Issue #1 regression guard.
# If snapshot_all() return value is discarded, get_snapshots() returns {} and
# snapshots["test"] falls back to "", causing this assertion to fail.
snapshots = get_snapshots()
assert "test" in snapshots, (
"snapshot cache must contain an entry for the 'test' session"
)
@pytest.mark.integration
async def test_state_file_written_atomically_by_poll_cycle(tmux_server):
"""After _run_poll_cycle, state.json exists, no .tmp file remains, content is valid JSON."""
patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with (
patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux),
patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux),
):
await _run_poll_cycle()
state_path = state_mod.STATE_PATH
tmp_path = state_mod.STATE_PATH.parent / (state_mod.STATE_PATH.name + ".tmp")
# state.json must exist after a successful poll cycle
assert state_path.exists(), "state.json was not written by _run_poll_cycle"
# The temporary file must be gone (atomic write completed)
assert not tmp_path.exists(), (
".tmp file was left behind (atomic write may have failed)"
)
# File content must be valid JSON
content = state_path.read_text()
data = json.loads(content)
assert isinstance(data, dict), "state.json does not contain a JSON object"
+251
View File
@@ -0,0 +1,251 @@
"""
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() == {}
+311
View File
@@ -0,0 +1,311 @@
"""
Tests for coordinator/state.py — state schema factories and I/O.
All acceptance-criteria tests are defined here.
"""
import asyncio
import time
from pathlib import Path
import pytest
from coordinator.state import (
empty_bell,
empty_device,
empty_state,
prune_devices,
read_state,
register_device,
state_lock,
write_state,
)
# ---------------------------------------------------------------------------
# autouse fixture — redirect STATE_DIR and STATE_PATH to a tmp directory
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def use_tmp_state_dir(tmp_path, monkeypatch):
"""Redirect state I/O to a fresh temp directory for every test."""
tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path)
# ---------------------------------------------------------------------------
# empty_state()
# ---------------------------------------------------------------------------
def test_empty_state_has_required_top_level_keys():
state = empty_state()
assert "active_session" in state
assert "session_order" in state
assert "sessions" in state
assert "devices" in state
def test_empty_state_active_session_is_none():
state = empty_state()
assert state["active_session"] is None
def test_empty_state_session_order_is_empty_list():
state = empty_state()
assert state["session_order"] == []
def test_empty_state_sessions_is_empty_dict():
state = empty_state()
assert state["sessions"] == {}
def test_empty_state_devices_is_empty_dict():
state = empty_state()
assert state["devices"] == {}
def test_empty_state_returns_independent_dicts():
"""Mutating one state must not affect another."""
s1 = empty_state()
s2 = empty_state()
s1["session_order"].append("my-session")
s1["sessions"]["foo"] = {}
s1["devices"]["bar"] = {}
assert s2["session_order"] == []
assert s2["sessions"] == {}
assert s2["devices"] == {}
# ---------------------------------------------------------------------------
# empty_bell()
# ---------------------------------------------------------------------------
def test_empty_bell_has_required_keys():
bell = empty_bell()
assert "last_fired_at" in bell
assert "seen_at" in bell
assert "unseen_count" in bell
def test_empty_bell_last_fired_at_is_none():
bell = empty_bell()
assert bell["last_fired_at"] is None
def test_empty_bell_seen_at_is_none():
bell = empty_bell()
assert bell["seen_at"] is None
def test_empty_bell_unseen_count_is_zero():
bell = empty_bell()
assert bell["unseen_count"] == 0
# ---------------------------------------------------------------------------
# empty_device(device_id, label)
# ---------------------------------------------------------------------------
def test_empty_device_has_required_keys():
device = empty_device("dev-1", "My Laptop")
assert "label" in device
assert "viewing_session" in device
assert "view_mode" in device
assert "last_interaction_at" in device
assert "last_heartbeat_at" in device
def test_empty_device_label_set_correctly():
device = empty_device("dev-1", "My Laptop")
assert device["label"] == "My Laptop"
def test_empty_device_viewing_session_is_none():
device = empty_device("dev-1", "My Laptop")
assert device["viewing_session"] is None
def test_empty_device_view_mode_is_grid():
device = empty_device("dev-1", "My Laptop")
assert device["view_mode"] == "grid"
def test_empty_device_timestamps_are_recent():
"""last_interaction_at and last_heartbeat_at should be close to now."""
before = time.time()
device = empty_device("dev-1", "My Laptop")
after = time.time()
assert before <= device["last_interaction_at"] <= after
assert before <= device["last_heartbeat_at"] <= after
# ---------------------------------------------------------------------------
# state_lock
# ---------------------------------------------------------------------------
def test_state_lock_is_asyncio_lock():
assert isinstance(state_lock, asyncio.Lock)
# ---------------------------------------------------------------------------
# load_state / save_state / read_state / write_state
# ---------------------------------------------------------------------------
async def test_read_state_returns_empty_when_no_file():
"""read_state() returns empty_state() when STATE_PATH does not exist."""
state = await read_state()
assert state == empty_state()
async def test_write_then_read_roundtrip():
"""write_state() persists state; subsequent read_state() returns it intact."""
original = empty_state()
original["active_session"] = "my-session"
original["session_order"] = ["my-session"]
await write_state(original)
loaded = await read_state()
assert loaded == original
async def test_write_creates_state_dir_if_missing():
"""save_state() must create STATE_DIR (and parents) if they do not exist."""
import coordinator.state as state_mod
# The tmp "state" subdirectory should not exist yet.
assert not state_mod.STATE_DIR.exists()
await write_state(empty_state())
assert state_mod.STATE_DIR.exists()
async def test_write_is_atomic_no_tmp_file_left():
"""After write_state(), no .tmp file should remain on disk."""
import coordinator.state as state_mod
await write_state(empty_state())
tmp_file = Path(str(state_mod.STATE_PATH) + ".tmp")
assert not tmp_file.exists()
async def test_concurrent_writes_do_not_corrupt():
"""Two concurrent write_state() calls must leave valid JSON matching one write."""
state_a = empty_state()
state_a["active_session"] = "session-a"
state_b = empty_state()
state_b["active_session"] = "session-b"
await asyncio.gather(
write_state(state_a),
write_state(state_b),
)
final = await read_state()
# Final state must be exactly one of the two known states — no corruption.
assert final == state_a or final == state_b
# ---------------------------------------------------------------------------
# register_device()
# ---------------------------------------------------------------------------
def test_register_device_adds_new_device():
"""register_device() creates a new device entry if device_id is not present."""
state = empty_state()
register_device(state, "dev-1", "My Laptop", None, "grid", time.time())
assert "dev-1" in state["devices"]
def test_register_device_updates_existing_device():
"""register_device() updates label, viewing_session, view_mode, last_interaction_at."""
state = empty_state()
now = time.time()
register_device(state, "dev-1", "Old Label", "session-a", "grid", now)
later = now + 10
register_device(state, "dev-1", "New Label", "session-b", "fullscreen", later)
device = state["devices"]["dev-1"]
assert device["label"] == "New Label"
assert device["viewing_session"] == "session-b"
assert device["view_mode"] == "fullscreen"
assert device["last_interaction_at"] == later
def test_register_device_sets_heartbeat_timestamp():
"""register_device() always refreshes last_heartbeat_at to current time.time()."""
state = empty_state()
before = time.time()
register_device(state, "dev-1", "My Laptop", None, "grid", before)
after = time.time()
device = state["devices"]["dev-1"]
assert before <= device["last_heartbeat_at"] <= after
# ---------------------------------------------------------------------------
# prune_devices()
# ---------------------------------------------------------------------------
def test_prune_devices_removes_stale():
"""prune_devices() removes devices whose last_heartbeat_at is older than ttl."""
state = empty_state()
old_time = time.time() - 400 # 400s ago, beyond default 300s TTL
state["devices"]["stale-dev"] = {
"label": "Stale",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": old_time,
"last_heartbeat_at": old_time,
}
prune_devices(state)
assert "stale-dev" not in state["devices"]
def test_prune_devices_keeps_fresh():
"""prune_devices() keeps devices whose last_heartbeat_at is within ttl."""
state = empty_state()
recent_time = time.time() - 100 # 100s ago, within default 300s TTL
state["devices"]["fresh-dev"] = {
"label": "Fresh",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": recent_time,
"last_heartbeat_at": recent_time,
}
prune_devices(state)
assert "fresh-dev" in state["devices"]
def test_prune_devices_returns_list_of_removed_ids():
"""prune_devices() returns the list of device IDs that were removed."""
state = empty_state()
old_time = time.time() - 400
state["devices"]["stale-1"] = {
"label": "Stale 1",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": old_time,
"last_heartbeat_at": old_time,
}
state["devices"]["stale-2"] = {
"label": "Stale 2",
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": old_time,
"last_heartbeat_at": old_time,
}
removed = prune_devices(state)
assert sorted(removed) == ["stale-1", "stale-2"]
+232
View File
@@ -0,0 +1,232 @@
"""
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"