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:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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() == {}
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user