refactor: rename coordinator → muxplex package, move frontend inside as package data

This commit is contained in:
Brian Krabach
2026-03-28 02:02:50 -07:00
parent be26d40a31
commit 74b63033d7
30 changed files with 95 additions and 95 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
""" """
Bell flag polling and unseen_count tracking for the tmux-web coordinator. Bell flag polling and unseen_count tracking for the tmux-web muxplex.
Based on spike findings: reading the tmux window_bell_flag does NOT clear it. 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. The flag persists until the window is made active inside tmux.
@@ -17,8 +17,8 @@ Public API:
import time import time
from coordinator.sessions import run_tmux from muxplex.sessions import run_tmux
from coordinator.state import empty_bell from muxplex.state import empty_bell
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# In-memory tracking: session_name → bool (was flag set on last poll?) # In-memory tracking: session_name → bool (was flag set on last poll?)

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 669 B

Before

Width:  |  Height:  |  Size: 401 B

After

Width:  |  Height:  |  Size: 401 B

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

+5 -5
View File
@@ -19,8 +19,8 @@ from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from coordinator.bells import apply_bell_clear_rule, process_bell_flags from muxplex.bells import apply_bell_clear_rule, process_bell_flags
from coordinator.sessions import ( from muxplex.sessions import (
enumerate_sessions, enumerate_sessions,
get_session_list, get_session_list,
get_snapshots, get_snapshots,
@@ -28,7 +28,7 @@ from coordinator.sessions import (
snapshot_all, snapshot_all,
update_session_cache, update_session_cache,
) )
from coordinator.state import ( from muxplex.state import (
empty_bell, empty_bell,
load_state, load_state,
prune_devices, prune_devices,
@@ -37,7 +37,7 @@ from coordinator.state import (
save_state, save_state,
state_lock, state_lock,
) )
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Configuration # Configuration
@@ -341,5 +341,5 @@ async def setup_hooks() -> dict:
# Static file serving — MUST come after all API routes (first-match-wins) # Static file serving — MUST come after all API routes (first-match-wins)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_FRONTEND_DIR = pathlib.Path(__file__).parent.parent / "frontend" _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="frontend") app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="frontend")
@@ -1,5 +1,5 @@
""" """
tmux session enumeration and snapshot helpers for the tmux-web coordinator. tmux session enumeration and snapshot helpers for the tmux-web muxplex.
In-memory cache: In-memory cache:
_session_list most-recently-enumerated list of session names. _session_list most-recently-enumerated list of session names.
+1 -1
View File
@@ -1,5 +1,5 @@
""" """
State schema and factory functions for the tmux-web coordinator. State schema and factory functions for the tmux-web muxplex.
State schema (all values are plain JSON-serialisable dicts): State schema (all values are plain JSON-serialisable dicts):
@@ -5,7 +5,7 @@ Tests for coordinator/main.py — FastAPI skeleton, lifespan, /health endpoint.
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from coordinator.main import app from muxplex.main import app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -19,26 +19,26 @@ def patch_startup_and_state(tmp_path, monkeypatch):
# Redirect state files # Redirect state files
tmp_state_dir = tmp_path / "state" tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json" tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
# Redirect PID files # Redirect PID files
tmp_pid_dir = tmp_path / "ttyd" tmp_pid_dir = tmp_path / "ttyd"
tmp_pid_path = tmp_pid_dir / "ttyd.pid" tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir) monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path) monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async) # Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
async def _mock_kill_orphan(): async def _mock_kill_orphan():
return False return False
monkeypatch.setattr("coordinator.main.kill_orphan_ttyd", _mock_kill_orphan) monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles # Replace _poll_loop with a no-op so tests don't spin up real poll cycles
async def noop_poll_loop() -> None: async def noop_poll_loop() -> None:
pass pass
monkeypatch.setattr("coordinator.main._poll_loop", noop_poll_loop) monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -101,7 +101,7 @@ def test_get_state_active_session_is_none_initially(client):
def test_patch_state_updates_session_order(client): def test_patch_state_updates_session_order(client):
"""PATCH /api/state updates session_order and persists the change.""" """PATCH /api/state updates session_order and persists the change."""
from coordinator.state import load_state, save_state from muxplex.state import load_state, save_state
# Write initial state with a known session order # Write initial state with a known session order
initial_state = { initial_state = {
@@ -148,9 +148,9 @@ def test_patch_state_ignores_unknown_fields(client):
def test_get_sessions_returns_list(client, monkeypatch): def test_get_sessions_returns_list(client, monkeypatch):
"""GET /api/sessions must return a JSON list.""" """GET /api/sessions must return a JSON list."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
monkeypatch.setattr( monkeypatch.setattr(
"coordinator.main.get_snapshots", lambda: {"alpha": "some text"} "muxplex.main.get_snapshots", lambda: {"alpha": "some text"}
) )
response = client.get("/api/sessions") response = client.get("/api/sessions")
@@ -162,10 +162,10 @@ def test_get_sessions_returns_list(client, monkeypatch):
def test_get_sessions_each_item_has_required_fields(client, monkeypatch): def test_get_sessions_each_item_has_required_fields(client, monkeypatch):
"""Each item in GET /api/sessions must have name, snapshot, and bell fields.""" """Each item in GET /api/sessions must have name, snapshot, and bell fields."""
from coordinator.state import save_state from muxplex.state import save_state
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["beta"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["beta"])
monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {"beta": "output"}) monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"beta": "output"})
save_state( save_state(
{ {
"active_session": None, "active_session": None,
@@ -191,9 +191,9 @@ def test_get_sessions_each_item_has_required_fields(client, monkeypatch):
def test_get_sessions_includes_snapshot_text(client, monkeypatch): def test_get_sessions_includes_snapshot_text(client, monkeypatch):
"""GET /api/sessions snapshot field must contain the cached capture-pane text.""" """GET /api/sessions snapshot field must contain the cached capture-pane text."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["gamma"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["gamma"])
monkeypatch.setattr( monkeypatch.setattr(
"coordinator.main.get_snapshots", "muxplex.main.get_snapshots",
lambda: {"gamma": "hello from tmux pane"}, lambda: {"gamma": "hello from tmux pane"},
) )
@@ -207,11 +207,11 @@ def test_get_sessions_includes_snapshot_text(client, monkeypatch):
def test_get_sessions_includes_bell_state(client, monkeypatch): def test_get_sessions_includes_bell_state(client, monkeypatch):
"""GET /api/sessions bell field must include unseen_count from persistent state.""" """GET /api/sessions bell field must include unseen_count from persistent state."""
from coordinator.state import save_state from muxplex.state import save_state
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["delta"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["delta"])
monkeypatch.setattr( monkeypatch.setattr(
"coordinator.main.get_snapshots", lambda: {"delta": "pane text"} "muxplex.main.get_snapshots", lambda: {"delta": "pane text"}
) )
save_state( save_state(
{ {
@@ -239,8 +239,8 @@ def test_get_sessions_includes_bell_state(client, monkeypatch):
def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch): 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.""" """GET /api/sessions must return an empty list when there are no sessions."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: []) monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {}) monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
response = client.get("/api/sessions") response = client.get("/api/sessions")
assert response.status_code == 200 assert response.status_code == 200
@@ -254,17 +254,17 @@ def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch):
def test_connect_session_returns_200(client, monkeypatch): def test_connect_session_returns_200(client, monkeypatch):
"""POST /api/sessions/{name}/connect returns 200 and correct body when session exists.""" """POST /api/sessions/{name}/connect returns 200 and correct body when session exists."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
async def mock_kill(): async def mock_kill():
return True return True
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
async def mock_spawn(name): async def mock_spawn(name):
pass pass
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn) monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
response = client.post("/api/sessions/alpha/connect") response = client.post("/api/sessions/alpha/connect")
assert response.status_code == 200 assert response.status_code == 200
@@ -275,19 +275,19 @@ def test_connect_session_returns_200(client, monkeypatch):
def test_connect_session_sets_active_session(client, monkeypatch): def test_connect_session_sets_active_session(client, monkeypatch):
"""POST /api/sessions/{name}/connect persists active_session to state.""" """POST /api/sessions/{name}/connect persists active_session to state."""
from coordinator.state import load_state from muxplex.state import load_state
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
async def mock_kill(): async def mock_kill():
return True return True
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
async def mock_spawn(name): async def mock_spawn(name):
pass pass
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn) monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
client.post("/api/sessions/alpha/connect") client.post("/api/sessions/alpha/connect")
@@ -297,7 +297,7 @@ def test_connect_session_sets_active_session(client, monkeypatch):
def test_connect_session_kills_existing_ttyd(client, monkeypatch): def test_connect_session_kills_existing_ttyd(client, monkeypatch):
"""POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd.""" """POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
call_order = [] call_order = []
@@ -308,8 +308,8 @@ def test_connect_session_kills_existing_ttyd(client, monkeypatch):
async def mock_spawn(name): async def mock_spawn(name):
call_order.append(("spawn", name)) call_order.append(("spawn", name))
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn) monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
response = client.post("/api/sessions/alpha/connect") response = client.post("/api/sessions/alpha/connect")
assert response.status_code == 200 assert response.status_code == 200
@@ -318,7 +318,7 @@ def test_connect_session_kills_existing_ttyd(client, monkeypatch):
def test_connect_nonexistent_session_returns_404(client, monkeypatch): def test_connect_nonexistent_session_returns_404(client, monkeypatch):
"""POST /api/sessions/{name}/connect returns 404 when session is not in list.""" """POST /api/sessions/{name}/connect returns 404 when session is not in list."""
monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha", "beta"]) monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha", "beta"])
response = client.post("/api/sessions/gamma/connect") response = client.post("/api/sessions/gamma/connect")
assert response.status_code == 404 assert response.status_code == 404
@@ -331,7 +331,7 @@ def test_connect_nonexistent_session_returns_404(client, monkeypatch):
def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch): def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
"""DELETE /api/sessions/current kills ttyd and clears active_session.""" """DELETE /api/sessions/current kills ttyd and clears active_session."""
from coordinator.state import load_state, save_state from muxplex.state import load_state, save_state
# Set up initial state with active session # Set up initial state with active session
save_state( save_state(
@@ -349,7 +349,7 @@ def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
kill_called.append(True) kill_called.append(True)
return True return True
monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
response = client.delete("/api/sessions/current") response = client.delete("/api/sessions/current")
assert response.status_code == 200 assert response.status_code == 200
@@ -488,7 +488,7 @@ def test_receive_bell_returns_ok_and_session_name(client):
def test_receive_bell_increments_unseen_count(client): def test_receive_bell_increments_unseen_count(client):
"""POST /api/sessions/{name}/bell increments unseen_count in state.""" """POST /api/sessions/{name}/bell increments unseen_count in state."""
from coordinator.state import load_state from muxplex.state import load_state
client.post("/api/sessions/my-session/bell") client.post("/api/sessions/my-session/bell")
@@ -499,7 +499,7 @@ def test_receive_bell_increments_unseen_count(client):
def test_receive_bell_creates_session_entry_if_absent(client): def test_receive_bell_creates_session_entry_if_absent(client):
"""POST /api/sessions/{name}/bell creates session/bell entries if missing.""" """POST /api/sessions/{name}/bell creates session/bell entries if missing."""
from coordinator.state import load_state from muxplex.state import load_state
# Ensure session does not exist in state yet # Ensure session does not exist in state yet
client.post("/api/sessions/brand-new/bell") client.post("/api/sessions/brand-new/bell")
@@ -511,7 +511,7 @@ def test_receive_bell_creates_session_entry_if_absent(client):
def test_receive_bell_multiple_calls_accumulate(client): def test_receive_bell_multiple_calls_accumulate(client):
"""Three POST calls to the bell endpoint accumulate unseen_count to 3.""" """Three POST calls to the bell endpoint accumulate unseen_count to 3."""
from coordinator.state import load_state from muxplex.state import load_state
for _ in range(3): for _ in range(3):
client.post("/api/sessions/multi-session/bell") client.post("/api/sessions/multi-session/bell")
@@ -525,7 +525,7 @@ def test_receive_bell_sets_last_fired_at(client):
"""POST /api/sessions/{name}/bell sets last_fired_at to a recent timestamp.""" """POST /api/sessions/{name}/bell sets last_fired_at to a recent timestamp."""
import time import time
from coordinator.state import load_state from muxplex.state import load_state
before = time.time() before = time.time()
client.post("/api/sessions/timed-session/bell") client.post("/api/sessions/timed-session/bell")
@@ -546,7 +546,7 @@ def test_setup_hooks_returns_ok(client, monkeypatch):
"""POST /api/internal/setup-hooks returns {"ok": True} when tmux hook registers.""" """POST /api/internal/setup-hooks returns {"ok": True} when tmux hook registers."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
monkeypatch.setattr("coordinator.main.run_tmux", AsyncMock(return_value="")) monkeypatch.setattr("muxplex.main.run_tmux", AsyncMock(return_value=""))
response = client.post("/api/internal/setup-hooks") response = client.post("/api/internal/setup-hooks")
assert response.status_code == 200 assert response.status_code == 200
@@ -559,7 +559,7 @@ def test_setup_hooks_returns_ok_false_on_error(client, monkeypatch):
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
monkeypatch.setattr( monkeypatch.setattr(
"coordinator.main.run_tmux", "muxplex.main.run_tmux",
AsyncMock(side_effect=RuntimeError("tmux not found")), AsyncMock(side_effect=RuntimeError("tmux not found")),
) )
@@ -575,7 +575,7 @@ def test_setup_hooks_curl_discards_response_body(client, monkeypatch):
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
mock_run_tmux = AsyncMock(return_value="") mock_run_tmux = AsyncMock(return_value="")
monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux) monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux)
response = client.post("/api/internal/setup-hooks") response = client.post("/api/internal/setup-hooks")
assert response.status_code == 200 assert response.status_code == 200
@@ -594,11 +594,11 @@ def test_lifespan_alert_bell_hook_discards_response(monkeypatch):
"""Lifespan startup registers alert-bell hook with curl -o /dev/null to discard response.""" """Lifespan startup registers alert-bell hook with curl -o /dev/null to discard response."""
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from coordinator.main import app from muxplex.main import app
# Mock run_tmux to capture the hook command # Mock run_tmux to capture the hook command
mock_run_tmux = AsyncMock(return_value="") mock_run_tmux = AsyncMock(return_value="")
monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux) monkeypatch.setattr("muxplex.main.run_tmux", mock_run_tmux)
# Trigger lifespan by creating a TestClient # Trigger lifespan by creating a TestClient
with TestClient(app) as _: with TestClient(app) as _:
@@ -8,14 +8,14 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from coordinator.bells import ( from muxplex.bells import (
_bell_seen, _bell_seen,
apply_bell_clear_rule, apply_bell_clear_rule,
poll_bell_flag, poll_bell_flag,
process_bell_flags, process_bell_flags,
should_clear_bell, should_clear_bell,
) )
from coordinator.state import empty_bell, empty_state from muxplex.state import empty_bell, empty_state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -38,14 +38,14 @@ def reset_bell_seen():
async def test_poll_bell_flag_returns_true_when_flag_is_1(): async def test_poll_bell_flag_returns_true_when_flag_is_1():
"""poll_bell_flag returns True when tmux reports window_bell_flag=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")): with patch("muxplex.bells.run_tmux", new=AsyncMock(return_value="1\n")):
result = await poll_bell_flag("my-session") result = await poll_bell_flag("my-session")
assert result is True assert result is True
async def test_poll_bell_flag_returns_false_when_flag_is_0(): async def test_poll_bell_flag_returns_false_when_flag_is_0():
"""poll_bell_flag returns False when tmux reports window_bell_flag=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")): with patch("muxplex.bells.run_tmux", new=AsyncMock(return_value="0\n")):
result = await poll_bell_flag("my-session") result = await poll_bell_flag("my-session")
assert result is False assert result is False
@@ -53,7 +53,7 @@ async def test_poll_bell_flag_returns_false_when_flag_is_0():
async def test_poll_bell_flag_returns_false_on_error(): async def test_poll_bell_flag_returns_false_on_error():
"""poll_bell_flag returns False when run_tmux raises RuntimeError.""" """poll_bell_flag returns False when run_tmux raises RuntimeError."""
with patch( with patch(
"coordinator.bells.run_tmux", "muxplex.bells.run_tmux",
new=AsyncMock(side_effect=RuntimeError("session not found")), new=AsyncMock(side_effect=RuntimeError("session not found")),
): ):
result = await poll_bell_flag("my-session") result = await poll_bell_flag("my-session")
@@ -70,7 +70,7 @@ async def test_process_bell_flags_increments_unseen_count_on_new_bell():
state = empty_state() state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()} state["sessions"]["session-a"] = {"bell": empty_bell()}
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)): with patch("muxplex.bells.poll_bell_flag", new=AsyncMock(return_value=True)):
changed = await process_bell_flags(["session-a"], state) changed = await process_bell_flags(["session-a"], state)
assert changed is True assert changed is True
@@ -83,7 +83,7 @@ async def test_process_bell_flags_does_not_double_count_persistent_flag():
state = empty_state() state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()} state["sessions"]["session-a"] = {"bell": empty_bell()}
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)): with patch("muxplex.bells.poll_bell_flag", new=AsyncMock(return_value=True)):
# First poll — 0→1 transition # First poll — 0→1 transition
await process_bell_flags(["session-a"], state) await process_bell_flags(["session-a"], state)
# Second poll — 1→1 (persistent), should NOT increment again # Second poll — 1→1 (persistent), should NOT increment again
@@ -100,7 +100,7 @@ async def test_process_bell_flags_resets_tracking_when_flag_clears():
# side_effect drives three sequential calls: 0→1, 1→0, 0→1 # side_effect drives three sequential calls: 0→1, 1→0, 0→1
with patch( with patch(
"coordinator.bells.poll_bell_flag", "muxplex.bells.poll_bell_flag",
new=AsyncMock(side_effect=[True, False, True]), new=AsyncMock(side_effect=[True, False, True]),
): ):
for _ in range(3): for _ in range(3):
@@ -114,7 +114,7 @@ async def test_process_bell_flags_no_change_returns_false():
state = empty_state() state = empty_state()
state["sessions"]["session-a"] = {"bell": empty_bell()} state["sessions"]["session-a"] = {"bell": empty_bell()}
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)): with patch("muxplex.bells.poll_bell_flag", new=AsyncMock(return_value=False)):
changed = await process_bell_flags(["session-a"], state) changed = await process_bell_flags(["session-a"], state)
assert changed is False assert changed is False
@@ -126,7 +126,7 @@ async def test_process_bell_flags_creates_bell_entry_if_missing():
state = empty_state() state = empty_state()
state["sessions"]["session-a"] = {} # no 'bell' key state["sessions"]["session-a"] = {} # no 'bell' key
with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)): with patch("muxplex.bells.poll_bell_flag", new=AsyncMock(return_value=False)):
await process_bell_flags(["session-a"], state) await process_bell_flags(["session-a"], state)
assert "bell" in state["sessions"]["session-a"] assert "bell" in state["sessions"]["session-a"]
@@ -2,7 +2,7 @@
import pathlib import pathlib
CSS_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "style.css" CSS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "style.css"
def read_css() -> str: def read_css() -> str:
@@ -4,7 +4,7 @@ import pathlib
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
HTML_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "index.html" HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "index.html"
# Parse once per module — tests are read-only so sharing is safe. # Parse once per module — tests are read-only so sharing is safe.
_SOUP: BeautifulSoup = BeautifulSoup(HTML_PATH.read_text(), "html.parser") _SOUP: BeautifulSoup = BeautifulSoup(HTML_PATH.read_text(), "html.parser")
@@ -1,5 +1,5 @@
""" """
Integration tests for the tmux-web coordinator. Integration tests for the tmux-web muxplex.
These tests require a real tmux installation and spin up an isolated tmux These tests require a real tmux installation and spin up an isolated tmux
server on socket 'test-server' for the duration of the module. server on socket 'test-server' for the duration of the module.
@@ -18,10 +18,10 @@ from unittest.mock import patch
import pytest import pytest
import coordinator.state as state_mod import muxplex.state as state_mod
from coordinator.bells import poll_bell_flag from muxplex.bells import poll_bell_flag
from coordinator.main import _run_poll_cycle from muxplex.main import _run_poll_cycle
from coordinator.sessions import enumerate_sessions, get_snapshots from muxplex.sessions import enumerate_sessions, get_snapshots
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -87,13 +87,13 @@ def use_tmp_state(tmp_path, monkeypatch):
"""Redirect state and PID files to tmp_path for test isolation.""" """Redirect state and PID files to tmp_path for test isolation."""
tmp_state_dir = tmp_path / "state" tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json" tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
tmp_pid_dir = tmp_path / "ttyd" tmp_pid_dir = tmp_path / "ttyd"
tmp_pid_path = tmp_pid_dir / "ttyd.pid" tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir) monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path) monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -134,7 +134,7 @@ def make_run_tmux_for_socket(socket: str):
async def test_enumerate_sessions_finds_test_session(tmux_server): async def test_enumerate_sessions_finds_test_session(tmux_server):
"""enumerate_sessions discovers the 'test' session on the isolated tmux server.""" """enumerate_sessions discovers the 'test' session on the isolated tmux server."""
patched_run_tmux = make_run_tmux_for_socket(tmux_server) patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux): with patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux):
sessions = await enumerate_sessions() sessions = await enumerate_sessions()
assert "test" in sessions assert "test" in sessions
@@ -158,7 +158,7 @@ async def test_bell_flag_detected_after_printf_bell(tmux_server):
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
patched_run_tmux = make_run_tmux_for_socket(tmux_server) patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux): with patch("muxplex.bells.run_tmux", side_effect=patched_run_tmux):
result = await poll_bell_flag("test") result = await poll_bell_flag("test")
assert result is True assert result is True
@@ -169,8 +169,8 @@ async def test_full_poll_cycle_via_api(tmux_server):
and populates the in-memory snapshot cache with non-empty content.""" and populates the in-memory snapshot cache with non-empty content."""
patched_run_tmux = make_run_tmux_for_socket(tmux_server) patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with ( with (
patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux), patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux),
patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux), patch("muxplex.bells.run_tmux", side_effect=patched_run_tmux),
): ):
await _run_poll_cycle() await _run_poll_cycle()
@@ -191,8 +191,8 @@ 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.""" """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) patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with ( with (
patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux), patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux),
patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux), patch("muxplex.bells.run_tmux", side_effect=patched_run_tmux),
): ):
await _run_poll_cycle() await _run_poll_cycle()
@@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import coordinator.sessions as sessions_mod import muxplex.sessions as sessions_mod
from coordinator.sessions import ( from muxplex.sessions import (
capture_pane, capture_pane,
enumerate_sessions, enumerate_sessions,
get_snapshots, get_snapshots,
@@ -169,7 +169,7 @@ async def test_snapshot_all_returns_dict_keyed_by_name():
async def mock_capture(name, lines=30): async def mock_capture(name, lines=30):
return f"output-for-{name}" return f"output-for-{name}"
with patch("coordinator.sessions.capture_pane", side_effect=mock_capture): with patch("muxplex.sessions.capture_pane", side_effect=mock_capture):
result = await snapshot_all(["alpha", "beta", "gamma"]) result = await snapshot_all(["alpha", "beta", "gamma"])
assert result == { assert result == {
@@ -181,7 +181,7 @@ async def test_snapshot_all_returns_dict_keyed_by_name():
async def test_snapshot_all_returns_empty_dict_for_empty_input(): async def test_snapshot_all_returns_empty_dict_for_empty_input():
"""snapshot_all([]) returns an empty dict without calling capture_pane.""" """snapshot_all([]) returns an empty dict without calling capture_pane."""
with patch("coordinator.sessions.capture_pane", new=AsyncMock()) as mock_capture: with patch("muxplex.sessions.capture_pane", new=AsyncMock()) as mock_capture:
result = await snapshot_all([]) result = await snapshot_all([])
assert result == {} assert result == {}
@@ -196,7 +196,7 @@ async def test_snapshot_all_returns_empty_string_on_individual_failure():
raise RuntimeError("pane not found") raise RuntimeError("pane not found")
return f"output-for-{name}" return f"output-for-{name}"
with patch("coordinator.sessions.capture_pane", side_effect=mock_capture): with patch("muxplex.sessions.capture_pane", side_effect=mock_capture):
result = await snapshot_all(["session-a", "bad-session", "session-b"]) result = await snapshot_all(["session-a", "bad-session", "session-b"])
assert result == { assert result == {
@@ -9,7 +9,7 @@ from pathlib import Path
import pytest import pytest
from coordinator.state import ( from muxplex.state import (
empty_bell, empty_bell,
empty_device, empty_device,
empty_state, empty_state,
@@ -31,8 +31,8 @@ def use_tmp_state_dir(tmp_path, monkeypatch):
"""Redirect state I/O to a fresh temp directory for every test.""" """Redirect state I/O to a fresh temp directory for every test."""
tmp_state_dir = tmp_path / "state" tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json" tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -180,7 +180,7 @@ async def test_write_then_read_roundtrip():
async def test_write_creates_state_dir_if_missing(): async def test_write_creates_state_dir_if_missing():
"""save_state() must create STATE_DIR (and parents) if they do not exist.""" """save_state() must create STATE_DIR (and parents) if they do not exist."""
import coordinator.state as state_mod import muxplex.state as state_mod
# The tmp "state" subdirectory should not exist yet. # The tmp "state" subdirectory should not exist yet.
assert not state_mod.STATE_DIR.exists() assert not state_mod.STATE_DIR.exists()
@@ -190,7 +190,7 @@ async def test_write_creates_state_dir_if_missing():
async def test_write_is_atomic_no_tmp_file_left(): async def test_write_is_atomic_no_tmp_file_left():
"""After write_state(), no .tmp file should remain on disk.""" """After write_state(), no .tmp file should remain on disk."""
import coordinator.state as state_mod import muxplex.state as state_mod
await write_state(empty_state()) await write_state(empty_state())
tmp_file = Path(str(state_mod.STATE_PATH) + ".tmp") tmp_file = Path(str(state_mod.STATE_PATH) + ".tmp")
@@ -8,8 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import coordinator.ttyd as ttyd_mod import muxplex.ttyd as ttyd_mod
from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -22,8 +22,8 @@ def use_tmp_pid_dir(tmp_path, monkeypatch):
"""Redirect PID file I/O to a fresh temp directory for every test.""" """Redirect PID file I/O to a fresh temp directory for every test."""
tmp_pid_dir = tmp_path / "ttyd" tmp_pid_dir = tmp_path / "ttyd"
tmp_pid_path = tmp_pid_dir / "ttyd.pid" tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir) monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path) monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,5 +1,5 @@
""" """
ttyd process lifecycle management for the tmux-web coordinator. ttyd process lifecycle management for the tmux-web muxplex.
Constants: Constants:
TTYD_PID_DIR directory for the PID file (default: ~/.local/share/tmux-web/) TTYD_PID_DIR directory for the PID file (default: ~/.local/share/tmux-web/)
+1 -1
View File
@@ -8,7 +8,7 @@ version = "0.1.0"
requires-python = ">=3.12" requires-python = ">=3.12"
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["coordinator/tests"] testpaths = ["muxplex/tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
addopts = "--import-mode=importlib -m 'not integration'" addopts = "--import-mode=importlib -m 'not integration'"
markers = [ markers = [