c8dac10348
* Start of MCP server and test suite * Add MCP server for interacting with meshtastic devices and testing framework / TUI * Update mcp-server/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix mcp-server review feedback from thread Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/91dc128a-ed50-4d07-8bb2-3dc6623a05f7 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Enhance StreamAPI and PhoneAPI for improved log record handling and concurrency control * Semgrep fixes * Trunk and semgrep fixes * optimize pio streaming tee file writes Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * chore: remove redundant log handle assignment Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/04e26c6b-6a2b-45be-bbeb-79ae4d0be633 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Consolidate type imports and remove placeholder test files * Add tests for config persistence and more exchange messages * Refactor position test to validate on-demand request/reply behavior * Remove position request/reply test and update README for telemetry behavior * Fix transmit history file to get removed on factory reset --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1783 lines
73 KiB
Python
1783 lines
73 KiB
Python
"""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/<tier>/..." 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=<label>) silently
|
||
# no-ops because the key is not the label. "Progress" is the
|
||
# new v2 column — a small [===== ] bar; see `_progress_bar`.
|
||
tier_table = self.query_one("#tier-table", tx.DataTable)
|
||
for col in (
|
||
"Tier",
|
||
"Passed",
|
||
"Failed",
|
||
"Skipped",
|
||
"Running",
|
||
"Remaining",
|
||
"Progress",
|
||
):
|
||
tier_table.add_column(col, key=col)
|
||
for t in TIERS:
|
||
tier_table.add_row(t, "0", "0", "0", "0", "0", "", key=t)
|
||
# Device table. "Status" shows which test (if any) is currently
|
||
# running on this device — derived from the running_nodeid plus
|
||
# role inference from the nodeid's `[...]` parametrization.
|
||
dev_table = self.query_one("#device-table", tx.DataTable)
|
||
for col in (
|
||
"Role",
|
||
"Port",
|
||
"Firmware",
|
||
"HW",
|
||
"Region",
|
||
"Channel",
|
||
"Peers",
|
||
"Status",
|
||
):
|
||
dev_table.add_column(col, key=col)
|
||
# Launch workers + subprocess
|
||
self._device_worker = DevicePollerWorker(self, self._state, self._stop)
|
||
self._device_worker.start()
|
||
self._reportlog_worker = ReportlogWorker(self, self._reportlog, self._stop)
|
||
self._reportlog_worker.start()
|
||
# Firmware log tail worker — publishes FirmwareLogLine messages.
|
||
self._fwlog_worker = _fwlog_mod.FirmwareLogTailer(
|
||
path=self._fwlog,
|
||
post=lambda rec: self.post_message(FirmwareLogLine(rec)),
|
||
stop=self._stop,
|
||
)
|
||
self._fwlog_worker.start()
|
||
# Flash log tail worker — plain-text pio/esptool/nrfutil/picotool
|
||
# output tee'd by `pio._run_capturing`. Routes each line into the
|
||
# pytest pane so the operator has live feedback during long flash
|
||
# operations (`pio run -t upload` is ~3 min of silence otherwise).
|
||
self._flashlog_worker = _flashlog_mod.FlashLogTailer(
|
||
path=self._flashlog,
|
||
post=lambda line: self.post_message(FlashLogLine(line)),
|
||
stop=self._stop,
|
||
)
|
||
self._flashlog_worker.start()
|
||
self._spawn_pytest(self._pytest_args)
|
||
# Header tick (seed / runtime / sparkline re-renders at 1 Hz).
|
||
# Also refreshes the device-status column so the per-test elapsed
|
||
# time climbs live during silent test bodies (flash, long mesh
|
||
# timeouts, etc.) — cheap: device-table is 1-2 rows.
|
||
self.set_interval(1.0, self._on_tick)
|
||
|
||
def _header_text(self) -> str:
|
||
elapsed = time.monotonic() - self._start_time
|
||
phase = (
|
||
"running"
|
||
if self._state.run_active
|
||
else ("done" if self._state.exit_code is not None else "starting")
|
||
)
|
||
stale = " (devices stale)" if self._state.run_active else ""
|
||
# Sparkline over recent run durations (oldest → newest).
|
||
spark = _history_mod.sparkline(
|
||
(r.duration_s for r in self._history_cache), width=20
|
||
)
|
||
spark_segment = f" history: {spark}" if spark else ""
|
||
return (
|
||
f"mcp-server test runner "
|
||
f"seed: [b]{self._seed}[/b] "
|
||
f"run #{self._run_number} "
|
||
f"elapsed {_format_duration(elapsed)} "
|
||
f"phase: [b]{phase}[/b]{stale}"
|
||
f"{spark_segment}"
|
||
)
|
||
|
||
def _refresh_header(self) -> None:
|
||
try:
|
||
self.query_one("#header-bar", tx.Static).update(self._header_text())
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_tick(self) -> None:
|
||
"""1 Hz tick: refresh header clock + any live-updating cells.
|
||
|
||
The device-status cell embeds the running test's elapsed time
|
||
(`RUNNING: test_bake_nrf52 (1:23)`), which needs to re-render
|
||
each second during long silent test bodies. Cheap — O(devices),
|
||
which is 1–2 rows in practice. Skipped when no test is
|
||
running so we don't burn cycles when the TUI is idle.
|
||
"""
|
||
self._refresh_header()
|
||
if self._state.running_started_at is not None:
|
||
self._refresh_device_status()
|
||
|
||
# -------- subprocess management --------
|
||
|
||
def _spawn_pytest(self, extra_args: list[str]) -> None:
|
||
env = os.environ.copy()
|
||
env.setdefault("MESHTASTIC_MCP_SEED", self._seed)
|
||
cmd = [str(self._run_tests), *extra_args]
|
||
# `run-tests.sh` has a `[ "$#" -eq 0 ]` guard that applies the
|
||
# full default-args set:
|
||
# tests/ --html=tests/report.html --self-contained-html
|
||
# --junitxml=tests/junit.xml -v --tb=short
|
||
# plus an unconditional `--report-log` append at the end. If we
|
||
# pre-append `--report-log` here when `extra_args` is empty, $#
|
||
# becomes 1 and the whole defaults block is skipped — pytest
|
||
# then runs without the `tests/` positional (discovers from the
|
||
# mcp-server root and potentially drags in production modules
|
||
# named `test_*.py`), without the HTML/junit reports the /test
|
||
# skill relies on for failure interpretation, and without
|
||
# `-v --tb=short` output formatting.
|
||
#
|
||
# So: only append `--report-log` when the operator explicitly
|
||
# passed pytest args (e.g. the `r`-key re-run-focused-test
|
||
# case, where the wrapper's defaults are already bypassed by
|
||
# the explicit arg). Trust the wrapper's own injection in the
|
||
# no-args path.
|
||
if extra_args and not any(a.startswith("--report-log") for a in cmd):
|
||
cmd.append(f"--report-log={self._reportlog}")
|
||
log = self.query_one("#pytest-log", tx.RichLog)
|
||
log.write(f"$ {' '.join(cmd)}")
|
||
try:
|
||
self._proc = subprocess.Popen( # noqa: S603
|
||
cmd,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
bufsize=1,
|
||
text=True,
|
||
start_new_session=True,
|
||
env=env,
|
||
cwd=str(self._root),
|
||
)
|
||
except Exception as exc:
|
||
log.write(f"[tui] failed to spawn pytest: {exc!r}")
|
||
return
|
||
with self._state.lock:
|
||
self._state.run_active = True
|
||
self._state.exit_code = None
|
||
self._stdout_worker = SubprocessReaderWorker(
|
||
self, self._proc.stdout, "stdout", self._stop
|
||
)
|
||
self._stdout_worker.start()
|
||
self._stderr_worker = SubprocessReaderWorker(
|
||
self, self._proc.stderr, "stderr", self._stop
|
||
)
|
||
self._stderr_worker.start()
|
||
# Watchdog thread that posts RunFinished when the subprocess exits.
|
||
threading.Thread(
|
||
target=self._watch_exit, daemon=True, name="pytest-watch"
|
||
).start()
|
||
|
||
def _watch_exit(self) -> None:
|
||
if self._proc is None:
|
||
return
|
||
rc = self._proc.wait()
|
||
self.post_message(RunFinished(returncode=rc))
|
||
|
||
# -------- message handlers --------
|
||
|
||
def on_report_log_event(self, message: Any) -> None:
|
||
ev = message.event
|
||
rt = ev.get("$report_type")
|
||
if rt == "SessionStart":
|
||
return
|
||
if rt == "CollectReport":
|
||
# pytest-reportlog emits CollectReport once per collected
|
||
# node (directory, module, class, session). Leaf items
|
||
# appear as nodeids in the `result` array; parent-level
|
||
# collections have empty `result`.
|
||
for item in ev.get("result") or []:
|
||
self._register_leaf(item.get("nodeid", ""))
|
||
return
|
||
if rt == "TestReport":
|
||
nodeid = ev.get("nodeid", "")
|
||
when = ev.get("when")
|
||
outcome = ev.get("outcome")
|
||
|
||
# Phase 1: update the "currently running" marker that
|
||
# drives the device-status strip. `when="setup"` +
|
||
# outcome=passed means the test body is about to execute;
|
||
# `when="call"` (any outcome) means it just finished;
|
||
# `when="setup"` + outcome in {failed, skipped} also
|
||
# clears, since the body will never run.
|
||
if when == "setup" and outcome == "passed":
|
||
self._state.running_nodeid = nodeid
|
||
self._state.running_started_at = time.monotonic()
|
||
self._refresh_device_status()
|
||
elif when == "call" or (
|
||
when == "setup" and outcome in ("failed", "skipped")
|
||
):
|
||
if self._state.running_nodeid == nodeid:
|
||
self._state.running_nodeid = None
|
||
self._state.running_started_at = None
|
||
self._refresh_device_status()
|
||
|
||
# Phase 2: emit an authoritative leaf outcome.
|
||
# `call` + terminal: test body ran.
|
||
# `setup` + failed: promote setup error to a failed leaf.
|
||
# `setup` + skipped: fixture-level `pytest.skip(...)`.
|
||
# Our `mesh_pair`, `baked_mesh`, and
|
||
# `hub_devices` fixtures all do this
|
||
# when a role isn't detected or the
|
||
# bake doesn't match. Without this
|
||
# branch, those tests would never
|
||
# register in the tree and the tier
|
||
# counters would silently lie — e.g.
|
||
# the telemetry tier showed 0/0/0
|
||
# while 4 tests were actually skipped.
|
||
# `rerun` (pytest-rerunfailures): rewind to pending.
|
||
# Teardown outcomes are intentionally ignored — a
|
||
# teardown failure shouldn't overwrite the call's
|
||
# authoritative pass/fail.
|
||
if when == "call" and outcome in ("passed", "failed", "skipped"):
|
||
self._apply_outcome(nodeid, outcome, ev)
|
||
elif when == "setup" and outcome in ("failed", "skipped"):
|
||
self._apply_outcome(nodeid, outcome, ev)
|
||
elif outcome == "rerun":
|
||
self._apply_outcome(nodeid, "pending", ev)
|
||
return
|
||
if rt == "SessionFinish":
|
||
return
|
||
# Unknown — ignore silently.
|
||
|
||
def on_pytest_line(self, message: Any) -> None:
|
||
log = self.query_one("#pytest-log", tx.RichLog)
|
||
prefix = "" if message.source == "stdout" else "[stderr] "
|
||
log.write(f"{prefix}{message.line}")
|
||
|
||
def on_flash_log_line(self, message: Any) -> None:
|
||
"""Route a pio/esptool/nrfutil line into the pytest pane.
|
||
|
||
Prefixed `[flash]` so the operator can visually separate
|
||
tee'd subprocess output from pytest's own stdout. Without this
|
||
routing, long flash operations are a 3-minute black hole of
|
||
pytest-captured silence.
|
||
"""
|
||
log = self.query_one("#pytest-log", tx.RichLog)
|
||
log.write(f"[flash] {message.line}")
|
||
|
||
def on_firmware_log_line(self, message: Any) -> None:
|
||
rec = message.record
|
||
port = rec.get("port")
|
||
line = rec.get("line", "")
|
||
# Track distinct ports for `l` filter cycling. The ordered-set
|
||
# trick — list membership — is fine here because `_fwlog_ports`
|
||
# is tiny (2-3 entries for a typical lab).
|
||
if port and port not in self._fwlog_ports:
|
||
self._fwlog_ports.append(port)
|
||
# Refresh the fwlog header to show the newly-available port.
|
||
try:
|
||
self.query_one("#fwlog-header", tx.Static).update(
|
||
self._fwlog_header_text()
|
||
)
|
||
except Exception:
|
||
pass
|
||
# Filter: None = show all; otherwise exact port match.
|
||
if self._fwlog_filter and port != self._fwlog_filter:
|
||
return
|
||
log = self.query_one("#fwlog-log", tx.RichLog)
|
||
port_tag = ""
|
||
if port:
|
||
# Show only the last path component — `/dev/cu.usbmodem1101`
|
||
# is long; `usbmodem1101` is enough when the filter is
|
||
# "all".
|
||
tail = port.rsplit("/", 1)[-1]
|
||
port_tag = f"[{tail}] "
|
||
log.write(f"{port_tag}{line}")
|
||
|
||
@staticmethod
|
||
def _progress_bar(counters: TierCounters, *, width: int = 10) -> str:
|
||
done = counters.passed + counters.failed + counters.skipped
|
||
total = done + counters.running + counters.remaining
|
||
if total <= 0:
|
||
return ""
|
||
filled = int(round(width * done / total))
|
||
bar = "█" * filled + "·" * (width - filled)
|
||
return f"{bar} {done}/{total}"
|
||
|
||
def on_device_snapshot(self, message: Any) -> None:
|
||
with self._state.lock:
|
||
self._state.devices = list(message.rows)
|
||
dev_table = self.query_one("#device-table", tx.DataTable)
|
||
dev_table.clear()
|
||
for row in message.rows:
|
||
info = row.info or {}
|
||
role = row.role or "?"
|
||
fw = info.get("firmware_version", "—")
|
||
hw = info.get("hw_model", "—")
|
||
region = info.get("region", "—")
|
||
channel = info.get("primary_channel", "—")
|
||
peers = info.get("num_nodes")
|
||
if peers is None:
|
||
peers = "—"
|
||
else:
|
||
peers = str(max(int(peers) - 1, 0)) # exclude self
|
||
status = self._status_for_role(role)
|
||
# Row key = port path (stable, unique, survives re-snapshots).
|
||
dev_table.add_row(
|
||
role,
|
||
row.port,
|
||
str(fw),
|
||
str(hw),
|
||
str(region),
|
||
str(channel),
|
||
peers,
|
||
status,
|
||
key=row.port,
|
||
)
|
||
|
||
def _status_for_role(self, role: str) -> str:
|
||
"""Status cell for the given role: 'idle' or 'RUNNING: <short> (M:SS)'.
|
||
|
||
A running test whose nodeid doesn't carry an explicit role
|
||
parametrization (no `[...]` bracket) is treated as touching
|
||
every device — that matches how `test_bidirectional` and the
|
||
pytest_sessionstart-level tests work in practice.
|
||
|
||
The trailing `(M:SS)` is live-updated by `_on_tick` at 1 Hz
|
||
and gives the operator concrete "still running" evidence for
|
||
long-silent test bodies (flash, long mesh timeouts).
|
||
"""
|
||
nodeid = self._state.running_nodeid
|
||
if not nodeid:
|
||
return "idle"
|
||
roles = _roles_from_nodeid(nodeid)
|
||
if roles and role not in roles:
|
||
return "idle"
|
||
short = _testname_of_nodeid(nodeid)
|
||
# Compute elapsed for the live counter. Budget 8 chars at the
|
||
# end of the cell — `(12:34)` plus a space. Shorten `short`
|
||
# first, then tack on the elapsed suffix.
|
||
started = self._state.running_started_at
|
||
elapsed_suffix = ""
|
||
if started is not None:
|
||
elapsed_suffix = f" ({_format_duration(time.monotonic() - started)})"
|
||
# Truncate the test name to fit; Status column is the
|
||
# rightmost column and the device table is horizontally
|
||
# short. 40 chars + the elapsed suffix keeps the
|
||
# parametrization suffix visible for mesh_pair tests.
|
||
if len(short) > 40:
|
||
short = short[:37] + "…"
|
||
return f"RUNNING: {short}{elapsed_suffix}"
|
||
|
||
def _refresh_device_status(self) -> None:
|
||
"""Update the Status cell for every detected device.
|
||
|
||
Called whenever `running_nodeid` transitions (setup → call).
|
||
Cheap: O(devices) which is 1–2 rows in practice.
|
||
"""
|
||
try:
|
||
dev_table = self.query_one("#device-table", tx.DataTable)
|
||
except Exception:
|
||
return
|
||
for row in self._state.devices:
|
||
role = row.role or "?"
|
||
try:
|
||
dev_table.update_cell(
|
||
row.port, "Status", self._status_for_role(role)
|
||
)
|
||
except Exception:
|
||
# Row key might not exist yet if a snapshot hasn't
|
||
# populated it — harmless; next snapshot will carry
|
||
# the fresh status value.
|
||
pass
|
||
|
||
def on_run_finished(self, message: Any) -> None:
|
||
with self._state.lock:
|
||
self._state.run_active = False
|
||
self._state.exit_code = message.returncode
|
||
log = self.query_one("#pytest-log", tx.RichLog)
|
||
log.write(f"[tui] pytest exited with {message.returncode}")
|
||
# Trigger a fresh device poll now that ports are free again.
|
||
if self._device_worker is not None:
|
||
self._device_worker.trigger()
|
||
# Persist a history record — one line per run, tailed by the
|
||
# sparkline on every subsequent TUI launch.
|
||
duration_s = time.monotonic() - self._start_time
|
||
passed = sum(t.passed for t in self._state.tiers.values())
|
||
failed = sum(t.failed for t in self._state.tiers.values())
|
||
skipped = sum(t.skipped for t in self._state.tiers.values())
|
||
try:
|
||
rec = self._history_store.record_run(
|
||
run=self._run_number,
|
||
duration_s=duration_s,
|
||
passed=passed,
|
||
failed=failed,
|
||
skipped=skipped,
|
||
exit_code=message.returncode,
|
||
seed=self._seed,
|
||
)
|
||
self._history_cache = self._history_store.read_recent()
|
||
log.write(
|
||
f"[tui] history: recorded run #{rec.run} "
|
||
f"({passed}p/{failed}f/{skipped}s in {_format_duration(duration_s)})"
|
||
)
|
||
except Exception as exc:
|
||
log.write(f"[tui] history persist failed: {exc!r}")
|
||
|
||
# -------- tree + counters --------
|
||
|
||
def _register_leaf(self, nodeid: str) -> None:
|
||
if not nodeid or nodeid in self._state.leaves:
|
||
return
|
||
tier = _tier_of_nodeid(nodeid)
|
||
leaf = LeafReport(nodeid=nodeid, tier=tier)
|
||
self._state.leaves[nodeid] = leaf
|
||
self._state.nodeid_order.append(nodeid)
|
||
counters = self._state.tiers.get(tier)
|
||
if counters is not None:
|
||
counters.remaining += 1
|
||
self._refresh_tier_row(tier)
|
||
self._add_to_tree(leaf)
|
||
|
||
def _apply_outcome(self, nodeid: str, outcome: str, ev: dict[str, Any]) -> None:
|
||
if not nodeid:
|
||
return
|
||
leaf = self._state.leaves.get(nodeid)
|
||
if leaf is None:
|
||
# First event for this nodeid is the report itself (no
|
||
# collection event seen) — register on the fly.
|
||
self._register_leaf(nodeid)
|
||
leaf = self._state.leaves[nodeid]
|
||
prev = leaf.outcome
|
||
leaf.outcome = outcome
|
||
leaf.duration_s = float(ev.get("duration", 0.0) or 0.0)
|
||
# Wall-clock start/stop — pytest-reportlog emits these as
|
||
# float seconds (Unix epoch). Used by the reproducer exporter
|
||
# to window fwlog.jsonl down to just the failure's context.
|
||
start = ev.get("start")
|
||
stop = ev.get("stop")
|
||
if isinstance(start, (int, float)):
|
||
leaf.start_ts = float(start)
|
||
if isinstance(stop, (int, float)):
|
||
leaf.stop_ts = float(stop)
|
||
longrepr = ev.get("longrepr") or ""
|
||
if isinstance(longrepr, dict):
|
||
# pytest-reportlog may serialize as {"reprcrash": ..., "reprtraceback": ...}
|
||
longrepr = json.dumps(longrepr, indent=2, default=str)
|
||
leaf.longrepr = longrepr
|
||
sections = ev.get("sections") or []
|
||
if isinstance(sections, list):
|
||
leaf.sections = [
|
||
(
|
||
(s[0], s[1])
|
||
if isinstance(s, (list, tuple)) and len(s) >= 2
|
||
else ("section", str(s))
|
||
)
|
||
for s in sections
|
||
]
|
||
counters = self._state.tiers.get(leaf.tier)
|
||
if counters is None:
|
||
return
|
||
# Undo prior bucket, apply new one.
|
||
if prev in ("passed", "failed", "skipped"):
|
||
setattr(counters, prev, max(getattr(counters, prev) - 1, 0))
|
||
elif prev == "pending":
|
||
counters.remaining = max(counters.remaining - 1, 0)
|
||
if outcome in ("passed", "failed", "skipped"):
|
||
setattr(counters, outcome, getattr(counters, outcome) + 1)
|
||
elif outcome == "pending":
|
||
counters.remaining += 1
|
||
self._refresh_tier_row(leaf.tier)
|
||
self._refresh_tree_leaf(leaf)
|
||
|
||
def _refresh_tier_row(self, tier: str) -> None:
|
||
counters = self._state.tiers.get(tier)
|
||
if counters is None:
|
||
return
|
||
try:
|
||
table = self.query_one("#tier-table", tx.DataTable)
|
||
table.update_cell(tier, column_key="Passed", value=str(counters.passed))
|
||
table.update_cell(tier, column_key="Failed", value=str(counters.failed))
|
||
table.update_cell(
|
||
tier, column_key="Skipped", value=str(counters.skipped)
|
||
)
|
||
table.update_cell(
|
||
tier, column_key="Running", value=str(counters.running)
|
||
)
|
||
table.update_cell(
|
||
tier, column_key="Remaining", value=str(counters.remaining)
|
||
)
|
||
table.update_cell(
|
||
tier, column_key="Progress", value=self._progress_bar(counters)
|
||
)
|
||
except Exception:
|
||
# Column-key API differs slightly across textual versions;
|
||
# fall back to positional update if needed.
|
||
pass
|
||
|
||
def _add_to_tree(self, leaf: LeafReport) -> None:
|
||
tree = self.query_one("#test-tree", tx.Tree)
|
||
root_node = tree.root
|
||
tier_node = None
|
||
for child in root_node.children:
|
||
if str(child.label).split(" ")[0] == leaf.tier:
|
||
tier_node = child
|
||
break
|
||
if tier_node is None:
|
||
tier_node = root_node.add(leaf.tier, expand=False)
|
||
file_name = _file_of_nodeid(leaf.nodeid)
|
||
file_node = None
|
||
for child in tier_node.children:
|
||
if str(child.label).split(" ")[0] == file_name:
|
||
file_node = child
|
||
break
|
||
if file_node is None:
|
||
file_node = tier_node.add(file_name, expand=False)
|
||
glyph = self._glyph_for(leaf.outcome)
|
||
file_node.add_leaf(
|
||
f"{glyph} {_testname_of_nodeid(leaf.nodeid)}", data=leaf.nodeid
|
||
)
|
||
|
||
def _refresh_tree_leaf(self, leaf: LeafReport) -> None:
|
||
tree = self.query_one("#test-tree", tx.Tree)
|
||
glyph = self._glyph_for(leaf.outcome)
|
||
label = f"{glyph} {_testname_of_nodeid(leaf.nodeid)}"
|
||
for tier_node in tree.root.children:
|
||
if str(tier_node.label).split(" ")[0] != leaf.tier:
|
||
continue
|
||
for file_node in tier_node.children:
|
||
if str(file_node.label).split(" ")[0] != _file_of_nodeid(
|
||
leaf.nodeid
|
||
):
|
||
continue
|
||
for leaf_node in file_node.children:
|
||
if getattr(leaf_node, "data", None) == leaf.nodeid:
|
||
leaf_node.set_label(label)
|
||
return
|
||
|
||
@staticmethod
|
||
def _glyph_for(outcome: str) -> str:
|
||
return {
|
||
"passed": "[green]✓[/green]",
|
||
"failed": "[red]✗[/red]",
|
||
"skipped": "[dim]○[/dim]",
|
||
"pending": "·",
|
||
"running": "[yellow]●[/yellow]",
|
||
}.get(outcome, "?")
|
||
|
||
# -------- actions --------
|
||
|
||
def action_rerun_focused(self) -> None:
|
||
if self._state.run_active:
|
||
self.bell()
|
||
return
|
||
tree = self.query_one("#test-tree", tx.Tree)
|
||
node = tree.cursor_node
|
||
if node is None:
|
||
self.bell()
|
||
return
|
||
target: str | None = None
|
||
if getattr(node, "data", None):
|
||
target = str(node.data) # leaf: full nodeid
|
||
else:
|
||
# Internal node — derive a pytest arg.
|
||
labels = []
|
||
cur: Any = node
|
||
while cur is not None and cur.parent is not None:
|
||
labels.append(str(cur.label).split(" ")[0])
|
||
cur = cur.parent
|
||
if labels:
|
||
target = "tests/" + "/".join(reversed(labels))
|
||
if not target:
|
||
self.bell()
|
||
return
|
||
# Reset state + tree for the new run.
|
||
self._reset_for_rerun()
|
||
self._spawn_pytest([target])
|
||
|
||
def action_filter_tree(self) -> None:
|
||
def _apply(value: str | None) -> None:
|
||
if value is None:
|
||
return
|
||
self._tree_filter = value
|
||
self._apply_tree_filter()
|
||
|
||
self.push_screen(FilterInputScreen(), _apply)
|
||
|
||
def _apply_tree_filter(self) -> None:
|
||
tree = self.query_one("#test-tree", tx.Tree)
|
||
needle = self._tree_filter.lower()
|
||
for tier_node in tree.root.children:
|
||
tier_match_count = 0
|
||
for file_node in tier_node.children:
|
||
file_match_count = 0
|
||
for leaf_node in file_node.children:
|
||
nodeid = str(getattr(leaf_node, "data", "") or "")
|
||
match = (not needle) or (needle in nodeid.lower())
|
||
leaf_node.display = match
|
||
if match:
|
||
file_match_count += 1
|
||
file_node.display = file_match_count > 0 or not needle
|
||
if file_match_count > 0:
|
||
tier_match_count += 1
|
||
tier_node.display = tier_match_count > 0 or not needle
|
||
|
||
def action_failure_detail(self) -> None:
|
||
tree = self.query_one("#test-tree", tx.Tree)
|
||
node = tree.cursor_node
|
||
if node is None or not getattr(node, "data", None):
|
||
self.bell()
|
||
return
|
||
leaf = self._state.leaves.get(str(node.data))
|
||
if leaf is None or leaf.outcome != "failed":
|
||
self.bell()
|
||
return
|
||
self.push_screen(FailureDetailScreen(leaf, self._report_html))
|
||
|
||
def action_open_html_report(self) -> None:
|
||
if not self._report_html.is_file():
|
||
self.bell()
|
||
return
|
||
try:
|
||
# macOS + Linux cover — falls through silently on failure.
|
||
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
||
subprocess.Popen([opener, str(self._report_html)]) # noqa: S603,S607
|
||
except Exception:
|
||
self.bell()
|
||
|
||
def action_cycle_fwlog_filter(self) -> None:
|
||
"""Cycle the firmware-log port filter: None → port1 → port2 → … → None."""
|
||
if not self._fwlog_ports:
|
||
self.bell()
|
||
return
|
||
cycle = [None, *self._fwlog_ports]
|
||
try:
|
||
idx = cycle.index(self._fwlog_filter)
|
||
except ValueError:
|
||
idx = 0
|
||
self._fwlog_filter = cycle[(idx + 1) % len(cycle)]
|
||
try:
|
||
self.query_one("#fwlog-header", tx.Static).update(
|
||
self._fwlog_header_text()
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def action_coverage_panel(self) -> None:
|
||
self.push_screen(CoverageModal(self._tool_coverage))
|
||
|
||
def action_export_reproducer(self) -> None:
|
||
"""Key `x`: export a reproducer bundle for the focused failed test.
|
||
|
||
Only fires when the tree cursor is on a leaf that we've seen
|
||
fail (has a TestReport with outcome=failed). Anything else
|
||
bells + no-ops so we don't write empty bundles.
|
||
"""
|
||
tree = self.query_one("#test-tree", tx.Tree)
|
||
node = tree.cursor_node
|
||
if node is None or not getattr(node, "data", None):
|
||
self.bell()
|
||
return
|
||
leaf = self._state.leaves.get(str(node.data))
|
||
if leaf is None or leaf.outcome != "failed":
|
||
self.bell()
|
||
return
|
||
# Snapshot current device state into the bundle so the
|
||
# receiving human has the same context you had when exporting.
|
||
device_rows: list[dict[str, Any]] = []
|
||
for row in self._state.devices:
|
||
device_rows.append(
|
||
{
|
||
"role": row.role,
|
||
"port": row.port,
|
||
"vid": row.vid,
|
||
"pid": row.pid,
|
||
"description": row.description,
|
||
"info": row.info,
|
||
}
|
||
)
|
||
ctx = _reproducer_mod.ReproContext(
|
||
nodeid=leaf.nodeid,
|
||
longrepr=leaf.longrepr,
|
||
sections=list(leaf.sections),
|
||
start_ts=leaf.start_ts,
|
||
stop_ts=leaf.stop_ts,
|
||
seed=self._seed,
|
||
run_number=self._run_number,
|
||
exit_code=self._state.exit_code,
|
||
fwlog_path=self._fwlog,
|
||
output_dir=self._repro_dir,
|
||
extra_device_rows=device_rows,
|
||
)
|
||
try:
|
||
archive = _reproducer_mod.build_reproducer_bundle(ctx)
|
||
except Exception as exc:
|
||
self.push_screen(
|
||
ReproducerResultModal(pathlib.Path("(none)"), error=repr(exc))
|
||
)
|
||
return
|
||
self.push_screen(ReproducerResultModal(archive))
|
||
|
||
def action_quit_app(self) -> None:
|
||
# First press: initiate graceful shutdown of the pgroup.
|
||
# Second press within 2 s: hard-kill.
|
||
if self._proc is None or self._proc.poll() is not None:
|
||
self._cleanup_and_exit()
|
||
return
|
||
self._sigint_count += 1
|
||
if self._sigint_count == 1:
|
||
try:
|
||
os.killpg(self._proc.pid, signal.SIGINT)
|
||
except ProcessLookupError:
|
||
pass
|
||
log = self.query_one("#pytest-log", tx.RichLog)
|
||
log.write("[tui] sent SIGINT; waiting up to 5 s for graceful exit…")
|
||
# Escalator thread
|
||
threading.Thread(target=self._escalate_kill, daemon=True).start()
|
||
else:
|
||
self._hard_kill()
|
||
|
||
def _escalate_kill(self) -> None:
|
||
deadline = time.monotonic() + _SIGINT_GRACE_S
|
||
while time.monotonic() < deadline:
|
||
if self._proc is None or self._proc.poll() is not None:
|
||
self.call_from_thread(self._cleanup_and_exit)
|
||
return
|
||
time.sleep(0.1)
|
||
if self._proc is not None and self._proc.poll() is None:
|
||
try:
|
||
os.killpg(self._proc.pid, signal.SIGTERM)
|
||
except ProcessLookupError:
|
||
pass
|
||
deadline = time.monotonic() + _SIGTERM_GRACE_S
|
||
while time.monotonic() < deadline:
|
||
if self._proc is None or self._proc.poll() is not None:
|
||
self.call_from_thread(self._cleanup_and_exit)
|
||
return
|
||
time.sleep(0.1)
|
||
self._hard_kill()
|
||
|
||
def _hard_kill(self) -> None:
|
||
if self._proc is not None and self._proc.poll() is None:
|
||
try:
|
||
os.killpg(self._proc.pid, signal.SIGKILL)
|
||
except ProcessLookupError:
|
||
pass
|
||
self.call_from_thread(self._cleanup_and_exit)
|
||
|
||
def _cleanup_and_exit(self) -> None:
|
||
self._stop.set()
|
||
self.exit(return_code=self._state.exit_code or 0)
|
||
|
||
def _reset_for_rerun(self) -> None:
|
||
"""Clear counters + tree + leaves for a focused re-run."""
|
||
with self._state.lock:
|
||
self._state.leaves.clear()
|
||
self._state.nodeid_order.clear()
|
||
for t in self._state.tiers.values():
|
||
t.passed = t.failed = t.skipped = t.running = t.remaining = 0
|
||
self._state.exit_code = None
|
||
# Defensive: the call-event handler would have cleared this
|
||
# at the end of the prior run, but if the prior run was
|
||
# interrupted (SIGINT during a test body) it may linger.
|
||
self._state.running_nodeid = None
|
||
self._state.running_started_at = None
|
||
# Device status cells need to go back to "idle" — otherwise
|
||
# the prior run's RUNNING: marker sticks until the next test
|
||
# actually starts.
|
||
self._refresh_device_status()
|
||
# Reset UI
|
||
tier_table = self.query_one("#tier-table", tx.DataTable)
|
||
for t in TIERS:
|
||
tier_table.update_cell(t, column_key="Passed", value="0")
|
||
tier_table.update_cell(t, column_key="Failed", value="0")
|
||
tier_table.update_cell(t, column_key="Skipped", value="0")
|
||
tier_table.update_cell(t, column_key="Running", value="0")
|
||
tier_table.update_cell(t, column_key="Remaining", value="0")
|
||
tier_table.update_cell(t, column_key="Progress", value="")
|
||
tree = self.query_one("#test-tree", tx.Tree)
|
||
tree.root.remove_children()
|
||
log = self.query_one("#pytest-log", tx.RichLog)
|
||
log.write("")
|
||
log.write("[tui] --- re-run ---")
|
||
# Clear the fwlog pane too — it's fresh context for the new run.
|
||
try:
|
||
self.query_one("#fwlog-log", tx.RichLog).clear()
|
||
except Exception:
|
||
pass
|
||
# Reset fwlog filter state; the conftest truncates fwlog.jsonl
|
||
# on fixture setup, but we also unlink here so our tail worker
|
||
# sees the new file from byte 0.
|
||
self._fwlog_filter = None
|
||
self._fwlog_ports = []
|
||
try:
|
||
self.query_one("#fwlog-header", tx.Static).update(
|
||
self._fwlog_header_text()
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._start_time = time.monotonic()
|
||
for p in (self._reportlog, self._fwlog):
|
||
try:
|
||
p.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
|
||
return TestTuiApp()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|