f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
783 lines
29 KiB
Python
783 lines
29 KiB
Python
from __future__ import annotations
|
|
|
|
import signal
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
|
|
from claude_tap.shared_dashboard import (
|
|
CLAUDE_TAP_VERSION,
|
|
DEFAULT_DASHBOARD_PORT,
|
|
_dashboard_listening_pids_for_port,
|
|
_dashboard_lock_path,
|
|
_dashboard_process_command,
|
|
_dashboard_spawn_lock,
|
|
_looks_like_legacy_dashboard_command,
|
|
_spawn_dashboard_subprocess,
|
|
_sync_dashboard_healthy_for_current_db,
|
|
_terminate_legacy_dashboard_pids,
|
|
dashboard_connect_host,
|
|
dashboard_url,
|
|
ensure_shared_dashboard,
|
|
is_dashboard_healthy,
|
|
is_legacy_dashboard_healthy,
|
|
resolve_dashboard_port,
|
|
stop_dashboard_service,
|
|
stop_legacy_dashboard_process,
|
|
stop_shared_dashboard,
|
|
)
|
|
from claude_tap.trace_store import resolve_db_path
|
|
|
|
|
|
async def _start_test_app(app: web.Application) -> tuple[web.AppRunner, int]:
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
port = site._server.sockets[0].getsockname()[1]
|
|
return runner, port
|
|
|
|
|
|
def test_resolve_dashboard_port_defaults_to_shared_port() -> None:
|
|
assert resolve_dashboard_port(0) == DEFAULT_DASHBOARD_PORT
|
|
assert resolve_dashboard_port(None) == DEFAULT_DASHBOARD_PORT
|
|
|
|
|
|
def test_resolve_dashboard_port_honors_explicit_port() -> None:
|
|
assert resolve_dashboard_port(3000) == 3000
|
|
|
|
|
|
def test_resolve_dashboard_port_honors_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DASHBOARD_PORT", "8765")
|
|
assert resolve_dashboard_port(0) == 8765
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["0", "-1", "not-a-port"])
|
|
def test_resolve_dashboard_port_ignores_invalid_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DASHBOARD_PORT", value)
|
|
assert resolve_dashboard_port(0) == DEFAULT_DASHBOARD_PORT
|
|
|
|
|
|
def test_dashboard_url() -> None:
|
|
assert dashboard_connect_host("localhost") == "localhost"
|
|
assert dashboard_connect_host(" ") == "127.0.0.1"
|
|
assert dashboard_connect_host("0.0.0.0") == "127.0.0.1"
|
|
assert dashboard_connect_host("::") == "::1"
|
|
assert dashboard_connect_host("[::]") == "::1"
|
|
assert dashboard_url("127.0.0.1", 1234) == "http://127.0.0.1:1234"
|
|
assert dashboard_url("0.0.0.0", 1234) == "http://127.0.0.1:1234"
|
|
assert dashboard_url("::", 1234) == "http://[::1]:1234"
|
|
assert dashboard_url("::1", 1234) == "http://[::1]:1234"
|
|
assert dashboard_url("[::1]", 1234) == "http://[::1]:1234"
|
|
|
|
|
|
def test_sync_dashboard_health_uses_proxyless_opener(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
class FakeResponse:
|
|
status = 200
|
|
|
|
def __enter__(self) -> "FakeResponse":
|
|
return self
|
|
|
|
def __exit__(self, *_args: object) -> None:
|
|
return None
|
|
|
|
def read(self) -> bytes:
|
|
return json_bytes
|
|
|
|
class FakeOpener:
|
|
def open(self, url: str, *, timeout: float) -> FakeResponse:
|
|
calls.append((url, timeout))
|
|
return FakeResponse()
|
|
|
|
db_path = tmp_path / "health.sqlite3"
|
|
json_bytes = f'{{"ok":true,"db_path":"{db_path}","version":"{CLAUDE_TAP_VERSION}"}}'.encode()
|
|
calls: list[tuple[str, float]] = []
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(db_path))
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._LOCAL_DASHBOARD_OPENER", FakeOpener())
|
|
|
|
assert _sync_dashboard_healthy_for_current_db("127.0.0.1", 19527) is True
|
|
assert calls and calls[0][0] == "http://127.0.0.1:19527/dashboard/health"
|
|
|
|
|
|
def test_sync_dashboard_health_rejects_stale_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
class FakeResponse:
|
|
status = 200
|
|
|
|
def __enter__(self) -> "FakeResponse":
|
|
return self
|
|
|
|
def __exit__(self, *_args: object) -> None:
|
|
return None
|
|
|
|
def read(self) -> bytes:
|
|
return json_bytes
|
|
|
|
class FakeOpener:
|
|
def open(self, _url: str, *, timeout: float) -> FakeResponse:
|
|
assert timeout > 0
|
|
return FakeResponse()
|
|
|
|
db_path = tmp_path / "health.sqlite3"
|
|
json_bytes = f'{{"ok":true,"db_path":"{db_path}","version":"0.1.106"}}'.encode()
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(db_path))
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._LOCAL_DASHBOARD_OPENER", FakeOpener())
|
|
|
|
assert _sync_dashboard_healthy_for_current_db("127.0.0.1", 19527) is False
|
|
|
|
|
|
def test_dashboard_lock_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3"))
|
|
assert _dashboard_lock_path() == tmp_path / "dashboard.lock"
|
|
|
|
|
|
def test_dashboard_spawn_lock(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3"))
|
|
with _dashboard_spawn_lock():
|
|
pass
|
|
with _dashboard_spawn_lock():
|
|
pass
|
|
|
|
|
|
def test_spawn_dashboard_subprocess(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
spawned_args: list[tuple[list[str], dict[str, object]]] = []
|
|
|
|
class FakePopen:
|
|
def __init__(self, cmd: list[str], **kwargs: object) -> None:
|
|
spawned_args.append((cmd, kwargs))
|
|
self.pid = 99999
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", FakePopen)
|
|
|
|
_spawn_dashboard_subprocess("127.0.0.1", 19527, tmp_path)
|
|
|
|
assert len(spawned_args) == 1
|
|
cmd, kwargs = spawned_args[0]
|
|
assert "dashboard" in cmd
|
|
assert "--tap-live-port" in cmd
|
|
assert "19527" in cmd
|
|
assert str(tmp_path) in cmd
|
|
assert kwargs["stdin"] == subprocess.DEVNULL
|
|
assert kwargs["stdout"] == subprocess.DEVNULL
|
|
assert kwargs["stderr"] == subprocess.DEVNULL
|
|
assert kwargs["start_new_session"] is True
|
|
|
|
|
|
def test_spawn_dashboard_subprocess_hides_windows_console(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeStartupInfo:
|
|
def __init__(self) -> None:
|
|
self.dwFlags = 0
|
|
self.wShowWindow: int | None = None
|
|
|
|
class FakePopen:
|
|
def __init__(self, cmd: list[str], **kwargs: object) -> None:
|
|
captured["cmd"] = cmd
|
|
captured["kwargs"] = kwargs
|
|
self.pid = 99999
|
|
|
|
scripts_dir = tmp_path / "uv" / "tools" / "claude-tap" / "Scripts"
|
|
scripts_dir.mkdir(parents=True)
|
|
python_exe = scripts_dir / "python.exe"
|
|
pythonw_exe = scripts_dir / "pythonw.exe"
|
|
python_exe.touch()
|
|
pythonw_exe.touch()
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.sys.platform", "win32")
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.sys.executable", str(python_exe))
|
|
monkeypatch.setattr(subprocess, "Popen", FakePopen)
|
|
monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", 0x1000, raising=False)
|
|
monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x2000, raising=False)
|
|
monkeypatch.setattr(subprocess, "STARTF_USESHOWWINDOW", 0x4000, raising=False)
|
|
monkeypatch.setattr(subprocess, "SW_HIDE", 0, raising=False)
|
|
monkeypatch.setattr(subprocess, "STARTUPINFO", FakeStartupInfo, raising=False)
|
|
|
|
_spawn_dashboard_subprocess("0.0.0.0", 19527, tmp_path)
|
|
|
|
cmd = captured["cmd"]
|
|
kwargs = captured["kwargs"]
|
|
assert isinstance(cmd, list)
|
|
assert isinstance(kwargs, dict)
|
|
assert cmd[0] == str(pythonw_exe)
|
|
assert cmd[-2:] == ["--tap-host", "0.0.0.0"]
|
|
assert kwargs["stdin"] == subprocess.DEVNULL
|
|
assert kwargs["stdout"] == subprocess.DEVNULL
|
|
assert kwargs["stderr"] == subprocess.DEVNULL
|
|
assert kwargs["creationflags"] == 0x1000 | 0x2000
|
|
assert "start_new_session" not in kwargs
|
|
startupinfo = kwargs["startupinfo"]
|
|
assert isinstance(startupinfo, FakeStartupInfo)
|
|
assert startupinfo.dwFlags == 0x4000
|
|
assert startupinfo.wShowWindow == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_dashboard_healthy_real_server(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
import aiohttp
|
|
|
|
from claude_tap.live import LiveViewerServer
|
|
from claude_tap.shared_dashboard import wait_for_dashboard_healthy
|
|
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3"))
|
|
|
|
# Before starting, it should be unhealthy
|
|
assert await is_dashboard_healthy("127.0.0.1", 54321) is False
|
|
assert await wait_for_dashboard_healthy("127.0.0.1", 54321, timeout=0.2, interval=0.05) is False
|
|
|
|
# Start real server
|
|
server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True)
|
|
port = await server.start()
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(f"http://127.0.0.1:{port}/dashboard/health") as resp:
|
|
assert resp.status == 200
|
|
payload = await resp.json()
|
|
assert payload["ok"] is True
|
|
assert payload["db_path"] == str(resolve_db_path())
|
|
assert payload["dashboard_mode"] is True
|
|
assert payload["version"] == CLAUDE_TAP_VERSION
|
|
assert isinstance(payload["quit_token"], str)
|
|
assert payload["quit_token"]
|
|
assert await is_dashboard_healthy("127.0.0.1", port) is True
|
|
assert await wait_for_dashboard_healthy("127.0.0.1", port, timeout=1.0) is True
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_shared_dashboard_stops_real_server(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
from claude_tap.live import LiveViewerServer
|
|
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3"))
|
|
|
|
server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True)
|
|
port = await server.start()
|
|
try:
|
|
assert await stop_shared_dashboard("127.0.0.1", port) is True
|
|
assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is False
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bind_all_dashboard_uses_loopback_for_local_controls(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from claude_tap.live import LiveViewerServer
|
|
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3"))
|
|
|
|
server = LiveViewerServer(port=0, host="0.0.0.0", migrate_from=tmp_path, dashboard_mode=True)
|
|
port = await server.start()
|
|
try:
|
|
assert server.url == f"http://127.0.0.1:{port}"
|
|
assert await is_dashboard_healthy("0.0.0.0", port) is True
|
|
assert await stop_shared_dashboard("0.0.0.0", port) is True
|
|
assert await is_dashboard_healthy("0.0.0.0", port, require_current_db=False) is False
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_shared_dashboard_requires_health_token() -> None:
|
|
quit_called = False
|
|
|
|
async def health(request: web.Request) -> web.Response:
|
|
return web.json_response({"ok": True})
|
|
|
|
async def quit_dashboard(request: web.Request) -> web.Response:
|
|
nonlocal quit_called
|
|
quit_called = True
|
|
return web.json_response({"ok": True})
|
|
|
|
app = web.Application()
|
|
app.router.add_get("/dashboard/health", health)
|
|
app.router.add_post("/dashboard/quit", quit_dashboard)
|
|
runner, port = await _start_test_app(app)
|
|
try:
|
|
assert await stop_shared_dashboard("127.0.0.1", port) is False
|
|
assert quit_called is False
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_shared_dashboard_rejects_unhealthy_or_forbidden_server() -> None:
|
|
async def unhealthy(request: web.Request) -> web.Response:
|
|
return web.json_response({"ok": False}, status=500)
|
|
|
|
unhealthy_app = web.Application()
|
|
unhealthy_app.router.add_get("/dashboard/health", unhealthy)
|
|
unhealthy_runner, unhealthy_port = await _start_test_app(unhealthy_app)
|
|
try:
|
|
assert await stop_shared_dashboard("127.0.0.1", unhealthy_port) is False
|
|
finally:
|
|
await unhealthy_runner.cleanup()
|
|
|
|
async def health(request: web.Request) -> web.Response:
|
|
return web.json_response({"ok": True, "quit_token": "test-token"})
|
|
|
|
async def forbidden_quit(request: web.Request) -> web.Response:
|
|
assert request.headers["X-Claude-Tap-Dashboard-Token"] == "test-token"
|
|
return web.json_response({"ok": False}, status=403)
|
|
|
|
forbidden_app = web.Application()
|
|
forbidden_app.router.add_get("/dashboard/health", health)
|
|
forbidden_app.router.add_post("/dashboard/quit", forbidden_quit)
|
|
forbidden_runner, forbidden_port = await _start_test_app(forbidden_app)
|
|
try:
|
|
assert await stop_shared_dashboard("127.0.0.1", forbidden_port) is False
|
|
finally:
|
|
await forbidden_runner.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_shared_dashboard_handles_post_client_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import aiohttp
|
|
|
|
async def healthy(*_args: object, **_kwargs: object) -> tuple[int, dict[str, str]]:
|
|
return 200, {"quit_token": "test-token"}
|
|
|
|
class FailingSession:
|
|
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> "FailingSession":
|
|
return self
|
|
|
|
async def __aexit__(self, *_args: object) -> None:
|
|
return None
|
|
|
|
def post(self, *_args: object, **_kwargs: object) -> object:
|
|
raise aiohttp.ClientError("post failed")
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._dashboard_get_status_and_payload", healthy)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.aiohttp.ClientSession", FailingSession)
|
|
|
|
assert await stop_shared_dashboard("127.0.0.1", 19527) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_dashboard_service_falls_back_to_legacy_process(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[str] = []
|
|
|
|
async def fake_stop_shared_dashboard(_host: str, _port: int) -> bool:
|
|
calls.append("shared")
|
|
return False
|
|
|
|
async def fake_is_legacy_dashboard_healthy(_host: str, _port: int) -> bool:
|
|
calls.append("legacy")
|
|
return True
|
|
|
|
async def fake_stop_legacy_dashboard_process(_host: str, _port: int) -> bool:
|
|
calls.append("process")
|
|
return True
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.stop_shared_dashboard", fake_stop_shared_dashboard)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", fake_is_legacy_dashboard_healthy)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.stop_legacy_dashboard_process", fake_stop_legacy_dashboard_process)
|
|
|
|
assert await stop_dashboard_service("127.0.0.1", 19527) is True
|
|
assert calls == ["shared", "legacy", "process"]
|
|
|
|
|
|
def test_looks_like_legacy_dashboard_command_is_strict() -> None:
|
|
assert _looks_like_legacy_dashboard_command(
|
|
"/usr/bin/python -m claude_tap dashboard --tap-live-port 19527",
|
|
19527,
|
|
)
|
|
assert not _looks_like_legacy_dashboard_command(
|
|
"/usr/bin/python -m claude_tap dashboard --tap-live-port 3000",
|
|
19527,
|
|
)
|
|
assert not _looks_like_legacy_dashboard_command(
|
|
"/usr/bin/python -m claude_tap proxy --tap-live-port 19527",
|
|
19527,
|
|
)
|
|
assert not _looks_like_legacy_dashboard_command(
|
|
"/usr/bin/python -m other_app dashboard --tap-live-port 19527",
|
|
19527,
|
|
)
|
|
|
|
|
|
def test_dashboard_listening_pids_uses_lsof(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class Result:
|
|
returncode = 0
|
|
stdout = "111\nnot-a-pid\n222\n"
|
|
|
|
calls: list[list[str]] = []
|
|
|
|
def fake_which(name: str) -> str | None:
|
|
return f"/usr/bin/{name}" if name == "lsof" else None
|
|
|
|
def fake_run(cmd: list[str], **_kwargs: object) -> Result:
|
|
calls.append(cmd)
|
|
return Result()
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", fake_which)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.subprocess.run", fake_run)
|
|
|
|
assert _dashboard_listening_pids_for_port(19527) == [111, 222]
|
|
assert calls == [["/usr/bin/lsof", "-nP", "-iTCP:19527", "-sTCP:LISTEN", "-t"]]
|
|
|
|
|
|
def test_dashboard_listening_pids_falls_back_to_ss(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class Result:
|
|
returncode = 0
|
|
stdout = 'LISTEN 0 128 127.0.0.1:19527 *:* users:(("python",pid=333,fd=6),("python",pid=444,fd=7))'
|
|
|
|
def fake_which(name: str) -> str | None:
|
|
return "/usr/bin/ss" if name == "ss" else None
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", fake_which)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.subprocess.run", lambda *_args, **_kwargs: Result())
|
|
|
|
assert _dashboard_listening_pids_for_port(19527) == [333, 444]
|
|
|
|
|
|
def test_dashboard_listening_pids_handles_missing_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", lambda _name: None)
|
|
|
|
assert _dashboard_listening_pids_for_port(0) == []
|
|
assert _dashboard_listening_pids_for_port(19527) == []
|
|
|
|
|
|
def test_dashboard_process_command_reads_linux_proc(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def fake_read_bytes(path: Path) -> bytes:
|
|
assert str(path) == "/proc/123/cmdline"
|
|
return b"python\0-m\0claude_tap\0dashboard\0"
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.sys.platform", "linux")
|
|
monkeypatch.setattr("pathlib.Path.read_bytes", fake_read_bytes)
|
|
|
|
assert _dashboard_process_command(123) == "python -m claude_tap dashboard"
|
|
|
|
|
|
def test_dashboard_process_command_falls_back_to_ps(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class Result:
|
|
returncode = 0
|
|
stdout = "claude-tap dashboard --tap-live-port 19527\n"
|
|
|
|
def fake_which(name: str) -> str | None:
|
|
return "/bin/ps" if name == "ps" else None
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.sys.platform", "darwin")
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", fake_which)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.subprocess.run", lambda *_args, **_kwargs: Result())
|
|
|
|
assert _dashboard_process_command(123) == "claude-tap dashboard --tap-live-port 19527"
|
|
|
|
|
|
def test_terminate_legacy_dashboard_pids_filters_commands(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
commands = {
|
|
111: "/usr/bin/python -m claude_tap dashboard --tap-live-port 19527",
|
|
222: "/usr/bin/python -m claude_tap proxy --tap-live-port 19527",
|
|
}
|
|
killed: list[tuple[int, signal.Signals]] = []
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._dashboard_process_command", commands.__getitem__)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.os.kill", lambda pid, sig: killed.append((pid, sig)))
|
|
|
|
assert _terminate_legacy_dashboard_pids([111, 222], 19527) is True
|
|
assert killed == [(111, signal.SIGTERM)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_legacy_dashboard_process_terminates_and_waits(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[tuple[str, object]] = []
|
|
|
|
def fake_listening_pids(port: int) -> list[int]:
|
|
calls.append(("pids", port))
|
|
return [111]
|
|
|
|
def fake_terminate(pids: list[int], port: int) -> bool:
|
|
calls.append(("terminate", (pids, port)))
|
|
return True
|
|
|
|
async def fake_wait_stopped(host: str, port: int) -> bool:
|
|
calls.append(("wait", (host, port)))
|
|
return True
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._dashboard_listening_pids_for_port", fake_listening_pids)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._terminate_legacy_dashboard_pids", fake_terminate)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.wait_for_dashboard_stopped", fake_wait_stopped)
|
|
|
|
assert await stop_legacy_dashboard_process("127.0.0.1", 19527) is True
|
|
assert calls == [
|
|
("pids", 19527),
|
|
("terminate", ([111], 19527)),
|
|
("wait", ("127.0.0.1", 19527)),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_dashboard_healthy_prefers_lightweight_health_route(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "health.sqlite3"))
|
|
sessions_seen = False
|
|
app = web.Application()
|
|
|
|
async def health(request: web.Request) -> web.Response:
|
|
return web.json_response({"ok": True, "db_path": str(resolve_db_path()), "version": CLAUDE_TAP_VERSION})
|
|
|
|
async def sessions(request: web.Request) -> web.Response:
|
|
nonlocal sessions_seen
|
|
sessions_seen = True
|
|
return web.json_response({"sessions": []})
|
|
|
|
app.router.add_get("/dashboard/health", health)
|
|
app.router.add_get("/api/sessions", sessions)
|
|
runner, port = await _start_test_app(app)
|
|
try:
|
|
assert await is_dashboard_healthy("127.0.0.1", port) is True
|
|
assert sessions_seen is False
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_dashboard_healthy_rejects_stale_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "current.sqlite3"))
|
|
app = web.Application()
|
|
|
|
async def health(request: web.Request) -> web.Response:
|
|
return web.json_response({"ok": True, "db_path": str(resolve_db_path()), "version": "0.1.106"})
|
|
|
|
app.router.add_get("/dashboard/health", health)
|
|
runner, port = await _start_test_app(app)
|
|
try:
|
|
assert await is_dashboard_healthy("127.0.0.1", port) is False
|
|
assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is True
|
|
assert await is_legacy_dashboard_healthy("127.0.0.1", port) is False
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_dashboard_healthy_rejects_different_database(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "current.sqlite3"))
|
|
app = web.Application()
|
|
|
|
async def health(request: web.Request) -> web.Response:
|
|
return web.json_response(
|
|
{"ok": True, "db_path": str(tmp_path / "other.sqlite3"), "version": CLAUDE_TAP_VERSION}
|
|
)
|
|
|
|
app.router.add_get("/dashboard/health", health)
|
|
runner, port = await _start_test_app(app)
|
|
try:
|
|
assert await is_dashboard_healthy("127.0.0.1", port) is False
|
|
assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is True
|
|
assert await is_legacy_dashboard_healthy("127.0.0.1", port) is False
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_dashboard_healthy_falls_back_for_legacy_dashboard() -> None:
|
|
app = web.Application()
|
|
|
|
async def sessions(request: web.Request) -> web.Response:
|
|
return web.json_response({"sessions": []})
|
|
|
|
app.router.add_get("/api/sessions", sessions)
|
|
runner, port = await _start_test_app(app)
|
|
try:
|
|
assert await is_dashboard_healthy("127.0.0.1", port) is False
|
|
assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is True
|
|
assert await is_legacy_dashboard_healthy("127.0.0.1", port) is True
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_shared_dashboard_already_healthy_does_not_reopen_browser(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
async def mock_true(h: str, p: int) -> bool:
|
|
return True
|
|
|
|
migrated: list[Path] = []
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_true)
|
|
monkeypatch.setattr("claude_tap.history.migrate_legacy_traces", migrated.append)
|
|
|
|
opened = []
|
|
|
|
def fake_open(url: str) -> None:
|
|
opened.append(url)
|
|
|
|
url, spawned = await ensure_shared_dashboard(
|
|
host="127.0.0.1",
|
|
port=19527,
|
|
output_dir=tmp_path,
|
|
open_browser=True,
|
|
open_browser_fn=fake_open,
|
|
)
|
|
|
|
assert url == "http://127.0.0.1:19527"
|
|
assert spawned is False
|
|
assert opened == []
|
|
assert migrated == [tmp_path]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_shared_dashboard_stops_stale_dashboard_before_spawn(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
calls: list[tuple[str, object]] = []
|
|
|
|
async def fake_is_dashboard_healthy(_host: str, _port: int, *, require_current_db: bool = True) -> bool:
|
|
calls.append(("health", require_current_db))
|
|
return not require_current_db
|
|
|
|
async def fake_stop_dashboard_service(_host: str, _port: int) -> bool:
|
|
calls.append(("stop", None))
|
|
return True
|
|
|
|
def fake_spawn_if_needed(_host: str, _port: int, _output_dir: Path) -> bool:
|
|
calls.append(("spawn", None))
|
|
return True
|
|
|
|
async def fake_wait_for_dashboard_healthy(_host: str, _port: int) -> bool:
|
|
calls.append(("wait", None))
|
|
return True
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", fake_is_dashboard_healthy)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.stop_dashboard_service", fake_stop_dashboard_service)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess_if_needed", fake_spawn_if_needed)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.wait_for_dashboard_healthy", fake_wait_for_dashboard_healthy)
|
|
|
|
url, spawned = await ensure_shared_dashboard(
|
|
host="127.0.0.1",
|
|
port=19527,
|
|
output_dir=tmp_path,
|
|
open_browser=False,
|
|
open_browser_fn=lambda _url: None,
|
|
)
|
|
|
|
assert url == "http://127.0.0.1:19527"
|
|
assert spawned is True
|
|
assert calls == [("health", True), ("health", False), ("stop", None), ("spawn", None), ("wait", None)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_shared_dashboard_reports_unstoppable_stale_dashboard(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
async def fake_is_dashboard_healthy(_host: str, _port: int, *, require_current_db: bool = True) -> bool:
|
|
return not require_current_db
|
|
|
|
async def fake_stop_dashboard_service(_host: str, _port: int) -> bool:
|
|
return False
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", fake_is_dashboard_healthy)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.stop_dashboard_service", fake_stop_dashboard_service)
|
|
|
|
with pytest.raises(RuntimeError, match="outdated claude-tap dashboard"):
|
|
await ensure_shared_dashboard(
|
|
host="127.0.0.1",
|
|
port=19527,
|
|
output_dir=tmp_path,
|
|
open_browser=False,
|
|
open_browser_fn=lambda _url: None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_shared_dashboard_migrates_after_lock_time_reuse(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
async def mock_false(h: str, p: int, *, require_current_db: bool = True) -> bool:
|
|
return False
|
|
|
|
migrated: list[Path] = []
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_false)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", mock_false)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess_if_needed", lambda h, p, d: False)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._migrate_legacy_traces", migrated.append)
|
|
|
|
opened: list[str] = []
|
|
|
|
url, spawned = await ensure_shared_dashboard(
|
|
host="127.0.0.1",
|
|
port=19527,
|
|
output_dir=tmp_path,
|
|
open_browser=True,
|
|
open_browser_fn=opened.append,
|
|
)
|
|
|
|
assert url == "http://127.0.0.1:19527"
|
|
assert spawned is False
|
|
assert opened == []
|
|
assert migrated == [tmp_path]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_shared_dashboard_spawns(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3"))
|
|
|
|
health_calls: list[int] = []
|
|
|
|
async def mock_health(h: str, p: int, *, require_current_db: bool = True) -> bool:
|
|
if len(health_calls) < 2:
|
|
health_calls.append(1)
|
|
return False
|
|
return True
|
|
|
|
async def mock_legacy_false(h: str, p: int) -> bool:
|
|
return False
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_health)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", mock_legacy_false)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess", lambda h, p, d: None)
|
|
|
|
opened = []
|
|
|
|
def fake_open(url: str) -> None:
|
|
opened.append(url)
|
|
|
|
url, spawned = await ensure_shared_dashboard(
|
|
host="127.0.0.1",
|
|
port=19527,
|
|
output_dir=tmp_path,
|
|
open_browser=True,
|
|
open_browser_fn=fake_open,
|
|
)
|
|
|
|
assert url == "http://127.0.0.1:19527"
|
|
assert spawned is True
|
|
assert opened == ["http://127.0.0.1:19527"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_shared_dashboard_timeout_raises_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3"))
|
|
|
|
async def mock_false(h: str, p: int, *, require_current_db: bool = True) -> bool:
|
|
return False
|
|
|
|
async def mock_wait_false(h: str, p: int, **kw: object) -> bool:
|
|
return False
|
|
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_false)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", mock_false)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard.wait_for_dashboard_healthy", mock_wait_false)
|
|
monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess", lambda h, p, d: None)
|
|
|
|
with pytest.raises(RuntimeError, match="Failed to start shared dashboard"):
|
|
await ensure_shared_dashboard(
|
|
host="127.0.0.1",
|
|
port=19527,
|
|
output_dir=tmp_path,
|
|
open_browser=False,
|
|
open_browser_fn=lambda u: None,
|
|
)
|