"""Textual TUI wrapping `mcp-server/run-tests.sh`. Launch: ``meshtastic-mcp-test-tui [pytest-args]`` The TUI *wraps* ``run-tests.sh``; it never replaces it. Same script, same env-var resolution, same ``userPrefs.jsonc`` session fixture. Four data sources drive live state: 1. ``tests/reportlog.jsonl`` — written by ``pytest-reportlog``. Tailed in a worker thread; each JSON line is published as a :class:`ReportLogEvent` message. This is the authoritative source for tree population + per-test outcome. 2. The pytest subprocess ``stdout`` + ``stderr`` streams — line-by-line, published as :class:`PytestLine` messages and rendered verbatim in the pytest pane. 3. ``tests/fwlog.jsonl`` — firmware log stream. Written by the ``_firmware_log_stream`` autouse session fixture in ``conftest.py`` (mirrors every ``meshtastic.log.line`` pubsub event), tailed by the :class:`FirmwareLogTailer` worker, displayed in a wrap-enabled RichLog with cycleable port filter. 4. ``devices.list_devices()`` + ``info.device_info(port)`` — polled only at startup and again after ``RunFinished``. Device polling while pytest holds a SerialInterface would deadlock on the exclusive port lock; the existing ``hub_devices`` fixture is session-scoped so there is no safe "between tests" window. The header reflects this with a "(stale)" marker while the run is active. Key bindings (see :class:`TestTuiApp.BINDINGS`): ``r`` re-run focused ``f`` filter tree ``d`` failure detail ``g`` open report.html ``l`` cycle firmware-log port filter ``x`` export reproducer bundle ``c`` tool-coverage panel ``q`` / Ctrl-C graceful quit with SIGINT → SIGTERM → SIGKILL escalation Shipped today (v1 + v2 slice): test tree + tier counters with progress bars, pytest tail, live firmware log with port filter, device strip with "currently running" status column, failure-detail modal, reproducer bundle export (filters fwlog by test's start/stop timestamps), tool-coverage modal, cross-run history sparkline in the header, clean SIGINT propagation. Still open (see the plan file): mesh topology mini-diagram and airtime / channel-utilization gauges. """ from __future__ import annotations import argparse import json import os import pathlib import signal import subprocess import sys import threading import time from dataclasses import dataclass, field from typing import Any, Iterator # --------------------------------------------------------------------------- # Configuration constants # --------------------------------------------------------------------------- # Tier names that map nodeids like "tests//..." to counter buckets. # Order here == display order in the tier-counters table. Matches the order # `pytest_collection_modifyitems` in `conftest.py` uses: # bake → unit → mesh → telemetry → monitor → fleet → admin → provisioning # so the counters table reads top-to-bottom in execution order. # # "bake" is the synthetic tier for `tests/test_00_bake.py` — the file sits # at the `tests/` root rather than under a tier subdirectory, so without # this mapping `_tier_of_nodeid` would return "other" and the bake outcomes # would be silently dropped from both the tier table and the history # record (which sums tier counters to compute passed/failed/skipped). TIERS = ( "bake", "unit", "mesh", "telemetry", "monitor", "fleet", "admin", "provisioning", ) # Relative paths from the mcp-server root. _REPORTLOG_RELATIVE = "tests/reportlog.jsonl" _FWLOG_RELATIVE = "tests/fwlog.jsonl" # pio / esptool / nrfutil / picotool tee subprocess output here when # `MESHTASTIC_MCP_FLASH_LOG` is set (see `pio._run_capturing`). run-tests.sh # sets that env var; the TUI also sets it for direct `_spawn_pytest` calls # so `r`-key re-runs that skip the wrapper still get tee'd output. _FLASHLOG_RELATIVE = "tests/flash.log" _REPORT_HTML_RELATIVE = "tests/report.html" _TOOL_COVERAGE_RELATIVE = "tests/tool_coverage.json" _HISTORY_RELATIVE = "tests/.history/runs.jsonl" _REPRODUCERS_RELATIVE = "tests/reproducers" _RUN_TESTS_RELATIVE = "run-tests.sh" _RUN_COUNTER_RELATIVE = "tests/.tui-runs" # Graceful-shutdown budgets (seconds) for the pytest subprocess when the # user hits `q`. Matches what the existing CLI's atexit + userprefs sidecar # self-heal expects. _SIGINT_GRACE_S = 5.0 _SIGTERM_GRACE_S = 5.0 # --------------------------------------------------------------------------- # Path resolution # --------------------------------------------------------------------------- def _mcp_server_root() -> pathlib.Path: """Locate the mcp-server directory (the one containing run-tests.sh).""" here = pathlib.Path(__file__).resolve() # Walk up until we find pyproject.toml with a matching project name, or # default to the three-up ancestor (src/meshtastic_mcp/cli/test_tui.py → # .../mcp-server). The walk-up protects against unusual checkouts. for parent in (here.parent, *here.parents): if (parent / "pyproject.toml").is_file() and ( parent / "run-tests.sh" ).is_file(): return parent return here.parents[3] # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class LeafReport: """Per-test state drawn from reportlog events. Outcomes mirror pytest's: "passed" | "failed" | "skipped" | "running". """ nodeid: str tier: str outcome: str = "pending" duration_s: float = 0.0 longrepr: str = "" # Captured stdout / stderr / firmware-log sections from the test's # `TestReport.sections` — shown in the failure-detail modal. sections: list[tuple[str, str]] = field(default_factory=list) # Wall-clock start/stop from the TestReport event. Used by the # reproducer exporter (`x`) to filter `tests/fwlog.jsonl` down to # just the lines around the failure window. start_ts: float | None = None stop_ts: float | None = None @dataclass class TierCounters: tier: str passed: int = 0 failed: int = 0 skipped: int = 0 running: int = 0 remaining: int = 0 @dataclass class DeviceRow: role: str | None port: str vid: str pid: str description: str # Populated from info.device_info when available; empty dict when we # haven't queried (or when the poller is paused). info: dict[str, Any] = field(default_factory=dict) @dataclass class State: """Shared state owned by the App; written by workers under `lock`. UI code reads via Textual Message handlers which run on the UI thread in the order workers called `post_message` — so reads don't need the lock themselves. """ lock: threading.Lock = field(default_factory=threading.Lock) tiers: dict[str, TierCounters] = field( default_factory=lambda: {t: TierCounters(tier=t) for t in TIERS} ) leaves: dict[str, LeafReport] = field(default_factory=dict) # Ordered list of nodeids in the order they were first seen — lets us # rebuild the tree deterministically. nodeid_order: list[str] = field(default_factory=list) devices: list[DeviceRow] = field(default_factory=list) run_active: bool = False exit_code: int | None = None # nodeid of the currently-running test. Set on `when="setup"` + # outcome="passed" (body about to execute); cleared on `when="call"` # (any outcome) or on `when="setup"` + outcome="failed" (no body # window). Drives the device-table "Status" column so the operator # can see which test is touching a given device right now. running_nodeid: str | None = None # `time.monotonic()` captured when `running_nodeid` was set. Surfaced # as live-updating elapsed-time ("RUNNING: test_bake_nrf52 (1:23)") so # an operator staring at a ~3 min `test_00_bake` or a `mesh_formation` # with a 60 s ceiling has concrete evidence the test isn't stuck. running_started_at: float | None = None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _tier_of_nodeid(nodeid: str) -> str: """Map a pytest nodeid to its tier bucket. Unknown → 'other'. `tests/test_00_bake.py::...` is special-cased to the synthetic `bake` tier — it's a top-level file (no tier subdirectory) so the generic "second path segment" logic would miss it and route the bake outcomes into the non-existent `other` bucket. """ parts = nodeid.split("/", 2) if len(parts) >= 2 and parts[0] == "tests": # Bake file sits at `tests/test_00_bake.py` — dedicated bucket. if parts[1].startswith("test_00_bake"): return "bake" candidate = parts[1] if candidate in TIERS: return candidate return "other" def _file_of_nodeid(nodeid: str) -> str: """Extract the test file name (e.g. 'test_boards.py') from a nodeid.""" left = nodeid.split("::", 1)[0] return left.rsplit("/", 1)[-1] def _testname_of_nodeid(nodeid: str) -> str: """Extract the 'test_foo[param]' suffix from a nodeid, or the full thing.""" if "::" in nodeid: return nodeid.split("::", 1)[1] return nodeid def _roles_from_nodeid(nodeid: str) -> set[str]: """Infer which device roles a parametrized test touches. Patterns we recognize (from the existing ``conftest.py`` parametrization in ``pytest_generate_tests``): - ``test_foo[nrf52]`` → {"nrf52"} (baked_single) - ``test_foo[nrf52->esp32s3]`` → {"nrf52", "esp32s3"} (mesh_pair) Unparametrized tests (no bracket) return an empty set — the caller should fall back to "this test involves ALL detected devices" rather than pretending it touches none. """ if "[" not in nodeid or not nodeid.endswith("]"): return set() try: inner = nodeid.rsplit("[", 1)[1][:-1] except Exception: return set() # Split on "->" for directed mesh pairs; otherwise treat as single role. parts = [p.strip() for p in inner.split("->")] if "->" in inner else [inner.strip()] return {p for p in parts if p} def _parse_events(path: pathlib.Path) -> Iterator[dict[str, Any]]: """Yield parsed JSON dicts from a reportlog file, skipping malformed lines. Used for smoke-testing the parser against a finished file; the live worker has its own tail loop. """ if not path.is_file(): return with path.open("r", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue try: yield json.loads(line) except json.JSONDecodeError: continue def _load_run_number(counter_path: pathlib.Path) -> int: """Bump + persist a monotonic run counter used in the TUI header.""" try: n = int(counter_path.read_text().strip()) except Exception: n = 0 n += 1 try: counter_path.parent.mkdir(parents=True, exist_ok=True) counter_path.write_text(str(n)) except Exception: # Non-fatal: the counter is cosmetic. pass return n def _resolve_seed() -> str: """Mirror the default-seed resolution from run-tests.sh. Operator can override via MESHTASTIC_MCP_SEED. Matches the per-user/per-host default so repeated invocations land on the same PSK (makes --assume-baked valid across invocations). """ if explicit := os.environ.get("MESHTASTIC_MCP_SEED"): return explicit try: who = os.environ.get("USER") or os.environ.get("LOGNAME") or "anon" except Exception: who = "anon" try: import socket host = socket.gethostname().split(".", 1)[0] except Exception: host = "host" return f"mcp-{who}-{host}" def _format_duration(seconds: float) -> str: if seconds < 60: return f"{seconds:5.1f}s" m, s = divmod(int(seconds), 60) return f"{m:d}:{s:02d}" # --------------------------------------------------------------------------- # Textual imports (lazy — only when main() runs, so `_parse_events` can be # imported by smoke tests without requiring textual installed in every env) # --------------------------------------------------------------------------- def _import_textual() -> Any: """Return a namespace carrying every Textual class we use. Deferred import keeps `_parse_events` + `_tier_of_nodeid` importable from tests / smoke scripts without pulling in the UI stack. """ import textual from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.message import Message from textual.screen import ModalScreen from textual.widgets import DataTable, Footer, Input, RichLog, Static, Tree ns = argparse.Namespace() ns.App = App ns.Binding = Binding ns.ComposeResult = ComposeResult ns.DataTable = DataTable ns.Footer = Footer ns.Horizontal = Horizontal ns.Input = Input ns.Message = Message ns.ModalScreen = ModalScreen ns.RichLog = RichLog ns.Static = Static ns.Tree = Tree ns.Vertical = Vertical ns.textual = textual return ns # --------------------------------------------------------------------------- # main() — the important scaffolding lives here so that when we bail out # before entering the Textual event loop (missing terminal, --help, etc.) # nothing has grabbed the screen yet. # --------------------------------------------------------------------------- def main(argv: list[str] | None = None) -> int: """Entry point for `meshtastic-mcp-test-tui`.""" argv = list(argv if argv is not None else sys.argv[1:]) parser = argparse.ArgumentParser( prog="meshtastic-mcp-test-tui", description=( "Live Textual TUI wrapping mcp-server/run-tests.sh. " "Passes any unrecognized arguments through to pytest." ), allow_abbrev=False, ) parser.add_argument( "--no-tui", action="store_true", help=( "Skip the TUI and exec run-tests.sh directly. Useful as a health " "check that the wrapper argv+env resolution is working." ), ) args, pytest_args = parser.parse_known_args(argv) root = _mcp_server_root() run_tests = root / _RUN_TESTS_RELATIVE reportlog = root / _REPORTLOG_RELATIVE fwlog = root / _FWLOG_RELATIVE flashlog = root / _FLASHLOG_RELATIVE counter = root / _RUN_COUNTER_RELATIVE if not run_tests.is_file(): print( f"error: could not locate {_RUN_TESTS_RELATIVE} relative to " f"{root}. Is this the mcp-server checkout?", file=sys.stderr, ) return 2 # Always clear stale log files before launching pytest. The TUI's tail # workers race pytest file-creation; starting from a known-empty state # avoids mid-line-decode confusion from the prior run. The fwlog session # fixture also truncates on its end, and run-tests.sh truncates the # flashlog — triple-truncate is deliberate (whichever side creates the # file first, it starts empty). for p in (reportlog, fwlog, flashlog): try: p.unlink(missing_ok=True) except Exception: pass # Compute + persist the run counter for the header (cosmetic). run_number = _load_run_number(counter) seed = _resolve_seed() # Export the seed so the subprocess inherits the SAME value the TUI # displays. run-tests.sh computes its own fallback if unset, and we'd # end up with a header / wrapper-header mismatch if we let that happen. os.environ.setdefault("MESHTASTIC_MCP_SEED", seed) # Turn on subprocess-output tee'ing so `pio._run_capturing` writes each # line of pio / esptool / nrfutil / picotool output to `tests/flash.log` # as it arrives. The TUI tails that file and routes each line to the # pytest pane so the operator sees live flash progress during long # `pio run -t upload` / `esptool erase_flash` operations. run-tests.sh # also sets this when invoked directly — `setdefault` so the wrapper's # value wins when present. os.environ.setdefault("MESHTASTIC_MCP_FLASH_LOG", str(flashlog)) # --no-tui: exec run-tests.sh directly. Useful for diagnosing wrapper # env / argv handling without getting into Textual's alternate screen. if args.no_tui: cmd = [str(run_tests), *pytest_args] os.execv(str(run_tests), cmd) # noqa: S606 — intentional # Textual UI import is deferred so `--help` and `--no-tui` do not pay # the ~40 MB startup cost. try: tx = _import_textual() except ImportError as exc: print( f"error: textual is not installed ({exc}). Install with: " f"pip install -e '.[test]'", file=sys.stderr, ) return 2 # Narrow-terminal warning (see plan §8 risk 2). Textual itself degrades, # but a heads-up helps a first-time user. term = os.environ.get("TERM", "") if term in ("", "dumb", "screen") and not os.environ.get("TEXTUAL_NO_TERM_HINT"): print( f"[hint] TERM={term!r} may render poorly. Try " f"`TERM=xterm-256color meshtastic-mcp-test-tui ...` if the layout " f"looks broken.", file=sys.stderr, ) app = _build_app( tx=tx, root=root, run_tests=run_tests, reportlog=reportlog, fwlog=fwlog, flashlog=flashlog, seed=seed, run_number=run_number, pytest_args=pytest_args, ) # App.run() returns the subprocess exit code via `app.exit(returncode)`. return_value = app.run() if isinstance(return_value, int): return return_value return 0 # --------------------------------------------------------------------------- # Everything below is only reachable once Textual is importable. `tx` is # the namespace returned by `_import_textual()` so we don't scatter `from # textual import ...` across the file. # --------------------------------------------------------------------------- def _build_app( *, tx: Any, root: pathlib.Path, run_tests: pathlib.Path, reportlog: pathlib.Path, fwlog: pathlib.Path, flashlog: pathlib.Path, seed: str, run_number: int, pytest_args: list[str], ) -> Any: """Assemble TestTuiApp with its Textual-dependent inner classes. Keeping the class definitions inside a factory means `main()` can short-circuit (--no-tui, terminal-check, argparse error) before we force Textual's import cost. """ # Helper modules — lazy-imported here so the top-of-file import cost # only kicks in when main() has decided to run the TUI. from . import _flashlog as _flashlog_mod from . import _fwlog as _fwlog_mod from . import _history as _history_mod from . import _reproducer as _reproducer_mod # ---------------- Messages ---------------- class ReportLogEvent(tx.Message): def __init__(self, event: dict[str, Any]) -> None: self.event = event super().__init__() class PytestLine(tx.Message): def __init__(self, source: str, line: str) -> None: self.source = source # "stdout" | "stderr" self.line = line super().__init__() class FirmwareLogLine(tx.Message): def __init__(self, record: dict[str, Any]) -> None: # {"ts": float, "port": str | None, "line": str} self.record = record super().__init__() class FlashLogLine(tx.Message): """Plain-text line from `tests/flash.log` — pio / esptool / nrfutil / picotool output tee'd by `pio._run_capturing`. Routed to the pytest pane so the operator sees live flash progress during `test_00_bake` instead of 3 minutes of pytest-captured silence.""" def __init__(self, line: str) -> None: self.line = line super().__init__() class DeviceSnapshot(tx.Message): def __init__(self, rows: list[DeviceRow]) -> None: self.rows = rows super().__init__() class RunFinished(tx.Message): def __init__(self, returncode: int) -> None: self.returncode = returncode super().__init__() # ---------------- Workers ---------------- class ReportlogWorker(threading.Thread): """Tail `reportlog.jsonl`, publish each event.""" def __init__(self, app: Any, path: pathlib.Path, stop: threading.Event) -> None: super().__init__(daemon=True, name="reportlog-tail") self._app = app self._path = path self._stop = stop def run(self) -> None: # Wait up to 30 s for pytest to create the file (first call on # a cold cache can be slow). wait_deadline = time.monotonic() + 30.0 while not self._path.is_file(): if self._stop.is_set() or time.monotonic() > wait_deadline: return time.sleep(0.1) try: fh = self._path.open("r", encoding="utf-8") except OSError: return try: while not self._stop.is_set(): line = fh.readline() if not line: time.sleep(0.05) continue line = line.strip() if not line: continue try: event = json.loads(line) except json.JSONDecodeError: continue self._app.post_message(ReportLogEvent(event)) finally: fh.close() class SubprocessReaderWorker(threading.Thread): """Read one stream line-by-line and publish PytestLine messages.""" def __init__( self, app: Any, stream: Any, source: str, stop: threading.Event, ) -> None: super().__init__(daemon=True, name=f"subprocess-{source}") self._app = app self._stream = stream self._source = source self._stop = stop def run(self) -> None: try: for line in iter(self._stream.readline, ""): if self._stop.is_set(): break self._app.post_message( PytestLine(source=self._source, line=line.rstrip("\n")) ) except Exception: # stream closed / subprocess died; not fatal. pass class DevicePollerWorker(threading.Thread): """Poll list_devices() + device_info() at startup and after RunFinished. Deliberately NOT polling during the run — `hub_devices` is a session-scoped fixture holding SerialInterfaces across the whole session, and device_info() would deadlock on the exclusive port lock. Header shows "(stale)" during the gap. """ def __init__(self, app: Any, state: State, stop: threading.Event) -> None: super().__init__(daemon=True, name="device-poller") self._app = app self._state = state self._stop = stop self._trigger = threading.Event() def trigger(self) -> None: self._trigger.set() def run(self) -> None: # Perform one poll at startup; then wait for explicit triggers. self._poll_once() while not self._stop.is_set(): if self._trigger.wait(timeout=0.5): self._trigger.clear() if self._stop.is_set(): break with self._state.lock: active = self._state.run_active if active: continue self._poll_once() def _poll_once(self) -> None: try: from meshtastic_mcp import devices as devices_mod from meshtastic_mcp import info as info_mod except Exception as exc: # pragma: no cover self._app.post_message( PytestLine( source="stderr", line=f"[tui] device import failed: {exc!r}" ) ) return rows: list[DeviceRow] = [] try: raw = devices_mod.list_devices(include_unknown=True) except Exception as exc: self._app.post_message( PytestLine( source="stderr", line=f"[tui] list_devices failed: {exc!r}" ) ) return for d in raw: vid_raw = d.get("vid") or "" try: vid_i = ( int(vid_raw, 16) if isinstance(vid_raw, str) and vid_raw.startswith("0x") else int(vid_raw) ) except (TypeError, ValueError): vid_i = 0 role = None if vid_i == 0x239A: role = "nrf52" elif vid_i in (0x303A, 0x10C4): role = "esp32s3" if not role and not d.get("likely_meshtastic"): continue row = DeviceRow( role=role, port=d.get("port", ""), vid=str(vid_raw), pid=str(d.get("pid") or ""), description=d.get("description", "") or "", ) if role: try: row.info = info_mod.device_info(port=row.port, timeout_s=6.0) except Exception as exc: row.info = {"error": repr(exc)} rows.append(row) self._app.post_message(DeviceSnapshot(rows=rows)) # ---------------- Modals ---------------- class FailureDetailScreen(tx.ModalScreen): """Show a failed test's longrepr + captured sections.""" BINDINGS = [tx.Binding("escape,q", "dismiss", "close")] def __init__(self, leaf: LeafReport, report_html: pathlib.Path) -> None: self._leaf = leaf self._report_html = report_html super().__init__() def compose(self) -> Any: yield tx.Static( f"[bold]{self._leaf.nodeid}[/bold] " f"outcome=[red]{self._leaf.outcome}[/red] " f"duration={_format_duration(self._leaf.duration_s)}", id="failure-detail-header", ) log = tx.RichLog( highlight=False, markup=False, wrap=False, id="failure-detail-log" ) yield log yield tx.Static( f"[dim]Full HTML report: {self._report_html}[/dim] [esc] close", id="failure-detail-footer", ) def on_mount(self) -> None: log = self.query_one("#failure-detail-log", tx.RichLog) if self._leaf.longrepr: log.write(self._leaf.longrepr) log.write("") for section_name, section_text in self._leaf.sections: log.write(f"--- {section_name} ---") log.write(section_text) log.write("") if not self._leaf.longrepr and not self._leaf.sections: log.write("(no longrepr or captured sections in reportlog event)") def action_dismiss(self, _result: Any = None) -> None: self.dismiss() class FilterInputScreen(tx.ModalScreen[str]): """Prompt the user for a tree filter substring (empty clears).""" BINDINGS = [tx.Binding("escape", "cancel", "cancel")] def compose(self) -> Any: yield tx.Static("filter test tree (substring, empty = clear):") yield tx.Input(placeholder="nodeid substring", id="filter-input") def on_input_submitted(self, event: Any) -> None: self.dismiss(event.value.strip()) def action_cancel(self) -> None: self.dismiss(None) class CoverageModal(tx.ModalScreen): """Read `tests/tool_coverage.json` (written by `tests/tool_coverage.py` at `pytest_sessionfinish`) and render a two-column summary of which MCP tools got exercised by the run. `(no coverage data yet)` while the run is in flight.""" BINDINGS = [tx.Binding("escape,q,c", "dismiss", "close")] def __init__(self, coverage_path: pathlib.Path) -> None: self._path = coverage_path super().__init__() def compose(self) -> Any: yield tx.Static("[bold]MCP tool coverage[/bold]", id="coverage-header") yield tx.RichLog( highlight=False, markup=True, wrap=False, id="coverage-log" ) yield tx.Static( f"[dim]{self._path}[/dim] [esc] close", id="coverage-footer", ) def on_mount(self) -> None: log = self.query_one("#coverage-log", tx.RichLog) if not self._path.is_file(): log.write("(no coverage data — tool_coverage.json not written yet)") log.write("") log.write("Coverage is emitted at pytest_sessionfinish; this") log.write("file appears after the suite completes.") return try: data = json.loads(self._path.read_text(encoding="utf-8")) except Exception as exc: log.write(f"[red]failed to read {self._path}:[/red] {exc!r}") return calls = data.get("calls") or {} if not calls: log.write("(tool_coverage.json present but no calls recorded)") return exercised = sorted( ((n, c) for n, c in calls.items() if c > 0), key=lambda x: -x[1] ) unexercised = sorted(n for n, c in calls.items() if c == 0) log.write(f"[b]{len(exercised)} / {len(calls)} MCP tools exercised[/b]") log.write("") log.write("[green]exercised[/green] (count):") for name, count in exercised: log.write(f" {count:>4} {name}") if unexercised: log.write("") log.write("[dim]not exercised:[/dim]") for name in unexercised: log.write(f" {name}") def action_dismiss(self, _result: Any = None) -> None: self.dismiss() class ReproducerResultModal(tx.ModalScreen): """Show the exported reproducer tarball path with a short instruction.""" BINDINGS = [tx.Binding("escape,q,enter", "dismiss", "close")] def __init__( self, archive_path: pathlib.Path, error: str | None = None ) -> None: self._archive = archive_path self._error = error super().__init__() def compose(self) -> Any: if self._error: yield tx.Static(f"[red]Reproducer export failed:[/red] {self._error}") else: yield tx.Static("[bold green]Reproducer bundle written[/bold green]") yield tx.Static(f"[cyan]{self._archive}[/cyan]") yield tx.Static("") yield tx.Static( "Contains: README.md, test_report.json, fwlog.jsonl (time-filtered)," ) yield tx.Static( "devices.json, env.json. Attach to an issue / paste the path in chat." ) yield tx.Static("") yield tx.Static("[dim][esc] close[/dim]") def action_dismiss(self, _result: Any = None) -> None: self.dismiss() # ---------------- App ---------------- class TestTuiApp(tx.App): CSS = """ Screen { layout: vertical; } #header-bar { height: 2; padding: 0 1; background: $panel; } #tier-table { height: auto; max-height: 11; } #body { height: 1fr; } #tree-pane { width: 50%; border-right: solid $primary-background; } #right-pane { width: 50%; layout: vertical; } #pytest-pane { height: 50%; border-bottom: solid $primary-background; } #fwlog-header { height: 1; padding: 0 1; background: $panel; } #fwlog-pane { height: 1fr; } Tree { height: 100%; } RichLog { height: 100%; } #device-table { height: auto; max-height: 6; } """ TITLE = "mcp-server test runner" BINDINGS = [ tx.Binding("r", "rerun_focused", "re-run focused"), tx.Binding("f", "filter_tree", "filter"), tx.Binding("d", "failure_detail", "failure detail"), tx.Binding("g", "open_html_report", "open report.html"), tx.Binding("x", "export_reproducer", "export reproducer"), tx.Binding("c", "coverage_panel", "coverage"), tx.Binding("l", "cycle_fwlog_filter", "fw log filter"), tx.Binding("q,ctrl+c", "quit_app", "quit"), ] def __init__(self) -> None: super().__init__() self._state = State() self._root = root self._run_tests = run_tests self._reportlog = reportlog self._fwlog = fwlog self._flashlog = flashlog self._report_html = root / _REPORT_HTML_RELATIVE self._tool_coverage = root / _TOOL_COVERAGE_RELATIVE self._repro_dir = root / _REPRODUCERS_RELATIVE self._seed = seed self._run_number = run_number self._pytest_args = pytest_args self._start_time = time.monotonic() self._proc: subprocess.Popen[str] | None = None self._stop = threading.Event() self._reportlog_worker: ReportlogWorker | None = None self._stdout_worker: SubprocessReaderWorker | None = None self._stderr_worker: SubprocessReaderWorker | None = None self._device_worker: DevicePollerWorker | None = None self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None self._tree_filter: str = "" self._sigint_count = 0 # Firmware-log port filter: None = all, else exact port match. self._fwlog_filter: str | None = None # Ordered set of distinct ports we've seen firmware log lines # from — the `l` key cycles through these. self._fwlog_ports: list[str] = [] # Cross-run history. self._history_store = _history_mod.HistoryStore( root / _HISTORY_RELATIVE, keep_last=40 ) self._history_cache = self._history_store.read_recent() # -------- composition / mount -------- def compose(self) -> Any: yield tx.Static(self._header_text(), id="header-bar") tier_table = tx.DataTable(id="tier-table", show_cursor=False) yield tier_table with tx.Horizontal(id="body"): with tx.Vertical(id="tree-pane"): yield tx.Tree("tests", id="test-tree") with tx.Vertical(id="right-pane"): with tx.Vertical(id="pytest-pane"): yield tx.RichLog( id="pytest-log", highlight=False, markup=False, wrap=False, max_lines=5000, ) yield tx.Static(self._fwlog_header_text(), id="fwlog-header") with tx.Vertical(id="fwlog-pane"): yield tx.RichLog( id="fwlog-log", highlight=False, markup=False, # `wrap=True` so long firmware log lines (some # hit ~200 chars — full packet hex dumps plus # source tags) don't get truncated at the # right edge. The right pane is ~50% of the # terminal so even a wide terminal has a # ~90-char cap; plain truncation dropped the # uptime counter or packet id off the end. wrap=True, max_lines=5000, ) yield tx.DataTable(id="device-table", show_cursor=False) yield tx.Footer() def _fwlog_header_text(self) -> str: filt = self._fwlog_filter or "(all ports)" return f"firmware log filter: [b]{filt}[/b] [l] cycle" def on_mount(self) -> None: # Tier-counters table. `add_column` (singular) lets us pick # the key explicitly — `add_columns` (plural) in textual 8.x # returns auto-generated keys that are tedious to track # separately, and update_cell(column_key=