diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index fee60df..895ced5 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-03-07 (REVIEW_broker_runtime_status_surface archived) +**Last Updated:** 2026-03-07 (P6-T2 review archived) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| P6-T2 | [P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/](P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/) | 2026-03-07 | PASS | | P6-T1 | [P6-T1_Add_explicit_broker_runtime_status_surface_for_frontend_consumers/](P6-T1_Add_explicit_broker_runtime_status_surface_for_frontend_consumers/) | 2026-03-07 | PASS | | P1-T12 | [P1-T12_Improve_troubleshooting_docs_for_Zed_broker_startup_timeouts/](P1-T12_Improve_troubleshooting_docs_for_Zed_broker_startup_timeouts/) | 2026-03-07 | PASS | | P5-T2 | [P5-T2_Release_0.4.1_to_PyPI_and_MCP_Registry/](P5-T2_Release_0.4.1_to_PyPI_and_MCP_Registry/) | 2026-03-06 | PASS | @@ -194,6 +195,7 @@ | File | Description | |------|-------------| +| [REVIEW_broker_terminal_frontend.md](_Historical/REVIEW_broker_terminal_frontend.md) | Review report for P6-T2 | | [REVIEW_broker_runtime_status_surface.md](_Historical/REVIEW_broker_runtime_status_surface.md) | Review report for P6-T1 | | [REVIEW_p1_t12_zed_timeout_docs.md](_Historical/REVIEW_p1_t12_zed_timeout_docs.md) | Review report for P1-T12 | | [REVIEW_P4-T2_broker_readiness_cache.md](_Historical/REVIEW_P4-T2_broker_readiness_cache.md) | Review report for P4-T2 | @@ -331,6 +333,7 @@ | Date | Task ID | Action | |------|---------|--------| +| 2026-03-07 | P6-T2 | Archived task artifacts and validation report | | 2026-03-07 | P6-T1 | Archived REVIEW_broker_runtime_status_surface report | | 2026-03-07 | P6-T1 | Archived task artifacts and validation report | | 2026-03-07 | P1-T12 | Archived task artifacts and validation report | @@ -593,3 +596,4 @@ | 2026-03-01 | P3-T11 | Archived REVIEW_p3_t11_webui_stop_control report | | 2026-03-06 | BUG-T9 | Archived Fix_broker_daemon_not_sending_notifications_initialized_before_tools_list_probe (PASS) | | 2026-03-06 | BUG-T9 | Archived REVIEW_BUG-T9 report | +| 2026-03-07 | P6-T2 | Archived REVIEW_broker_terminal_frontend report | diff --git a/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md new file mode 100644 index 0000000..7396a9a --- /dev/null +++ b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control.md @@ -0,0 +1,135 @@ +# P6-T2 — Build a terminal frontend for broker daemon monitoring and control + +## Objective Summary + +Users who adopt the dedicated broker host pattern still need a first-class +operator surface that does not require a browser. This task adds a standalone +terminal frontend that attaches to the existing broker-hosted Web UI, reads the +broker runtime status API introduced in `P6-T1`, and presents broker health in +one explicit place. The frontend should make it obvious whether the shared +daemon is healthy, reconnecting, awaiting approval, or no longer reachable. + +The implementation should stay dependency-light. Prefer the Python standard +library (`curses`, `urllib`, `json`) and existing `WebUIConfig` loading over a +new third-party terminal framework. The TUI may require an already running +broker-hosted Web UI; that constraint is acceptable as long as failure modes are +clear and actionable. + +## Deliverables + +- Add a standalone `--tui` runtime mode that launches an interactive terminal + dashboard instead of proxy, bridge, or daemon execution. +- Implement a TUI module that polls `GET /api/broker/status`, inspects control + capability, and renders broker state, PID information, connected client + counts, readiness flags, and reconnect indicators. +- Surface recent broker activity by tailing the recommended local + `~/.mcpbridge_wrapper/broker.log` file inside the terminal UI. +- Expose at least one lifecycle control action from the TUI, with `stop` backed + by `POST /api/control/stop`. +- Add automated tests for argument parsing, HTTP/status handling, and terminal + rendering/control logic. + +## Success Criteria + +- Users can run `mcpbridge-wrapper --tui` to inspect broker status without + tailing logs manually. +- The terminal UI shows broker state, daemon PID, upstream PID, connected + client count, readiness/cached-tool status, and reconnect attempt count. +- The UI displays recent broker log lines or equivalent reconnect indicators in + the same screen. +- The UI exposes an explicit stop control and handles unavailable/unauthorized + backends with clear messaging. +- No new runtime dependency is required for the terminal frontend. + +## Test-First Plan + +1. Add parser/main tests that lock down `--tui` mode, including invalid + flag combinations with `--broker*` and `--web-ui`. +2. Add unit tests for a pure rendering/presentation layer so the terminal + layout is testable without a real curses session. +3. Add client tests for status fetches, stop requests, auth header behavior, + and log tail handling using mocks or a lightweight HTTP stub. +4. Implement the production TUI only after the expected runtime contract and + screen sections are pinned in tests. +5. Run required quality gates: `pytest`, `ruff check src/`, `mypy src/`, + and `pytest --cov`. + +## Execution Plan + +### Phase 1: Standalone TUI mode and configuration + +Inputs: +- `src/mcpbridge_wrapper/__main__.py` +- `src/mcpbridge_wrapper/webui/config.py` +- existing broker/Web UI CLI flag behavior + +Outputs: +- `--tui` argument parsing and validation +- Web UI endpoint resolution for host, port, and optional auth credentials +- clear errors for unsupported flag combinations + +Verification: +- `main()` routes cleanly into TUI mode +- `--tui` does not accidentally start bridge, proxy, or broker-daemon codepaths + +### Phase 2: Terminal frontend runtime + +Inputs: +- `GET /api/broker/status` +- `POST /api/control/stop` +- broker log path conventions from `BrokerConfig.default()` + +Outputs: +- new `src/mcpbridge_wrapper/tui.py` module +- polling client + screen model + curses runner +- keyboard actions for refresh, quit, and stop + +Verification: +- healthy and degraded states render distinct operator-facing output +- unreachable backend and auth failures produce actionable terminal messages + +### Phase 3: Validation and integration hardening + +Inputs: +- TUI runtime implementation +- parser/main tests and pure rendering tests + +Outputs: +- unit test coverage for TUI rendering, HTTP control, and CLI wiring +- validation report with required quality gate results + +Verification: +- the TUI remains dependency-free and CI-stable +- quality gates remain green with coverage at or above project threshold + +## Acceptance Tests + +- `pytest tests/unit/test_tui.py` +- `pytest tests/unit/test_main_tui.py` +- `pytest` +- `ruff check src/` +- `mypy src/` +- `pytest --cov` + +## Decision Points + +- The TUI should be a standalone mode (`--tui`), not a secondary view embedded + into `--broker-daemon`, so operator lifecycle stays explicit and predictable. +- The TUI should consume the existing broker-hosted Web UI APIs rather than + opening the broker socket directly; that keeps one runtime contract for both + browser and terminal frontends. +- Recent activity should come from the recommended local `broker.log` tail. The + status API already covers health, while the log tail adds human-readable event + context without expanding the HTTP schema again in `P6-T2`. + +## Notes + +- Keep user-facing documentation mostly scoped to `P6-T3`, aside from any small + inline CLI/help text needed for correctness. +- Prefer pure helper functions for layout and formatting so the curses shell is + thin and easy to test. +- Review subject name for this task: `broker_terminal_frontend`. + +--- +**Archived:** 2026-03-07 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md new file mode 100644 index 0000000..9c25c2c --- /dev/null +++ b/SPECS/ARCHIVE/P6-T2_Build_a_terminal_frontend_for_broker_daemon_monitoring_and_control/P6-T2_Validation_Report.md @@ -0,0 +1,83 @@ +# P6-T2 Validation Report + +**Task:** P6-T2 — Build a terminal frontend for broker daemon monitoring and control +**Date:** 2026-03-07 +**Verdict:** PASS + +## Summary + +Implemented a terminal frontend for the broker daemon by: + +- adding a curses-backed TUI module in `src/mcpbridge_wrapper/tui.py` +- wiring `--tui` into `src/mcpbridge_wrapper/__main__.py` +- resolving dashboard endpoint/auth settings from existing Web UI config +- surfacing broker runtime details from `/api/control` and `/api/broker/status` +- adding local PID/socket/version fallback details and broker-log tailing +- normalizing wildcard/IPv6 dashboard bind hosts into client-safe TUI endpoints +- tailing broker logs from the end of the file so refresh cost stays bounded +- degrading gracefully when `broker.log` is temporarily unreadable +- adding dedicated unit coverage for runtime resolution, HTTP aggregation, + rendering, interactive loop behavior, and main CLI integration + +## Files Validated + +- `src/mcpbridge_wrapper/tui.py` +- `src/mcpbridge_wrapper/__main__.py` +- `tests/unit/test_tui.py` +- `tests/unit/test_main_tui.py` + +## Targeted Verification + +```bash +PYTHONPATH=src pytest tests/unit/test_tui.py tests/unit/test_main_tui.py -q +``` + +- Result: `40 passed` + +```bash +ruff check src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py tests/unit/test_tui.py tests/unit/test_main_tui.py +``` + +- Result: `All checks passed!` + +```bash +mypy src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py +``` + +- Result: `Success: no issues found in 2 source files` + +## Required Quality Gates + +```bash +PYTHONPATH=src pytest +``` + +- Result: `827 passed, 5 skipped in 8.06s` + +```bash +ruff check src/ +``` + +- Result: `All checks passed!` + +```bash +mypy src/ +``` + +- Result: `Success: no issues found in 19 source files` + +```bash +PYTHONPATH=src pytest --cov +``` + +- Result: `827 passed, 5 skipped in 9.16s` +- Coverage: `91.52%` + +## Notes + +- `PYTHONPATH=src` was required for local `pytest` invocations because the + package is not installed into the active interpreter environment. +- The TUI depends only on the Python standard library plus the existing local + Web UI API surface; no new project dependency was introduced. +- Remaining warnings are pre-existing `websockets` / `uvicorn` deprecations and + are not introduced by this task. diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_broker_terminal_frontend.md b/SPECS/ARCHIVE/_Historical/REVIEW_broker_terminal_frontend.md new file mode 100644 index 0000000..305063b --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_broker_terminal_frontend.md @@ -0,0 +1,70 @@ +## REVIEW REPORT — Broker Terminal Frontend + +**Scope:** `origin/main..HEAD` +**Files:** 9 +**Date:** 2026-03-07 + +--- + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +--- + +### Critical Issues + +None. + +--- + +### Secondary Issues + +None. + +--- + +### Architectural Notes + +- The standalone `--tui` mode stays consistent with the project's existing + hand-rolled mode dispatch in `__main__.py` and avoids introducing any new CLI + or terminal UI dependency. +- The TUI reuses the broker-hosted Web UI APIs (`/api/control`, + `/api/control/stop`, `/api/broker/status`) instead of opening another broker + transport, so browser and terminal frontends share one runtime contract. +- The final implementation hardens two operational edges that matter for a + broker dashboard: bind hosts are normalized into client-safe URLs + (wildcard/IPv6-safe), and broker log tailing now degrades gracefully on read + errors while keeping refresh work bounded to tail-sized reads. + +--- + +### Tests + +- Validation report confirms: + - `PYTHONPATH=src pytest tests/unit/test_tui.py tests/unit/test_main_tui.py -q` + -> `40 passed` + - `ruff check src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py tests/unit/test_tui.py tests/unit/test_main_tui.py` + -> pass + - `mypy src/mcpbridge_wrapper/tui.py src/mcpbridge_wrapper/__main__.py` + -> pass + - `PYTHONPATH=src pytest` -> `827 passed, 5 skipped` + - `ruff check src/` -> pass + - `mypy src/` -> pass + - `PYTHONPATH=src pytest --cov` -> `91.52%` +- The focused TUI tests now cover: + - CLI routing and invalid flag combinations + - auth/header propagation and HTTP error shaping + - wildcard/IPv6 host normalization for dashboard attachment + - bounded tail reads and unreadable `broker.log` fallback behavior + - curses loop behavior for refresh, stop, and quit actions + +--- + +### Next Steps + +- FOLLOW-UP skipped: no actionable review findings remain after the final + hardening pass. +- Proceed to `ARCHIVE-REVIEW`, then open the PR for `P6-T2`. diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 2b31432..2e80c6c 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,18 +1,15 @@ -# Next Task: P6-T2 — Build a terminal frontend for broker daemon monitoring and control +# Next Task: P6-T3 — Document the explicit dedicated-host frontend workflow -**Priority:** P1 +**Priority:** P2 **Phase:** Phase 6: Explicit Broker Frontend -**Dependencies:** P6-T1 +**Effort:** 4h +**Dependencies:** P6-T1, P6-T2 **Status:** Ready ## Description -Implement a terminal-first operator interface for the broker daemon so users can explicitly see whether the daemon is running, whether upstream Xcode connectivity is healthy, which clients are attached, and what recent reconnect/error events occurred. The interface should give a clearer operational model than auto-spawn alone. - -## Recently Archived - -- `P6-T1` — Add explicit broker runtime status surface for frontend consumers (`PASS`, archived 2026-03-07) +Update the operator docs so the recommended path for multi-editor setups is an explicit dedicated broker host plus a single monitoring frontend. The docs should explain when to prefer a dedicated host over implicit auto-spawn, how to verify that both editors share one daemon, and how the new frontend fits into that workflow. ## Next Step -Run the PLAN command for `P6-T2` and define the TUI scope, entrypoint, and control flow against the new broker runtime status API. +Run the PLAN command to document the dedicated-host workflow, multi-editor validation steps, and terminal frontend usage. diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 8917c96..ddf8ece 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -438,19 +438,20 @@ Add new tasks using the canonical template in [TASK_TEMPLATE.md](TASK_TEMPLATE.m - [x] Status makes reconnecting/not-ready states explicit so a frontend can distinguish them from a healthy shared daemon - [x] Automated tests cover both healthy and degraded broker runtime status responses -#### ⬜️ P6-T2: Build a terminal frontend for broker daemon monitoring and control +#### ✅ P6-T2: Build a terminal frontend for broker daemon monitoring and control +- **Status:** ✅ Completed (2026-03-07) - **Description:** Implement a terminal-first operator interface for the broker daemon so users can explicitly see whether the daemon is running, whether upstream Xcode connectivity is healthy, which clients are attached, and what recent reconnect/error events occurred. The interface should give a clearer operational model than auto-spawn alone. - **Priority:** P1 - **Dependencies:** P6-T1 - **Parallelizable:** no - **Outputs/Artifacts:** - - `src/mcpbridge_wrapper/` TUI entrypoint/module for broker monitoring and control - - Tests covering the TUI status rendering and control integration where practical - - CLI/docs wiring for launching the TUI + - `src/mcpbridge_wrapper/tui.py` terminal frontend for broker monitoring and stop control + - `src/mcpbridge_wrapper/__main__.py` CLI wiring for `--tui` + - `tests/unit/test_tui.py` and `tests/unit/test_main_tui.py` covering runtime resolution, rendering, control requests, and CLI integration - **Acceptance Criteria:** - - [ ] Users can launch a terminal UI from the wrapper package to inspect broker runtime state without tailing logs manually - - [ ] The TUI shows at minimum broker state, daemon/upstream PIDs, connected client count, and recent broker events or reconnect indicators - - [ ] The TUI exposes at least one explicit control action for the daemon lifecycle (for example stop or restart) + - [x] Users can launch a terminal UI from the wrapper package to inspect broker runtime state without tailing logs manually + - [x] The TUI shows at minimum broker state, daemon/upstream PIDs, connected client count, and recent broker events or reconnect indicators + - [x] The TUI exposes at least one explicit control action for the daemon lifecycle (for example stop or restart) #### ⬜️ P6-T3: Document the explicit dedicated-host frontend workflow - **Description:** Update the operator docs so the recommended path for multi-editor setups is an explicit dedicated broker host plus a single monitoring frontend. The docs should explain when to prefer a dedicated host over implicit auto-spawn, how to verify that both editors share one daemon, and how the new frontend fits into that workflow. diff --git a/src/mcpbridge_wrapper/__main__.py b/src/mcpbridge_wrapper/__main__.py index 9255827..409a323 100644 --- a/src/mcpbridge_wrapper/__main__.py +++ b/src/mcpbridge_wrapper/__main__.py @@ -128,6 +128,20 @@ def _parse_webui_args( return web_ui, web_ui_only, web_ui_restart, port, config_path, remaining +def _parse_tui_args(args: list) -> Tuple[bool, list]: + """Parse terminal frontend arguments from command-line args.""" + tui_enabled = False + remaining = [] + + for arg in args: + if arg == "--tui": + tui_enabled = True + else: + remaining.append(arg) + + return tui_enabled, remaining + + def _find_listener_pids_for_port(port: int) -> Set[int]: """Return listener PIDs bound to TCP port, or empty set when none found.""" try: @@ -474,6 +488,7 @@ def main() -> int: and outputs unbuffered results to stdout. Supports optional --web-ui flag to start a monitoring dashboard. + Supports optional --tui flag for standalone broker terminal monitoring. Supports optional --broker-daemon flag to start a persistent broker host. Supports optional --broker flag for proxy mode. @@ -495,10 +510,27 @@ def main() -> int: print(f"Error: {exc}", file=sys.stderr) return 2 + tui_enabled, after_tui_args = _parse_tui_args(after_webui_args) broker_daemon, broker_connect, broker_spawn, broker_status, broker_stop, bridge_args = ( - _parse_broker_args(after_webui_args) + _parse_broker_args(after_tui_args) ) + if tui_enabled and web_ui_enabled: + print( + "Error: --tui cannot be combined with --web-ui flags. " + "Use --web-ui-port/--web-ui-config to target an existing dashboard.", + file=sys.stderr, + ) + return 2 + + if tui_enabled and (broker_daemon or broker_connect or broker_status or broker_stop): + print("Error: --tui cannot be combined with broker mode flags.", file=sys.stderr) + return 2 + + if tui_enabled and bridge_args: + print("Error: --tui does not accept bridge arguments.", file=sys.stderr) + return 2 + if web_ui_only and (broker_daemon or broker_connect): print( "Error: --web-ui-only cannot be combined with broker mode flags.", @@ -506,6 +538,22 @@ def main() -> int: ) return 2 + if tui_enabled: + from mcpbridge_wrapper.tui import build_tui_runtime, run_tui + + if not sys.stdin.isatty() or not sys.stdout.isatty(): + print("Error: --tui requires an interactive terminal.", file=sys.stderr) + return 2 + + tui_runtime = build_tui_runtime( + web_ui_port=web_ui_port, + web_ui_config=web_ui_config, + ) + try: + return run_tui(tui_runtime) + except KeyboardInterrupt: + return 0 + # --broker-status: print broker daemon status and exit if broker_status: from mcpbridge_wrapper import __version__ diff --git a/src/mcpbridge_wrapper/tui.py b/src/mcpbridge_wrapper/tui.py new file mode 100644 index 0000000..bab47c8 --- /dev/null +++ b/src/mcpbridge_wrapper/tui.py @@ -0,0 +1,461 @@ +"""Terminal frontend for broker daemon monitoring and control.""" + +from __future__ import annotations + +import base64 +import contextlib +import ipaddress +import json +import os +import textwrap +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from mcpbridge_wrapper.broker.types import BrokerConfig +from mcpbridge_wrapper.webui.config import WebUIConfig + + +@dataclass +class TUIRuntimeConfig: + """Resolved runtime settings for the broker terminal UI.""" + + base_url: str + auth_header: str | None + log_path: Path + pid_file: Path = field(default_factory=lambda: BrokerConfig.default().pid_file) + socket_path: Path = field(default_factory=lambda: BrokerConfig.default().socket_path) + version_file: Path = field(default_factory=lambda: BrokerConfig.default().version_file) + timeout_seconds: float = 1.5 + refresh_interval_seconds: float = 1.0 + recent_log_lines: int = 8 + + +@dataclass +class BrokerTUISnapshot: + """Operator-facing snapshot rendered by the broker terminal UI.""" + + base_url: str + service_name: str + can_stop: bool + available: bool + broker: dict[str, Any] | None + recent_events: list[str] + local_pid: int | None = None + local_daemon_running: bool = False + local_socket_present: bool = False + local_daemon_version: str | None = None + local_pid_file: str = "n/a" + local_socket_path: str = "n/a" + local_version_file: str = "n/a" + error_message: str | None = None + status_message: str | None = None + refreshed_at: float = field(default_factory=time.time) + + +def build_tui_runtime( + *, + web_ui_port: int | None, + web_ui_config: str | None, +) -> TUIRuntimeConfig: + """Resolve endpoint, auth, and log-path settings for TUI mode.""" + config = WebUIConfig(config_path=web_ui_config) + if web_ui_port is not None: + config._data["port"] = web_ui_port + + auth_header: str | None = None + if config.auth_enabled: + raw_credentials = f"{config.auth_username}:{config.auth_password}".encode() + token = base64.b64encode(raw_credentials).decode("ascii") + auth_header = f"Basic {token}" + + client_host = _client_host_for_base_url(config.host) + broker_state_dir = BrokerConfig.default().pid_file.parent + return TUIRuntimeConfig( + base_url=f"http://{client_host}:{config.port}", + auth_header=auth_header, + log_path=broker_state_dir / "broker.log", + pid_file=broker_state_dir / "broker.pid", + socket_path=broker_state_dir / "broker.sock", + version_file=broker_state_dir / "broker.version", + ) + + +def tail_log_lines(log_path: Path, max_lines: int = 8) -> list[str]: + """Return the last ``max_lines`` from the broker log with friendly fallbacks.""" + if max_lines <= 0: + return [] + + if not log_path.exists(): + return [f"(no broker log at {log_path})"] + + try: + chunks: list[bytes] = [] + newline_count = 0 + with log_path.open("rb") as handle: + handle.seek(0, os.SEEK_END) + position = handle.tell() + + while position > 0 and newline_count <= max_lines: + read_size = min(4096, position) + position -= read_size + handle.seek(position) + chunk = handle.read(read_size) + chunks.append(chunk) + newline_count += chunk.count(b"\n") + + text = b"".join(reversed(chunks)).decode("utf-8", errors="replace") + except OSError as exc: + return [f"(cannot read broker log at {log_path}: {exc})"] + + lines = text.splitlines() + if not lines: + return ["(broker log is empty)"] + + return lines[-max_lines:] + + +class BrokerTUIClient: + """HTTP-backed client used by the terminal frontend.""" + + def __init__(self, runtime: TUIRuntimeConfig) -> None: + """Store runtime settings for later polling and control calls.""" + self._runtime = runtime + + def fetch_snapshot(self, status_message: str | None = None) -> BrokerTUISnapshot: + """Fetch control + broker status and merge them into one render snapshot.""" + recent_events = tail_log_lines( + self._runtime.log_path, + max_lines=self._runtime.recent_log_lines, + ) + local_pid, local_running = _read_local_pid(self._runtime.pid_file) + local_version = _read_local_version(self._runtime.version_file) + + try: + control = self._request_json("/api/control") + broker_status = self._request_json("/api/broker/status") + except RuntimeError as exc: + return BrokerTUISnapshot( + base_url=self._runtime.base_url, + service_name="unavailable", + can_stop=False, + available=False, + broker=None, + recent_events=recent_events, + local_pid=local_pid, + local_daemon_running=local_running, + local_socket_present=self._runtime.socket_path.exists(), + local_daemon_version=local_version, + local_pid_file=str(self._runtime.pid_file), + local_socket_path=str(self._runtime.socket_path), + local_version_file=str(self._runtime.version_file), + error_message=str(exc), + status_message=status_message, + ) + + service_name = str( + broker_status.get("service_name") or control.get("service_name") or "mcpbridge-wrapper" + ) + broker_payload = broker_status.get("broker") + broker = broker_payload if isinstance(broker_payload, dict) else None + status_error = broker_status.get("error") + + return BrokerTUISnapshot( + base_url=self._runtime.base_url, + service_name=service_name, + can_stop=bool(control.get("can_stop")), + available=bool(broker_status.get("available")), + broker=broker, + recent_events=recent_events, + local_pid=local_pid, + local_daemon_running=local_running, + local_socket_present=self._runtime.socket_path.exists(), + local_daemon_version=local_version, + local_pid_file=str(self._runtime.pid_file), + local_socket_path=str(self._runtime.socket_path), + local_version_file=str(self._runtime.version_file), + error_message=status_error if isinstance(status_error, str) and status_error else None, + status_message=status_message, + ) + + def request_stop(self) -> tuple[bool, str]: + """Request broker shutdown through the control API.""" + try: + payload = self._request_json("/api/control/stop", method="POST") + except RuntimeError as exc: + return False, str(exc) + + message = payload.get("message") + if not isinstance(message, str) or not message: + message = "Shutdown requested." + return True, message + + def _request_json(self, path: str, method: str = "GET") -> dict[str, Any]: + """Perform a JSON request against the local Web UI API.""" + url = f"{self._runtime.base_url.rstrip('/')}{path}" + request = urllib.request.Request(url, method=method) + request.add_header("Accept", "application/json") + if self._runtime.auth_header: + request.add_header("Authorization", self._runtime.auth_header) + + try: + with urllib.request.urlopen(request, timeout=self._runtime.timeout_seconds) as response: + payload = response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + payload = exc.read().decode("utf-8", errors="replace") + detail = _extract_http_error(payload) or str(exc.reason or exc) + raise RuntimeError(f"{method} {path} failed: {detail}") from exc + except urllib.error.URLError as exc: + reason = exc.reason if exc.reason is not None else exc + raise RuntimeError(f"Cannot reach {self._runtime.base_url}: {reason}") from exc + + try: + data = json.loads(payload) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{method} {path} returned invalid JSON.") from exc + + if not isinstance(data, dict): + raise RuntimeError(f"{method} {path} returned an unexpected payload.") + + return data + + +def render_screen(snapshot: BrokerTUISnapshot, width: int) -> list[str]: + """Build wrapped screen lines for the current broker snapshot.""" + broker = snapshot.broker or {} + lines = [ + "mcpbridge-wrapper Broker TUI", + "Keys: q quit | r refresh | s stop broker", + "", + f"Endpoint: {snapshot.base_url}", + f"Service: {snapshot.service_name}", + f"Stop Control: {_availability(snapshot.can_stop)}", + "", + "Local Broker Files", + f"Local PID: {_display_value(snapshot.local_pid)}" + + (" (running)" if snapshot.local_daemon_running else " (not running)"), + f"Local Version: {_display_value(snapshot.local_daemon_version)}", + f"Local Socket Present: {_yes_no(snapshot.local_socket_present)}", + f"PID File: {_display_value(snapshot.local_pid_file)}", + f"Socket Path: {_display_value(snapshot.local_socket_path)}", + f"Version File: {_display_value(snapshot.local_version_file)}", + "", + "Broker Runtime", + ] + + if snapshot.available and broker: + lines.extend( + [ + f"State: {_display_value(broker.get('state'))}", + f"Daemon PID: {_display_value(broker.get('pid'))}", + f"Upstream PID: {_display_value(broker.get('upstream_pid'))}", + f"Connected Clients: {_display_value(broker.get('connected_clients'))}", + f"Upstream Alive: {_yes_no(broker.get('upstream_alive'))}", + f"Initialized: {_yes_no(broker.get('upstream_initialized'))}", + f"Tools Cached: {_yes_no(broker.get('tools_list_cached'))}", + f"Reconnect Attempt: {_display_value(broker.get('reconnect_attempt'))}", + f"Shutdown Requested: {_yes_no(broker.get('shutdown_requested'))}", + f"Socket: {_display_value(broker.get('socket_path'))}", + ] + ) + else: + lines.append("Broker runtime is unavailable.") + if snapshot.error_message: + lines.append(f"Error: {snapshot.error_message}") + + lines.extend(["", "Recent Broker Events"]) + lines.extend(snapshot.recent_events or ["(no broker events found)"]) + + if snapshot.error_message and (snapshot.available and broker): + lines.extend(["", f"Warning: {snapshot.error_message}"]) + + lines.extend( + [ + "", + "Last Refresh: " + + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(snapshot.refreshed_at)), + ] + ) + if snapshot.status_message: + lines.append(f"Message: {snapshot.status_message}") + + return _wrap_lines(lines, width=max(20, width)) + + +class BrokerTUI: + """Thin curses shell around the broker TUI client and render helpers.""" + + def __init__(self, client: BrokerTUIClient, refresh_interval_seconds: float = 1.0) -> None: + """Store the polling client and refresh interval.""" + self._client = client + self._refresh_interval_seconds = refresh_interval_seconds + self._status_message: str | None = None + + def run(self) -> int: + """Start the interactive terminal session.""" + import curses + + return int(curses.wrapper(self._run_loop)) + + def _run_loop(self, stdscr: Any) -> int: + """Drive the refresh and key-handling loop.""" + import curses + + with contextlib.suppress(Exception): + curses.curs_set(0) + + stdscr.nodelay(True) + stdscr.timeout(100) + + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + + while True: + self._render(stdscr, snapshot) + key = stdscr.getch() + if key in (ord("q"), ord("Q")): + return 0 + if key in (ord("r"), ord("R")): + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + continue + if key in (ord("s"), ord("S")): + _, self._status_message = self._client.request_stop() + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + continue + + if time.monotonic() - last_refresh >= self._refresh_interval_seconds: + snapshot = self._client.fetch_snapshot(self._status_message) + last_refresh = time.monotonic() + + def _render(self, stdscr: Any, snapshot: BrokerTUISnapshot) -> None: + """Render the current snapshot into the terminal window.""" + stdscr.erase() + height, width = stdscr.getmaxyx() + lines = render_screen(snapshot, width=max(20, width - 1)) + + # Keep the curses layer thin: rendering is pure and tested separately. + for row, line in enumerate(lines[: max(1, height - 1)]): + with contextlib.suppress(Exception): + stdscr.addnstr(row, 0, line, max(1, width - 1)) + + stdscr.refresh() + + +def run_tui(runtime: TUIRuntimeConfig) -> int: + """Run the broker terminal frontend for the resolved runtime.""" + client = BrokerTUIClient(runtime) + ui = BrokerTUI(client, refresh_interval_seconds=runtime.refresh_interval_seconds) + return ui.run() + + +def _extract_http_error(payload: str) -> str | None: + """Extract a human-readable detail string from an error payload.""" + try: + data = json.loads(payload) + except json.JSONDecodeError: + return payload.strip() or None + + if not isinstance(data, dict): + return payload.strip() or None + + detail = data.get("detail") + if isinstance(detail, str) and detail: + return detail + + message = data.get("message") + if isinstance(message, str) and message: + return message + + error = data.get("error") + if isinstance(error, str) and error: + return error + + return payload.strip() or None + + +def _client_host_for_base_url(host: str) -> str: + """Convert a bind host into a client-friendly URL host component.""" + normalized_host = host.strip() + if not normalized_host: + return "127.0.0.1" + + with contextlib.suppress(ValueError): + parsed_host = ipaddress.ip_address(normalized_host) + if parsed_host.is_unspecified: + return "[::1]" if parsed_host.version == 6 else "127.0.0.1" + if parsed_host.version == 6: + return f"[{normalized_host}]" + return normalized_host + + return normalized_host + + +def _wrap_lines(lines: list[str], width: int) -> list[str]: + """Wrap screen lines to fit the available width.""" + wrapped: list[str] = [] + effective_width = max(1, width) + for line in lines: + if not line: + wrapped.append("") + continue + wrapped.extend( + textwrap.wrap( + line, + width=effective_width, + replace_whitespace=False, + drop_whitespace=False, + ) + or [""] + ) + return wrapped + + +def _availability(value: bool) -> str: + """Format availability for operator output.""" + return "available" if value else "unavailable" + + +def _yes_no(value: Any) -> str: + """Format booleans for operator output.""" + return "yes" if bool(value) else "no" + + +def _display_value(value: Any) -> str: + """Format possibly-missing values for operator output.""" + if value is None or value == "": + return "n/a" + return str(value) + + +def _read_local_pid(pid_file: Path) -> tuple[int | None, bool]: + """Read broker PID from file and report whether it is still alive.""" + if not pid_file.exists(): + return None, False + + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None, False + + try: + os.kill(pid, 0) + except ProcessLookupError: + return pid, False + except PermissionError: + return pid, True + return pid, True + + +def _read_local_version(version_file: Path) -> str | None: + """Read broker version file if present.""" + if not version_file.exists(): + return None + try: + return version_file.read_text(encoding="utf-8").strip() or None + except OSError: + return None diff --git a/tests/unit/test_main_tui.py b/tests/unit/test_main_tui.py new file mode 100644 index 0000000..489ce0e --- /dev/null +++ b/tests/unit/test_main_tui.py @@ -0,0 +1,101 @@ +"""Tests for __main__.py terminal frontend integration.""" + +import json +from unittest.mock import patch + +from mcpbridge_wrapper.__main__ import _parse_tui_args, main + + +class TestParseTUIArgs: + """Tests for _parse_tui_args.""" + + def test_parse_tui_flag(self) -> None: + enabled, remaining = _parse_tui_args(["--tui", "--foo"]) + + assert enabled is True + assert remaining == ["--foo"] + + def test_parse_tui_flag_absent(self) -> None: + enabled, remaining = _parse_tui_args(["--foo"]) + + assert enabled is False + assert remaining == ["--foo"] + + +class TestMainTUI: + """Tests for main() behavior in standalone terminal frontend mode.""" + + def test_main_tui_runs_terminal_frontend(self, tmp_path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text( + json.dumps( + { + "host": "127.0.0.1", + "port": 9091, + "auth": { + "enabled": True, + "username": "alice", + "password": "secret", + }, + } + ) + ) + + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--web-ui-config", str(config_path)], + ), patch("mcpbridge_wrapper.__main__.sys.stdin") as mock_stdin, patch( + "mcpbridge_wrapper.__main__.sys.stdout" + ) as mock_stdout, patch("mcpbridge_wrapper.tui.run_tui", return_value=0) as run_tui: + mock_stdin.isatty.return_value = True + mock_stdout.isatty.return_value = True + + result = main() + + assert result == 0 + runtime = run_tui.call_args.args[0] + assert runtime.base_url == "http://127.0.0.1:9091" + assert runtime.auth_header is not None + assert runtime.auth_header.startswith("Basic ") + + def test_main_tui_rejects_broker_flags(self, capsys) -> None: + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--broker"], + ): + result = main() + + assert result == 2 + assert "--tui cannot be combined with broker mode flags" in capsys.readouterr().err + + def test_main_tui_rejects_webui_flags(self, capsys) -> None: + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--web-ui"], + ): + result = main() + + assert result == 2 + assert "--tui cannot be combined with --web-ui flags" in capsys.readouterr().err + + def test_main_tui_rejects_bridge_args(self, capsys) -> None: + with patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--tui", "--", "--foo"], + ): + result = main() + + assert result == 2 + assert "--tui does not accept bridge arguments" in capsys.readouterr().err + + def test_main_tui_requires_interactive_terminal(self, capsys) -> None: + with patch("mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--tui"]), patch( + "mcpbridge_wrapper.__main__.sys.stdin" + ) as mock_stdin, patch("mcpbridge_wrapper.__main__.sys.stdout") as mock_stdout: + mock_stdin.isatty.return_value = False + mock_stdout.isatty.return_value = True + + result = main() + + assert result == 2 + assert "--tui requires an interactive terminal" in capsys.readouterr().err diff --git a/tests/unit/test_tui.py b/tests/unit/test_tui.py new file mode 100644 index 0000000..8e00dd6 --- /dev/null +++ b/tests/unit/test_tui.py @@ -0,0 +1,589 @@ +"""Tests for the broker terminal frontend.""" + +from __future__ import annotations + +import base64 +import io +import json +import sys +import urllib.error +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import call, patch + +import pytest + +from mcpbridge_wrapper.tui import ( + BrokerTUI, + BrokerTUIClient, + BrokerTUISnapshot, + TUIRuntimeConfig, + _client_host_for_base_url, + _extract_http_error, + _read_local_pid, + _read_local_version, + build_tui_runtime, + render_screen, + run_tui, + tail_log_lines, +) + + +class _FakeHTTPResponse: + def __init__(self, payload: str) -> None: + self._payload = payload.encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> bool: + return False + + def read(self) -> bytes: + return self._payload + + +class _FakeWindow: + def __init__(self, keys: list[int]) -> None: + self._keys = list(keys) + self.lines: list[tuple[int, int, str, int]] = [] + self.timeout_value = None + self.nodelay_value = None + self.refreshed = False + + def nodelay(self, value: bool) -> None: + self.nodelay_value = value + + def timeout(self, value: int) -> None: + self.timeout_value = value + + def getmaxyx(self) -> tuple[int, int]: + return (20, 80) + + def getch(self) -> int: + if not self._keys: + return -1 + return self._keys.pop(0) + + def erase(self) -> None: + return None + + def addnstr(self, row: int, col: int, text: str, width: int) -> None: + self.lines.append((row, col, text, width)) + + def refresh(self) -> None: + self.refreshed = True + + +def _runtime( + *, + auth_header: str | None = None, + timeout_seconds: float = 1.5, + base_url: str = "http://127.0.0.1:8080", +) -> TUIRuntimeConfig: + return TUIRuntimeConfig( + base_url=base_url, + auth_header=auth_header, + log_path=Path("/tmp/broker.log"), + pid_file=Path("/tmp/broker.pid"), + socket_path=Path("/tmp/broker.sock"), + version_file=Path("/tmp/broker.version"), + timeout_seconds=timeout_seconds, + ) + + +def _snapshot() -> BrokerTUISnapshot: + return BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="broker-daemon", + can_stop=True, + available=True, + broker={"state": "ready", "pid": 1, "socket_path": "/tmp/broker.sock"}, + recent_events=["ready"], + local_pid=1, + local_daemon_running=True, + local_socket_present=True, + local_daemon_version="0.4.1", + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", + ) + + +class TestBuildTUIRuntime: + """Tests for runtime resolution helpers.""" + + def test_build_runtime_uses_config_and_auth(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text( + json.dumps( + { + "host": "127.0.0.1", + "port": 9090, + "auth": { + "enabled": True, + "username": "alice", + "password": "secret", + }, + } + ) + ) + + runtime = build_tui_runtime(web_ui_port=None, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://127.0.0.1:9090" + expected = "Basic " + base64.b64encode(b"alice:secret").decode("ascii") + assert runtime.auth_header == expected + assert runtime.log_path.name == "broker.log" + assert runtime.pid_file.name == "broker.pid" + assert runtime.socket_path.name == "broker.sock" + assert runtime.version_file.name == "broker.version" + + def test_build_runtime_port_override_wins(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text(json.dumps({"port": 8080})) + + runtime = build_tui_runtime(web_ui_port=9191, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://127.0.0.1:9191" + + def test_build_runtime_normalizes_unspecified_host_to_loopback(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text(json.dumps({"host": "0.0.0.0", "port": 9090})) + + runtime = build_tui_runtime(web_ui_port=None, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://127.0.0.1:9090" + + def test_build_runtime_brackets_ipv6_host(self, tmp_path: Path) -> None: + config_path = tmp_path / "webui.json" + config_path.write_text(json.dumps({"host": "::1", "port": 9090})) + + runtime = build_tui_runtime(web_ui_port=None, web_ui_config=str(config_path)) + + assert runtime.base_url == "http://[::1]:9090" + + +class TestTailLogLines: + """Tests for broker log tailing.""" + + def test_tail_log_lines_reads_recent_entries(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("line-1\nline-2\nline-3\n") + + assert tail_log_lines(log_path, max_lines=2) == ["line-2", "line-3"] + + def test_tail_log_lines_reports_missing_log(self, tmp_path: Path) -> None: + lines = tail_log_lines(tmp_path / "missing.log", max_lines=3) + + assert len(lines) == 1 + assert "no broker log" in lines[0] + + def test_tail_log_lines_handles_zero_limit(self, tmp_path: Path) -> None: + assert tail_log_lines(tmp_path / "broker.log", max_lines=0) == [] + + def test_tail_log_lines_reports_empty_log(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("") + + assert tail_log_lines(log_path, max_lines=3) == ["(broker log is empty)"] + + def test_tail_log_lines_handles_read_error(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("line-1\n") + + with patch.object(Path, "open", side_effect=OSError("denied")): + lines = tail_log_lines(log_path, max_lines=3) + + assert len(lines) == 1 + assert "cannot read broker log" in lines[0] + + def test_tail_log_lines_reads_tail_from_large_log(self, tmp_path: Path) -> None: + log_path = tmp_path / "broker.log" + log_path.write_text("".join(f"line-{index}\n" for index in range(1000))) + + assert tail_log_lines(log_path, max_lines=3) == ["line-997", "line-998", "line-999"] + + +class TestBrokerTUIClient: + """Tests for HTTP aggregation and control helpers.""" + + def test_fetch_snapshot_combines_control_status_and_log_tail(self) -> None: + runtime = _runtime() + client = BrokerTUIClient(runtime) + + with patch.object( + client, + "_request_json", + side_effect=[ + {"service_name": "broker-daemon", "can_stop": True}, + { + "available": True, + "service_name": "broker-daemon", + "broker": { + "state": "ready", + "pid": 101, + "upstream_pid": 202, + "connected_clients": 3, + }, + }, + ], + ) as request_json, patch( + "mcpbridge_wrapper.tui.tail_log_lines", return_value=["ready"] + ) as tail_lines: + snapshot = client.fetch_snapshot("Refreshed.") + + assert snapshot.service_name == "broker-daemon" + assert snapshot.can_stop is True + assert snapshot.available is True + assert snapshot.broker is not None + assert snapshot.broker["connected_clients"] == 3 + assert snapshot.recent_events == ["ready"] + assert snapshot.status_message == "Refreshed." + assert snapshot.local_pid is None + assert snapshot.local_daemon_running is False + assert request_json.call_args_list == [call("/api/control"), call("/api/broker/status")] + tail_lines.assert_called_once_with(runtime.log_path, max_lines=runtime.recent_log_lines) + + def test_fetch_snapshot_surfaces_runtime_errors(self) -> None: + client = BrokerTUIClient(_runtime()) + + with patch.object(client, "_request_json", side_effect=RuntimeError("boom")), patch( + "mcpbridge_wrapper.tui.tail_log_lines", return_value=["event"] + ): + snapshot = client.fetch_snapshot() + + assert snapshot.available is False + assert snapshot.error_message == "boom" + assert snapshot.recent_events == ["event"] + + def test_request_stop_returns_backend_message(self) -> None: + client = BrokerTUIClient(_runtime()) + + with patch.object( + client, + "_request_json", + return_value={"status": "accepted", "message": "Shutdown requested for broker-daemon."}, + ): + ok, message = client.request_stop() + + assert ok is True + assert message == "Shutdown requested for broker-daemon." + + def test_request_stop_returns_default_message_when_backend_omits_one(self) -> None: + client = BrokerTUIClient(_runtime()) + + with patch.object(client, "_request_json", return_value={"status": "accepted"}): + ok, message = client.request_stop() + + assert ok is True + assert message == "Shutdown requested." + + def test_request_stop_surfaces_runtime_error(self) -> None: + client = BrokerTUIClient(_runtime()) + + with patch.object(client, "_request_json", side_effect=RuntimeError("stop unavailable")): + ok, message = client.request_stop() + + assert ok is False + assert message == "stop unavailable" + + def test_request_json_success_includes_auth_header(self) -> None: + client = BrokerTUIClient(_runtime(auth_header="Basic token", timeout_seconds=2.0)) + captured: dict[str, object] = {} + + def fake_urlopen(request, timeout): + headers = dict(request.header_items()) + captured["url"] = request.full_url + captured["timeout"] = timeout + captured["auth"] = headers.get("Authorization") + return _FakeHTTPResponse('{"status": "ok"}') + + with patch("mcpbridge_wrapper.tui.urllib.request.urlopen", side_effect=fake_urlopen): + payload = client._request_json("/api/control") + + assert payload == {"status": "ok"} + assert captured == { + "url": "http://127.0.0.1:8080/api/control", + "timeout": 2.0, + "auth": "Basic token", + } + + def test_request_json_http_error_uses_detail_message(self) -> None: + client = BrokerTUIClient(_runtime()) + error = urllib.error.HTTPError( + url="http://127.0.0.1:8080/api/control/stop", + code=409, + msg="Conflict", + hdrs=None, + fp=io.BytesIO(b'{"detail":"Stop control is not available."}'), + ) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", side_effect=error + ), pytest.raises(RuntimeError, match="Stop control is not available"): + client._request_json("/api/control/stop", method="POST") + + def test_request_json_url_error_is_actionable(self) -> None: + client = BrokerTUIClient(_runtime()) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", + side_effect=urllib.error.URLError("refused"), + ), pytest.raises(RuntimeError, match="Cannot reach http://127.0.0.1:8080: refused"): + client._request_json("/api/control") + + def test_request_json_rejects_invalid_json(self) -> None: + client = BrokerTUIClient(_runtime()) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", + return_value=_FakeHTTPResponse("not json"), + ), pytest.raises(RuntimeError, match="returned invalid JSON"): + client._request_json("/api/control") + + def test_request_json_rejects_non_mapping_payload(self) -> None: + client = BrokerTUIClient(_runtime()) + + with patch( + "mcpbridge_wrapper.tui.urllib.request.urlopen", + return_value=_FakeHTTPResponse('["bad"]'), + ), pytest.raises(RuntimeError, match="unexpected payload"): + client._request_json("/api/control") + + +class TestRenderScreen: + """Tests for pure screen rendering helpers.""" + + def test_render_screen_includes_runtime_fields(self) -> None: + snapshot = BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="broker-daemon", + can_stop=True, + available=True, + broker={ + "state": "ready", + "pid": 111, + "upstream_pid": 222, + "connected_clients": 2, + "upstream_alive": True, + "upstream_initialized": True, + "tools_list_cached": True, + "reconnect_attempt": 0, + "shutdown_requested": False, + "socket_path": "/tmp/broker.sock", + }, + recent_events=["ready", "tools/list cached"], + local_pid=111, + local_daemon_running=True, + local_socket_present=True, + local_daemon_version="0.4.1", + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", + status_message="Watching broker.", + ) + + output = "\n".join(render_screen(snapshot, width=80)) + + assert "Local Broker Files" in output + assert "Local Version: 0.4.1" in output + assert "State: ready" in output + assert "Connected Clients: 2" in output + assert "Recent Broker Events" in output + assert "Watching broker." in output + + def test_render_screen_handles_unavailable_backend(self) -> None: + snapshot = BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="unavailable", + can_stop=False, + available=False, + broker=None, + recent_events=["(no broker log)"], + local_pid=None, + local_daemon_running=False, + local_socket_present=False, + local_daemon_version=None, + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", + error_message="Cannot reach http://127.0.0.1:8080: refused", + ) + + output = "\n".join(render_screen(snapshot, width=80)) + + assert "Broker runtime is unavailable." in output + assert "Cannot reach http://127.0.0.1:8080: refused" in output + assert "Local Socket Present: no" in output + + def test_render_screen_shows_runtime_warning_when_broker_is_available(self) -> None: + snapshot = BrokerTUISnapshot( + base_url="http://127.0.0.1:8080", + service_name="broker-daemon", + can_stop=True, + available=True, + broker={"state": "reconnecting", "pid": None, "socket_path": ""}, + recent_events=[], + local_pid=None, + local_daemon_running=False, + local_socket_present=False, + local_daemon_version=None, + local_pid_file="/tmp/broker.pid", + local_socket_path="/tmp/broker.sock", + local_version_file="/tmp/broker.version", + error_message="degraded runtime", + ) + + output = "\n".join(render_screen(snapshot, width=60)) + + assert "Warning: degraded runtime" in output + assert "(no broker events found)" in output + + +class TestBrokerTUI: + """Tests for the thin curses shell.""" + + def test_run_loop_renders_and_exits_on_q(self) -> None: + snapshot = _snapshot() + + class _Client: + def fetch_snapshot(self, status_message): + del status_message + return snapshot + + window = _FakeWindow([ord("q")]) + fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) + ui = BrokerTUI(_Client()) + + with patch.dict(sys.modules, {"curses": fake_curses}): + result = ui._run_loop(window) + + assert result == 0 + assert window.timeout_value == 100 + assert window.nodelay_value is True + assert window.refreshed is True + assert any("Broker Runtime" in line[2] for line in window.lines) + + def test_run_loop_handles_refresh_and_stop_keys(self) -> None: + snapshot = _snapshot() + + class _Client: + def __init__(self) -> None: + self.fetch_calls: list[str | None] = [] + self.request_stop_calls = 0 + + def fetch_snapshot(self, status_message): + self.fetch_calls.append(status_message) + return snapshot + + def request_stop(self): + self.request_stop_calls += 1 + return True, "stop requested" + + client = _Client() + window = _FakeWindow([ord("r"), ord("s"), ord("q")]) + fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) + ui = BrokerTUI(client) + + with patch.dict(sys.modules, {"curses": fake_curses}): + result = ui._run_loop(window) + + assert result == 0 + assert client.fetch_calls == [None, None, "stop requested"] + assert client.request_stop_calls == 1 + + def test_run_loop_refreshes_on_timer(self) -> None: + snapshot = _snapshot() + + class _Client: + def __init__(self) -> None: + self.fetch_calls: list[str | None] = [] + + def fetch_snapshot(self, status_message): + self.fetch_calls.append(status_message) + return snapshot + + def request_stop(self): + return True, "unused" + + client = _Client() + window = _FakeWindow([-1, ord("q")]) + fake_curses = SimpleNamespace(curs_set=lambda *_args, **_kwargs: None) + ui = BrokerTUI(client, refresh_interval_seconds=1.0) + + with patch("mcpbridge_wrapper.tui.time.monotonic", side_effect=[0.0, 2.0, 2.0]), patch.dict( + sys.modules, {"curses": fake_curses} + ): + result = ui._run_loop(window) + + assert result == 0 + assert client.fetch_calls == [None, None] + + def test_run_uses_curses_wrapper(self) -> None: + fake_curses = SimpleNamespace( + wrapper=lambda func: 7, + curs_set=lambda *_args, **_kwargs: None, + ) + ui = BrokerTUI(SimpleNamespace()) + + with patch.dict(sys.modules, {"curses": fake_curses}): + assert ui.run() == 7 + + def test_run_tui_builds_ui_and_runs_it(self) -> None: + with patch("mcpbridge_wrapper.tui.BrokerTUI.run", return_value=5) as run_method: + assert run_tui(_runtime()) == 5 + + run_method.assert_called_once() + + +class TestHelpers: + """Tests for small formatting and local-state helpers.""" + + def test_extract_http_error_prefers_known_fields(self) -> None: + assert _extract_http_error('{"detail":"fine"}') == "fine" + assert _extract_http_error('{"message":"hello"}') == "hello" + assert _extract_http_error('{"error":"boom"}') == "boom" + assert _extract_http_error("plain text") == "plain text" + + def test_client_host_for_base_url_normalizes_special_hosts(self) -> None: + assert _client_host_for_base_url("127.0.0.1") == "127.0.0.1" + assert _client_host_for_base_url("0.0.0.0") == "127.0.0.1" + assert _client_host_for_base_url("::1") == "[::1]" + assert _client_host_for_base_url("::") == "[::1]" + assert _client_host_for_base_url(" localhost ") == "localhost" + + def test_read_local_pid_and_version_helpers(self, tmp_path: Path) -> None: + pid_file = tmp_path / "broker.pid" + version_file = tmp_path / "broker.version" + pid_file.write_text("1234") + version_file.write_text("0.4.1") + + with patch("mcpbridge_wrapper.tui.os.kill") as os_kill: + assert _read_local_pid(pid_file) == (1234, True) + os_kill.assert_called_once_with(1234, 0) + + assert _read_local_version(version_file) == "0.4.1" + + def test_read_local_pid_handles_missing_stale_and_permission(self, tmp_path: Path) -> None: + missing = tmp_path / "missing.pid" + assert _read_local_pid(missing) == (None, False) + + stale = tmp_path / "stale.pid" + stale.write_text("9876") + with patch("mcpbridge_wrapper.tui.os.kill", side_effect=ProcessLookupError): + assert _read_local_pid(stale) == (9876, False) + + protected = tmp_path / "protected.pid" + protected.write_text("4321") + with patch("mcpbridge_wrapper.tui.os.kill", side_effect=PermissionError): + assert _read_local_pid(protected) == (4321, True) + + def test_read_local_version_handles_missing_and_read_error(self, tmp_path: Path) -> None: + missing = tmp_path / "missing.version" + assert _read_local_version(missing) is None + + broken = tmp_path / "broken.version" + broken.write_text("0.4.1") + with patch.object(Path, "read_text", side_effect=OSError): + assert _read_local_version(broken) is None