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
+177
View File
@@ -0,0 +1,177 @@
"""
Bell flag polling and unseen_count tracking for the tmux-web coordinator.
Based on spike findings: reading the tmux window_bell_flag does NOT clear it.
The flag persists until the window is made active inside tmux.
In-memory state:
_bell_seen — tracks whether the bell flag was '1' on the last poll,
keyed by session_name. Used to detect 0→1 transitions.
Public API:
poll_bell_flag(session_name) → bool
process_bell_flags(session_names, state) → bool
should_clear_bell(session_name, state) → bool
apply_bell_clear_rule(state) → list[str]
"""
import time
from coordinator.sessions import run_tmux
from coordinator.state import empty_bell
# ---------------------------------------------------------------------------
# In-memory tracking: session_name → bool (was flag set on last poll?)
# ---------------------------------------------------------------------------
_bell_seen: dict[str, bool] = {}
# ---------------------------------------------------------------------------
# poll_bell_flag
# ---------------------------------------------------------------------------
async def poll_bell_flag(session_name: str) -> bool:
"""Poll the tmux window_bell_flag for session_name.
Calls: tmux display-message -t <name> -p #{window_bell_flag}
Returns True if the output is '1', False otherwise (including on errors).
Note: reading does NOT clear the tmux bell flag.
"""
try:
output = await run_tmux(
"display-message", "-t", session_name, "-p", "#{window_bell_flag}"
)
return output.strip() == "1"
except RuntimeError:
return False
# ---------------------------------------------------------------------------
# process_bell_flags
# ---------------------------------------------------------------------------
async def process_bell_flags(session_names: list[str], state: dict) -> bool:
"""Poll bell flags for all sessions and update state accordingly.
NOTE: The tmux alert-bell hook (POST /api/sessions/{name}/bell) is the
primary bell detection mechanism. window_bell_flag is only set when NO
tmux client is watching the window — with an SSH/WezTerm session attached,
the flag is never set even though the bell fires. This function serves as
a fallback for sessions that fired before the coordinator registered the hook.
Detects 0→1 transitions using _bell_seen and increments unseen_count.
Persistent '1' flags (1→1) are not double-counted.
When flag clears (1→0), _bell_seen is reset so the next '1' counts as
a new, separate bell event.
Ensures the bell sub-dict exists for each session in state.
Args:
session_names: List of session names to poll.
state: Mutable state dict (modified in-place).
Returns:
True if any bell state changed (new bell detected), False otherwise.
"""
changed = False
for name in session_names:
# Ensure session entry and bell sub-dict exist
if name not in state["sessions"]:
state["sessions"][name] = {}
if "bell" not in state["sessions"][name]:
state["sessions"][name]["bell"] = empty_bell()
bell = state["sessions"][name]["bell"]
flag_set = await poll_bell_flag(name)
previously_seen = _bell_seen.get(name, False)
if flag_set and not previously_seen:
# 0→1 transition: new bell event
bell["unseen_count"] += 1
bell["last_fired_at"] = time.time()
_bell_seen[name] = True
changed = True
elif not flag_set and previously_seen:
# 1→0: flag cleared — reset tracking so next '1' is a new bell
# Do NOT decrement unseen_count
_bell_seen[name] = False
return changed
# ---------------------------------------------------------------------------
# Bell clear rule constants
# ---------------------------------------------------------------------------
_INTERACTION_WINDOW_SECONDS: float = 60.0
# ---------------------------------------------------------------------------
# should_clear_bell
# ---------------------------------------------------------------------------
def should_clear_bell(session_name: str, state: dict) -> bool:
"""Return True if any connected device qualifies to globally acknowledge bells.
A session's bells should be cleared when ANY device satisfies ALL of:
- viewing_session == session_name
- view_mode == 'fullscreen'
- last_interaction_at > now - _INTERACTION_WINDOW_SECONDS
Args:
session_name: Name of the tmux session to check.
state: Current application state dict.
Returns:
True if at least one device meets all conditions, False otherwise.
"""
cutoff = time.time() - _INTERACTION_WINDOW_SECONDS
for device in state["devices"].values():
if (
device["viewing_session"] == session_name
and device["view_mode"] == "fullscreen"
and device["last_interaction_at"] > cutoff
):
return True
return False
# ---------------------------------------------------------------------------
# apply_bell_clear_rule
# ---------------------------------------------------------------------------
def apply_bell_clear_rule(state: dict) -> list[str]:
"""Check every session with unseen_count > 0 against the active-device gate.
For each qualifying session (unseen_count > 0 AND should_clear_bell):
- Resets unseen_count to 0
- Sets seen_at to now
- Resets _bell_seen[name] = False
Args:
state: Mutable application state dict (modified in-place).
Returns:
List of session names whose bells were cleared.
"""
cleared: list[str] = []
now = time.time()
for name, session in state["sessions"].items():
bell = session.get("bell")
if bell is None or bell.get("unseen_count", 0) == 0:
continue
if should_clear_bell(name, state):
bell["unseen_count"] = 0
bell["seen_at"] = now
_bell_seen[name] = False
cleared.append(name)
return cleared
+345
View File
@@ -0,0 +1,345 @@
"""
FastAPI coordinator application for tmux-web.
Entry point for the coordinator service. Exposes:
GET /health → {"status": "ok"}
Background poll loop reconciles tmux session state every POLL_INTERVAL seconds.
"""
import asyncio
import contextlib
import logging
import os
import pathlib
import time
from typing import Literal
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from coordinator.bells import apply_bell_clear_rule, process_bell_flags
from coordinator.sessions import (
enumerate_sessions,
get_session_list,
get_snapshots,
run_tmux,
snapshot_all,
update_session_cache,
)
from coordinator.state import (
empty_bell,
load_state,
prune_devices,
read_state,
register_device,
save_state,
state_lock,
)
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
_log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Module-level task reference
# ---------------------------------------------------------------------------
_poll_task: asyncio.Task | None = None
# ---------------------------------------------------------------------------
# Poll cycle
# ---------------------------------------------------------------------------
async def _run_poll_cycle() -> None:
"""Perform one full poll cycle, all operations executed under state_lock."""
async with state_lock:
# 1. Enumerate live tmux sessions
names = await enumerate_sessions()
name_set = set(names)
# 2. Capture pane snapshots and update in-memory snapshot cache
new_snapshots = await snapshot_all(names)
update_session_cache(names, new_snapshots)
# 3. Load current persisted state
state = load_state()
# 4. Reconcile session_order: preserve user ordering, add new, remove deleted
state["session_order"] = [s for s in state["session_order"] if s in name_set]
existing_order_set = set(state["session_order"])
for name in names:
if name not in existing_order_set:
state["session_order"].append(name)
# 5. Ensure bell entries exist for every current session
for name in names:
if name not in state["sessions"]:
state["sessions"][name] = {}
if "bell" not in state["sessions"][name]:
state["sessions"][name]["bell"] = empty_bell()
# 6. Remove state entries for sessions that no longer exist
deleted = [s for s in list(state["sessions"]) if s not in name_set]
for name in deleted:
del state["sessions"][name]
# 7. Clear active_session if the session is gone
if state["active_session"] not in name_set:
state["active_session"] = None
# 8. Process bell flags (detect 0→1 transitions, update unseen_count)
await process_bell_flags(names, state)
# 9. Apply bell clear rule (acknowledge bells when device is watching fullscreen)
apply_bell_clear_rule(state)
# 10. Prune devices that haven't sent a heartbeat recently
prune_devices(state)
# 11. Atomically persist the updated state
save_state(state)
# ---------------------------------------------------------------------------
# Poll loop
# ---------------------------------------------------------------------------
async def _poll_loop() -> None:
"""Run _run_poll_cycle() every POLL_INTERVAL seconds, catching all exceptions."""
while True:
try:
await _run_poll_cycle()
except Exception:
_log.exception("poll cycle error")
await asyncio.sleep(POLL_INTERVAL)
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
global _poll_task
# Startup: kill any orphaned ttyd from a previous coordinator run, then
# start the background poll loop.
await kill_orphan_ttyd()
_poll_task = asyncio.create_task(_poll_loop())
# Register tmux alert-bell hook so bells are detected even when clients are attached.
# window_bell_flag is only set when no client watches the window; the hook fires always.
try:
await run_tmux(
"set-hook",
"-g",
"alert-bell",
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
)
except Exception:
pass # tmux not running at startup is OK; hook will be set on first poll
yield
# Shutdown: cancel the poll loop task and wait for it to finish.
if _poll_task is not None:
_poll_task.cancel()
try:
await _poll_task
except (asyncio.CancelledError, Exception):
pass
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(title="tmux-web coordinator", version="0.1.0", lifespan=lifespan)
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class StatePatch(BaseModel):
session_order: list[str]
class HeartbeatPayload(BaseModel):
device_id: str
label: str
viewing_session: str | None
view_mode: Literal["grid", "fullscreen"]
last_interaction_at: float
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/health")
async def health() -> dict[str, str]:
"""Simple liveness check."""
return {"status": "ok"}
@app.get("/api/state")
async def get_state() -> dict:
"""Return the full persistent state."""
return await read_state()
@app.patch("/api/state")
async def patch_state(patch: StatePatch) -> dict:
"""Update session_order in the persistent state and return the updated state."""
async with state_lock:
state = load_state()
state["session_order"] = patch.session_order
save_state(state)
return state
@app.get("/api/sessions")
async def get_sessions() -> list[dict]:
"""Return list of sessions with name, snapshot, and bell data."""
names = get_session_list()
snapshots = get_snapshots()
state = await read_state()
result = []
for name in names:
session_state = state.get("sessions", {}).get(name, {})
bell = session_state.get("bell", empty_bell())
result.append(
{
"name": name,
"snapshot": snapshots.get(name, ""),
"bell": bell,
}
)
return result
@app.post("/api/sessions/{name}/connect")
async def connect_session(name: str) -> dict:
"""Connect to a tmux session via ttyd.
Kills any existing ttyd process, spawns a new one attached to *name*,
and updates the active_session in persistent state.
Returns {active_session: name, ttyd_port: 7682}.
Raises HTTP 404 if *name* is not in the known session list (when non-empty).
"""
known = get_session_list()
if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
await kill_ttyd()
await spawn_ttyd(name)
async with state_lock:
state = load_state()
state["active_session"] = name
save_state(state)
return {"active_session": name, "ttyd_port": TTYD_PORT}
@app.delete("/api/sessions/current")
async def delete_current_session() -> dict:
"""Disconnect the current ttyd session.
Kills the running ttyd process and clears active_session in persistent state.
Returns {active_session: None}.
"""
await kill_ttyd()
async with state_lock:
state = load_state()
state["active_session"] = None
save_state(state)
return {"active_session": None}
@app.post("/api/heartbeat")
async def heartbeat(payload: HeartbeatPayload) -> dict:
"""Register or update a device heartbeat.
Acquires state_lock, loads state, calls register_device() with payload
fields, saves state.
Returns {device_id: str, status: 'ok'}.
Missing device_id or invalid view_mode returns 422 (handled by Pydantic).
"""
async with state_lock:
state = load_state()
register_device(
state,
device_id=payload.device_id,
label=payload.label,
viewing_session=payload.viewing_session,
view_mode=payload.view_mode,
last_interaction_at=payload.last_interaction_at,
)
save_state(state)
return {"device_id": payload.device_id, "status": "ok"}
@app.post("/api/sessions/{name}/bell")
async def receive_bell(name: str) -> dict:
"""Called by tmux alert-bell hook when a bell fires in session *name*.
This is more reliable than polling window_bell_flag because tmux only
sets that flag when no client is attached -- with an SSH/WezTerm session
attached, the flag never gets set even though the bell fires.
"""
async with state_lock:
state = load_state()
if name not in state["sessions"]:
state["sessions"][name] = {}
if "bell" not in state["sessions"][name]:
state["sessions"][name]["bell"] = empty_bell()
bell = state["sessions"][name]["bell"]
bell["unseen_count"] = bell.get("unseen_count", 0) + 1
bell["last_fired_at"] = time.time()
save_state(state)
return {"ok": True, "session": name}
@app.post("/api/internal/setup-hooks")
async def setup_hooks() -> dict:
"""Re-register tmux hooks. Call after tmux server restarts."""
try:
await run_tmux(
"set-hook",
"-g",
"alert-bell",
"run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'",
)
return {"ok": True}
except Exception as e:
return {"ok": False, "error": str(e)}
# ---------------------------------------------------------------------------
# Static file serving — MUST come after all API routes (first-match-wins)
# ---------------------------------------------------------------------------
_FRONTEND_DIR = pathlib.Path(__file__).parent.parent / "frontend"
app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="frontend")
+139
View File
@@ -0,0 +1,139 @@
"""
tmux session enumeration and snapshot helpers for the tmux-web coordinator.
In-memory cache:
_session_list — most-recently-enumerated list of session names.
_snapshots — most-recently-captured pane text, keyed by session name.
Public API:
get_session_list() → list[str]
get_snapshots() → dict[str, str]
update_session_cache(names, snapshots) → None
run_tmux(*args) → str (raises RuntimeError on nonzero exit)
enumerate_sessions() → list[str]
capture_pane(name, lines) → str
snapshot_all(names) → dict[str, str]
"""
import asyncio
# ---------------------------------------------------------------------------
# In-memory cache
# ---------------------------------------------------------------------------
_session_list: list[str] = []
_snapshots: dict[str, str] = {}
def get_session_list() -> list[str]:
"""Return a copy of the cached session name list."""
return list(_session_list)
def get_snapshots() -> dict[str, str]:
"""Return a copy of the cached pane-snapshot dict."""
return dict(_snapshots)
def update_session_cache(names: list[str], snapshots: dict[str, str]) -> None:
"""Replace the in-memory caches with fresh data.
Sets _session_list to *names* and _snapshots to the provided *snapshots* dict.
Callers must pass the return value of snapshot_all() as *snapshots*.
"""
global _session_list, _snapshots
_session_list = list(names)
_snapshots = snapshots
# ---------------------------------------------------------------------------
# Subprocess helpers
# ---------------------------------------------------------------------------
async def run_tmux(*args: str) -> str:
"""Run `tmux <args>` in a subprocess and return stdout as a string.
Raises:
RuntimeError: If the process exits with a nonzero return code.
The error message contains the decoded stderr output.
"""
proc = await asyncio.create_subprocess_exec(
"tmux",
*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")
# ---------------------------------------------------------------------------
# Session enumeration
# ---------------------------------------------------------------------------
async def enumerate_sessions() -> list[str]:
"""Return the list of currently running tmux session names.
Calls ``tmux list-sessions -F #{session_name}``, splits on newlines,
and strips whitespace from each entry.
Returns [] if tmux is not running (RuntimeError from run_tmux).
"""
try:
output = await run_tmux("list-sessions", "-F", "#{session_name}")
except RuntimeError:
return []
names = [line.strip() for line in output.splitlines() if line.strip()]
return names
# ---------------------------------------------------------------------------
# Pane capture
# ---------------------------------------------------------------------------
async def capture_pane(session_name: str, lines: int = 30) -> str:
"""Capture the last *lines* lines of output from *session_name*.
Returns the captured text, or '' on any error.
"""
try:
return await run_tmux(
"capture-pane",
"-p",
"-t",
session_name,
"-S",
f"-{lines}",
)
except RuntimeError:
return ""
async def snapshot_all(names: list[str]) -> dict[str, str]:
"""Capture all sessions concurrently and return a name→text mapping.
Uses asyncio.gather with return_exceptions=True so that individual
failures do not abort the whole batch. Failed sessions map to ''.
Note: this function does not mutate module state — it does not update the module cache.
Callers are responsible for passing the result to update_session_cache.
"""
if not names:
return {}
results = await asyncio.gather(
*[capture_pane(name) for name in names],
return_exceptions=True,
)
snapshots: dict[str, str] = {}
for name, result in zip(names, results):
if isinstance(result, BaseException):
snapshots[name] = ""
else:
snapshots[name] = result
return snapshots
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""
Bell Flag Spike — run ONCE manually to determine tmux bell flag read behavior.
Usage:
python3 coordinator/spike_bell_flag.py
What this tests:
Does `tmux display-message -p "#{window_bell_flag}"` clear the flag when
read, or does the flag persist until the window is visited in tmux?
Expected result (almost certain): the flag persists. Reading it does NOT clear
it. The flag is cleared only when the window is marked active inside tmux.
"""
import subprocess
import time
SESSION = "bell-spike-test"
def run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip()
def main() -> None:
# 1. Create a test session
print("Creating test session...")
subprocess.run(
["tmux", "new-session", "-d", "-s", SESSION, "-x", "80", "-y", "24"], check=True
)
time.sleep(0.2)
# 2. Send a bell to the session
print("Sending bell to session...")
subprocess.run(
["tmux", "send-keys", "-t", SESSION, "printf '\\a'", "Enter"], check=True
)
time.sleep(1.0)
# 3. Read the bell flag (first read)
flag_read1 = run(
["tmux", "display-message", "-t", SESSION, "-p", "#{window_bell_flag}"]
)
print(f"Bell flag (1st read): '{flag_read1}'")
# 4. Read the bell flag immediately again (second read)
flag_read2 = run(
["tmux", "display-message", "-t", SESSION, "-p", "#{window_bell_flag}"]
)
print(f"Bell flag (2nd read): '{flag_read2}'")
# 5. Cleanup
subprocess.run(["tmux", "kill-session", "-t", SESSION])
# 6. Report
print()
if flag_read1 == "1" and flag_read2 == "1":
print("FINDING: Reading does NOT clear the flag. Both reads show '1'.")
print(
"Implementation: use in-memory _bell_seen dict to detect 0→1 transitions."
)
elif flag_read1 == "1" and flag_read2 == "0":
print(
"FINDING: Reading CLEARS the flag. First read shows '1', second shows '0'."
)
print("Implementation: each '1' is a new bell — no transition tracking needed.")
elif flag_read1 == "0":
print("WARNING: Bell flag not set after printf '\\a'. Try running manually:")
print(f" tmux send-keys -t {SESSION} \"printf '\\\\a'\" Enter")
print(
" Then check: tmux display-message -t bell-spike-test -p '#{window_bell_flag}'"
)
else:
print(f"UNEXPECTED: read1={flag_read1!r}, read2={flag_read2!r}")
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
"""
State schema and factory functions for the tmux-web coordinator.
State schema (all values are plain JSON-serialisable dicts):
{
"active_session": str | None,
"session_order": list[str],
"sessions": {
"<name>": {
"bell": {
"last_fired_at": float | None,
"seen_at": float | None,
"unseen_count": int,
}
}
},
"devices": {
"<device_id>": {
"label": str,
"viewing_session": str | None,
"view_mode": "fullscreen" | "grid",
"last_interaction_at": float,
"last_heartbeat_at": float,
}
},
}
"""
import asyncio
import json
import os
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_default_state_dir = Path.home() / ".local" / "share" / "tmux-web"
STATE_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir))
STATE_PATH: Path = STATE_DIR / "state.json"
# ---------------------------------------------------------------------------
# Global asyncio lock — must be acquired before reading or writing state.
# ---------------------------------------------------------------------------
state_lock: asyncio.Lock = asyncio.Lock()
# ---------------------------------------------------------------------------
# Factory functions
# ---------------------------------------------------------------------------
def empty_state() -> dict:
"""Return a fresh, empty top-level state dict.
Every call returns a fully independent object — no shared mutables.
"""
return {
"active_session": None,
"session_order": [],
"sessions": {},
"devices": {},
}
def empty_bell() -> dict:
"""Return a fresh bell sub-dict with all fields reset."""
return {
"last_fired_at": None,
"seen_at": None,
"unseen_count": 0,
}
def empty_device(device_id: str, label: str) -> dict: # noqa: ARG001
"""Return a fresh device sub-dict.
Args:
device_id: Identifier for the device (unused in the dict itself,
kept as a parameter for call-site clarity).
label: Human-readable name for the device.
"""
now = time.time()
return {
"label": label,
"viewing_session": None,
"view_mode": "grid",
"last_interaction_at": now,
"last_heartbeat_at": now,
}
# ---------------------------------------------------------------------------
# Device helpers
# ---------------------------------------------------------------------------
def register_device(
state: dict,
device_id: str,
label: str,
viewing_session: str | None,
view_mode: str,
last_interaction_at: float,
) -> None:
"""Create or update a device entry in state['devices'].
For new devices, seeds the entry via empty_device().
Always refreshes last_heartbeat_at to time.time().
Updates label, viewing_session, view_mode, last_interaction_at.
"""
if device_id not in state["devices"]:
state["devices"][device_id] = empty_device(device_id, label)
device = state["devices"][device_id]
device["label"] = label
device["viewing_session"] = viewing_session
device["view_mode"] = view_mode
device["last_interaction_at"] = last_interaction_at
device["last_heartbeat_at"] = time.time()
def prune_devices(state: dict, ttl_seconds: float = 300.0) -> list[str]:
"""Remove devices whose last_heartbeat_at is older than ttl_seconds.
Returns the list of removed device IDs.
"""
cutoff = time.time() - ttl_seconds
stale = [
device_id
for device_id, device in state["devices"].items()
if device["last_heartbeat_at"] < cutoff
]
for device_id in stale:
del state["devices"][device_id]
return stale
# ---------------------------------------------------------------------------
# Sync I/O helpers (no lock — callers must hold state_lock when appropriate)
# ---------------------------------------------------------------------------
def load_state() -> dict:
"""Read and return state from STATE_PATH.
Returns empty_state() if the file does not exist or contains invalid JSON.
"""
try:
with open(STATE_PATH) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return empty_state()
def save_state(state: dict) -> None:
"""Atomically write *state* to STATE_PATH.
Uses the write-to-tmp-then-os.replace pattern so readers never see a
partial file. Creates STATE_DIR (and parents) if it does not exist.
"""
STATE_DIR.mkdir(parents=True, exist_ok=True)
tmp = Path(str(STATE_PATH) + ".tmp")
tmp.write_text(json.dumps(state, indent=2))
os.replace(tmp, STATE_PATH)
# ---------------------------------------------------------------------------
# Async wrappers — acquire state_lock before touching the file
# ---------------------------------------------------------------------------
async def read_state() -> dict:
"""Async read: acquires state_lock, then delegates to load_state()."""
async with state_lock:
return load_state()
async def write_state(state: dict) -> None:
"""Async write: acquires state_lock, then delegates to save_state()."""
async with state_lock:
save_state(state)
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"
+156
View File
@@ -0,0 +1,156 @@
"""
ttyd process lifecycle management for the tmux-web coordinator.
Constants:
TTYD_PID_DIR — directory for the PID file (default: ~/.local/share/tmux-web/)
TTYD_PID_PATH — full path to the PID file (TTYD_PID_DIR / 'ttyd.pid')
TTYD_PORT — port ttyd listens on (7682)
Module state:
_active_process — the currently running ttyd subprocess (or None)
Public API:
spawn_ttyd(session_name) — spawn ttyd attached to a tmux session, write PID file
kill_ttyd() — kill the running ttyd process, clean up PID file
kill_orphan_ttyd() — kill any orphaned ttyd from a previous coordinator run
"""
import asyncio
import os
import signal
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths and constants
# ---------------------------------------------------------------------------
_default_ttyd_pid_dir = Path.home() / ".local" / "share" / "tmux-web"
TTYD_PID_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_ttyd_pid_dir))
TTYD_PID_PATH: Path = TTYD_PID_DIR / "ttyd.pid"
TTYD_PORT: int = 7682
# ---------------------------------------------------------------------------
# Module state
# ---------------------------------------------------------------------------
_active_process: asyncio.subprocess.Process | None = None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def kill_ttyd() -> bool:
"""Kill the running ttyd process and clean up the PID file.
Reads the PID from TTYD_PID_PATH. If no PID file exists, returns False.
If the file content is not a valid integer, removes the file and returns False.
Checks whether the process is alive via ``os.kill(pid, 0)``. If the
process is already gone (ProcessLookupError), cleans up and returns True.
Otherwise sends SIGTERM and polls every 0.1 s for up to 2 s waiting for
the process to exit. The PID file and ``_active_process`` are cleared in
all cases before returning.
Uses ``asyncio.sleep`` for polling to avoid blocking the event loop.
Returns:
True — process was killed or was already dead.
False — no PID file found, or PID file contained invalid content.
"""
global _active_process
if not TTYD_PID_PATH.exists():
return False
try:
pid = int(TTYD_PID_PATH.read_text().strip())
except ValueError:
TTYD_PID_PATH.unlink(missing_ok=True)
return False
# Check whether the process is still alive.
try:
os.kill(pid, 0)
except ProcessLookupError:
# Already dead — clean up and report success.
TTYD_PID_PATH.unlink(missing_ok=True)
_active_process = None
return True
# Process is alive — ask it to terminate.
os.kill(pid, signal.SIGTERM)
# Poll up to 2 s for the process to exit, yielding to the event loop each iteration.
deadline = time.time() + 2.0
while time.time() < deadline:
try:
os.kill(pid, 0)
except (ProcessLookupError, PermissionError):
break
await asyncio.sleep(0.1)
# Always clean up regardless of whether the process exited in time.
TTYD_PID_PATH.unlink(missing_ok=True)
_active_process = None
return True
async def kill_orphan_ttyd() -> bool:
"""Kill any orphaned ttyd process left over from a previous coordinator run.
On coordinator startup, checks for a stale PID file from a previous run.
If found, kills the process (if still running) and removes the PID file.
Prevents two ttyd instances running simultaneously after a coordinator
restart or crash.
Delegates to kill_ttyd() for all process management and PID file cleanup.
Returns:
True — an orphan was found (process was dead or alive).
False — no PID file found, or PID file contained invalid content.
"""
return await kill_ttyd()
async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
"""Spawn a ttyd process attached to *session_name* via ``tmux attach``.
Runs::
ttyd -W -m 3 -p 7682 tmux attach -t <session_name>
stdout and stderr are discarded (DEVNULL). The PID is written to
TTYD_PID_PATH. The process handle is stored in ``_active_process``.
Args:
session_name: The tmux session name to attach to.
Returns:
The asyncio.subprocess.Process object for the spawned ttyd.
"""
global _active_process
proc = await asyncio.create_subprocess_exec(
"ttyd",
"-W",
"-m",
"3",
"-p",
str(TTYD_PORT),
"tmux",
"attach",
"-t",
session_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
# Write PID file (create parent dirs if needed)
TTYD_PID_DIR.mkdir(parents=True, exist_ok=True)
TTYD_PID_PATH.write_text(str(proc.pid))
_active_process = proc
return proc