"""Root conftest for the MCP server test harness. Organizes the fixture graph used by every test tier: session_seed ── test_profile ─┐ hub_devices ──────────────────┴─ baked_mesh (verifies) ── baked_single (parametrized) hub_devices ──────────────────── no_region_profile (provisioning negative test) (per-test) ──────────────────── serial_capture, device_state_dump, wait_until CLI flags (see `pytest_addoption`): --force-bake always reflash at session start, even if state matches --assume-baked trust the operator; skip test_00_bake collection entirely --hub-profile=... path to a YAML file mapping role → {vid, pid_contains} --no-teardown-rebake skip the session-end rebake that provisioning/fleet perform Coverage hooks: - Failure artifacts (serial capture, device_info, get_config) are attached to pytest-html reports via `pytest_runtest_makereport`. - Tool-surface coverage (which of the 37 MCP tools got exercised) is accumulated in `tests/tool_coverage.py` and written to `tool_coverage.json` at session end. """ from __future__ import annotations import atexit import json import os import pathlib import sys import time from typing import Any, Callable import pytest # Ensure the MCP server is on `sys.path` without requiring installation in # development mode for every checkout (we DO install in .venv but this makes # `pytest tests/` work from a fresh clone too). The path mutation must # happen before `meshtastic_mcp.*` imports below — hence the `noqa: E402` # markers on those imports (ruff's "module-level import not at top of file" # rule doesn't understand path-bootstrapping patterns). _HERE = pathlib.Path(__file__).resolve().parent _MCP_SRC = _HERE.parent / "src" if str(_MCP_SRC) not in sys.path: sys.path.insert(0, str(_MCP_SRC)) # Default firmware root: the repo this mcp-server/ lives inside. os.environ.setdefault("MESHTASTIC_FIRMWARE_ROOT", str(_HERE.parent.parent)) from meshtastic_mcp import admin # noqa: E402 from meshtastic_mcp import devices as devices_module # noqa: E402 from meshtastic_mcp import info, serial_session, userprefs # noqa: E402 from . import tool_coverage # noqa: E402 # ---------- CLI options --------------------------------------------------- def pytest_addoption(parser: pytest.Parser) -> None: group = parser.getgroup("meshtastic", "Meshtastic MCP test options") group.addoption( "--force-bake", action="store_true", help="Flash both hub roles at session start, even if devices appear baked.", ) group.addoption( "--assume-baked", action="store_true", help="Skip `test_00_bake.py` and trust devices are already baked.", ) group.addoption( "--hub-profile", default=None, help="YAML file mapping role → {vid, pid_contains} for non-default hardware.", ) group.addoption( "--no-teardown-rebake", action="store_true", help="Skip session-end rebake after provisioning/fleet tests mutate state.", ) def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item] ) -> None: """Deselect `test_00_bake.py` when --assume-baked is passed, and sort items so that admin/ + provisioning/ (tests that mutate device state via reboot or factory_reset) run AFTER the read-only mesh/telemetry tests. Why the reorder: admin/test_owner_survives_reboot reboots both devices; provisioning/test_baked_prefs_survive_factory_reset does a factory_reset. Both wipe the in-memory PKI public-key table. Directed sends with wantAck=True then NAK with Routing.Error=39 (PKI_SEND_FAIL_PUBLIC_KEY) because TX lost RX's key, and the firmware NodeInfo cooldown (10 min) + 12-h reply suppression make re-exchange slow enough to fail within a test budget. Running mesh/telemetry first against the pre-reboot state is both faster and more reliable; admin/provisioning then runs against a clean mesh and exercises its own invariants without contaminating other tiers. """ if config.getoption("--assume-baked"): for item in items: if "test_00_bake" in item.nodeid: item.add_marker(pytest.mark.skip(reason="skipped by --assume-baked")) def sort_key(item: pytest.Item) -> tuple[int, str]: path = str(getattr(item, "fspath", "") or item.nodeid) # Session-start bake runs FIRST. `baked_mesh` only verifies state — # nothing else actually reflashes — so if test_00_bake doesn't run # before the tier tests, `--force-bake` silently becomes a no-op for # the tier tests and only flashes at the very end of the session. # Top-level nodeid ("tests/test_00_bake.py") otherwise falls into the # fallback bucket and sorts after every tier. if "test_00_bake" in item.nodeid: return (-1, item.nodeid) # Tiers that don't mutate device state run first. if "/unit/" in path or "tests/unit" in path: return (0, item.nodeid) if "/mesh/" in path or "tests/mesh" in path: return (1, item.nodeid) if "/telemetry/" in path or "tests/telemetry" in path: return (2, item.nodeid) if "/monitor/" in path or "tests/monitor" in path: return (3, item.nodeid) # Recovery tier: explicitly cycles device power via uhubctl. Slots # between monitor (read-only) and ui (state-preserving) so any tier # after it starts from a known re-enumerated + re-verified state. if "/recovery/" in path or "tests/recovery" in path: return (4, item.nodeid) # UI tier slots here — read-only w.r.t. mesh state, only mutates # the on-screen UI (BACK×5 guard restores home before each test). if "/ui/" in path or "tests/ui" in path: return (5, item.nodeid) if "/fleet/" in path or "tests/fleet" in path: return (6, item.nodeid) # State-mutating tiers run last. if "/admin/" in path or "tests/admin" in path: return (7, item.nodeid) if "/provisioning/" in path or "tests/provisioning" in path: return (8, item.nodeid) # Top-level + anything else falls between. return (9, item.nodeid) items.sort(key=sort_key) # ---------- Session-scoped fixtures --------------------------------------- @pytest.fixture(scope="session") def session_seed(request: pytest.FixtureRequest) -> str: """Deterministic PSK seed for this pytest session. Logged in the HTML report header so two runs can be correlated — and so a flaky-looking test can be reproduced exactly by passing the seed back via an env var (future extension). """ # Pytest session `starttime` isn't directly exposed on the pytest API we # care about, so derive from process start time — unique enough for human # purposes and stable across the session. seed = os.environ.get("MESHTASTIC_MCP_SEED") or f"pytest-{int(time.time())}" return seed @pytest.fixture(scope="session") def test_profile(session_seed: str) -> dict[str, Any]: """The canonical isolated-mesh test profile for this session. `enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware emits `Screen: frame N/M name=... reason=...` log lines per UI transition — consumed by the `tests/ui/` tier. Harmless on boards without a screen (the `#ifdef` sits behind `HAS_SCREEN`). """ return userprefs.build_testing_profile( psk_seed=session_seed, channel_name="McpTest", channel_num=88, region="US", modem_preset="LONG_FAST", enable_ui_log=True, ) @pytest.fixture(scope="session", autouse=True) def _session_userprefs(test_profile: dict[str, Any]) -> Any: """Snapshot `userPrefs.jsonc`, apply the session test profile, restore at session end. Guards against the suite leaving test-profile USERPREFS values baked into the file — if that happened, any firmware build a contributor ran next would silently inherit the test PSK / test channel name / test admin key etc. Layered safety: 1. In-memory snapshot taken before any mutation; teardown writes it back. 2. Sidecar `userPrefs.jsonc.mcp-session-bak` on disk — belt to the in-memory suspenders. If Python segfaults or SIGKILLs, the next session self-heals from this file at startup. 3. `atexit.register()` fallback: if pytest exits abnormally (Ctrl-C mid-test, fatal exception before teardown), the atexit hook still restores from the in-memory snapshot. 4. Startup self-heal: if the sidecar exists at session start, a prior session crashed without cleanup — the sidecar IS the truth; restore from it before taking this session's snapshot. That way a crash during test A doesn't propagate dirty state into test B's baseline. Autouse + depends on `test_profile` so it applies on every run (even unit-only) — cheap, unified code path, no ordering surprises. """ path = userprefs.jsonc_path() backup_path = path.with_name(path.name + ".mcp-session-bak") if not path.is_file(): # Nothing to snapshot; yield no-op and skip restore. yield return # (4) Startup self-heal — prior session crashed without teardown. if backup_path.is_file(): try: sidecar_bytes = backup_path.read_bytes() current_bytes = path.read_bytes() if sidecar_bytes != current_bytes: path.write_bytes(sidecar_bytes) print( f"[userprefs] recovered {path.name} from " f"{backup_path.name} (prior session exited without " f"cleanup)", file=sys.stderr, ) except Exception as exc: print( f"[userprefs] startup self-heal failed: {exc!r}", file=sys.stderr, ) # (1) + (2) Snapshot + sidecar. original_bytes = path.read_bytes() original_stat = path.stat() try: backup_path.write_bytes(original_bytes) except Exception as exc: print(f"[userprefs] could not write sidecar: {exc!r}", file=sys.stderr) # (3) atexit fallback — fires even if pytest aborts before fixture teardown. restored = {"done": False} def _atexit_restore() -> None: if restored["done"]: return try: path.write_bytes(original_bytes) except Exception: pass try: if backup_path.is_file(): backup_path.unlink() except Exception: pass restored["done"] = True atexit.register(_atexit_restore) # Apply the session test profile on top of the snapshot. The firmware # reads userPrefs.jsonc at build time via `bin/platformio-custom.py`, # so every `pio run` during the session picks up the test values. # Delegate to `userprefs.merge_active` — the public API that already # parses, merges, validates, and writes — rather than reaching into # the private parser/renderer machinery from here. try: userprefs.merge_active(test_profile) # Bump mtime so any pre-existing `.pio/build/*/` cache is invalidated. now = time.time() os.utime(path, (now, now)) except Exception as exc: # Non-fatal: tests that depend on the baked profile will fail loudly; # tests that don't (unit) still run. But the restore below is # unconditional, so we can't leave a half-written file behind. print( f"[userprefs] failed to apply test profile: {exc!r} — " f"file left at original state", file=sys.stderr, ) try: path.write_bytes(original_bytes) except Exception: pass try: yield finally: restore_ok = False try: path.write_bytes(original_bytes) os.utime(path, (original_stat.st_atime, original_stat.st_mtime)) restore_ok = True except Exception as exc: # Don't `return` out of finally (that swallows any in-flight # exception from the yielded body); use a flag so the cleanup # control-flow stays linear and exceptions propagate normally. print( f"[userprefs] teardown restore failed: {exc!r} — " f"sidecar {backup_path} retained for manual recovery", file=sys.stderr, ) if restore_ok: try: if backup_path.is_file(): backup_path.unlink() except Exception: pass # Mark done either way: on success, cleanup is complete; on failure, # the sidecar is intentionally left for next-run self-heal and we # don't want the atexit hook to fight us. restored["done"] = True try: atexit.unregister(_atexit_restore) except Exception: pass @pytest.fixture(scope="session") def no_region_profile(session_seed: str) -> dict[str, Any]: """Variant of `test_profile` with the LoRa region stripped. Used only by the negative `unset_region_blocks_tx` test. That test MUST re-bake `test_profile` in its own teardown so downstream shared-state tests still see a correctly-configured mesh. """ profile = userprefs.build_testing_profile( psk_seed=session_seed, channel_name="McpTest", channel_num=88, region="US", # placeholder; we delete the key below modem_preset="LONG_FAST", ) profile.pop("USERPREFS_CONFIG_LORA_REGION", None) return profile @pytest.fixture(scope="session") def hub_profile(request: pytest.FixtureRequest) -> dict[str, dict[str, Any]]: """Role → {vid, pid_contains} map for detecting connected hardware. Default covers the common nRF52840 + ESP32-S3 lab hub. Override via `--hub-profile=path/to/hub.yaml`. Example YAML: nrf52: vid: 0x239a pid_contains: null esp32s3: vid: 0x303a pid_contains: null """ path = request.config.getoption("--hub-profile") if path: import yaml with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) return { "nrf52": {"vid": 0x239A, "pid_contains": None}, # ESP32-S3 can enumerate under Espressif native USB (0x303a) or via a # CP2102 USB-serial chip (0x10c4). Both should match for # Meshtastic-compatible boards. "esp32s3": {"vid": 0x303A, "pid_contains": None}, "esp32s3_alt": {"vid": 0x10C4, "pid_contains": None}, } def _hex_to_int(value: Any) -> int | None: if value is None: return None if isinstance(value, int): return value if isinstance(value, str): return int(value, 16) if value.startswith("0x") else int(value) return None @pytest.fixture(scope="session") def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]: """Map of `role → port` for devices detected on the hub. Excludes `*_alt` roles from the returned map (they're additional VID matchers for the same logical role). If a role isn't detected, an entry is absent from the return value; fixtures that require specific roles should check presence and `pytest.skip` with an actionable message. """ # include_unknown=True so non-whitelisted VIDs (e.g. CP2102 at 0x10c4) that # are configured as hub roles still match. The hub_profile itself gates # which VIDs we consider — no risk of unrelated serial ports sneaking in. found = devices_module.list_devices(include_unknown=True) # Coalesce alt roles into their base name (esp32s3_alt → esp32s3) resolved: dict[str, str] = {} for role, spec in hub_profile.items(): target_vid = spec["vid"] pid_contains = spec.get("pid_contains") canonical = role.split("_alt", 1)[0] if canonical in resolved: continue for dev in found: vid = _hex_to_int(dev.get("vid")) pid = _hex_to_int(dev.get("pid")) if vid != target_vid: continue if pid_contains is not None and (pid is None or pid_contains not in pid): continue resolved[canonical] = dev["port"] break return resolved def _reset_transmit_history_state(role: str, port: str) -> str: """Wipe `/prefs/transmit_history.dat` + in-memory throttle cache via delete_file_request + reboot. Returns the post-reboot port (nRF52 re-enumerates). Best-effort — errors log to stderr + return original port so a flaky start doesn't block the session. """ from ._port_discovery import resolve_port_by_role try: from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped] from meshtastic_mcp.connection import connect with connect(port=port) as iface: msg = admin_pb2.AdminMessage() msg.delete_file_request = "/prefs/transmit_history.dat" iface.localNode._sendAdmin(msg) time.sleep(1.0) # Reboot clears in-memory cache; otherwise the 5-min auto-flush # rewrites the file with pre-reset timestamps. iface.localNode.reboot(3) except Exception as exc: print( f"[transmit-history-reset] {role} @ {port} clear failed: {exc!r}", file=sys.stderr, ) return port time.sleep(8.0) try: fresh = resolve_port_by_role(role, timeout_s=45.0) except Exception as exc: print( f"[transmit-history-reset] {role} didn't reappear: {exc!r}", file=sys.stderr, ) return port for _ in range(20): try: if info.device_info(port=fresh, timeout_s=5.0).get("my_node_num"): return fresh except Exception: time.sleep(1.5) return fresh @pytest.fixture(scope="session", autouse=True) def _session_clear_transmit_history(hub_devices: dict[str, str]) -> None: """Wipe transmit_history.dat on each device at session start. Without this, the firmware's per-portnum last-broadcast cache (`src/mesh/TransmitHistory.h`) carries throttle state across sessions and suppresses early broadcasts. Mutates `hub_devices` in place with post-reboot ports since nRF52 re-enumerates. """ if not hub_devices: yield return # Iterate over a snapshot — _reset_transmit_history_state can mutate # hub_devices mid-loop via the update below, and dict-iteration isn't # safe during mutation. for role, port in list(hub_devices.items()): fresh_port = _reset_transmit_history_state(role, port) if fresh_port != port: hub_devices[role] = fresh_port yield @pytest.fixture(scope="session") def baked_mesh( hub_devices: dict[str, str], test_profile: dict[str, Any], session_seed: str, request: pytest.FixtureRequest, ) -> dict[str, Any]: """Verify that both roles are baked with the session `test_profile`. Does NOT reflash. `test_00_bake.py` is responsible for applying the bake; this fixture just checks the result by connecting to each device and comparing the live config to the expected profile. Raises with an actionable error if state is missing or mismatched: "device nrf52 at /dev/cu.X not baked with session profile — run test_00_bake.py first or pass --force-bake" Returns a per-role dict with `{port, iface_fresh: callable, my_node_num}`. """ # Verify every role that's present — don't require a fixed set. # Tests that NEED a specific role (mesh_pair, bidirectional) check # presence in their own fixtures and skip there with an actionable # message. That keeps single-device tests runnable on a one-device # hub without needing a --hub-profile override. if not hub_devices: pytest.skip( "no hub roles detected. Attach a device or override with --hub-profile." ) expected_region = test_profile["USERPREFS_CONFIG_LORA_REGION"] expected_preset = test_profile["USERPREFS_LORACONFIG_MODEM_PRESET"] expected_slot = test_profile["USERPREFS_LORACONFIG_CHANNEL_NUM"] expected_channel_name = test_profile["USERPREFS_CHANNEL_0_NAME"] out: dict[str, Any] = {} per_role_errors: dict[str, str] = {} for role in sorted(hub_devices): port = hub_devices[role] try: live = info.device_info(port=port, timeout_s=12.0) except Exception as exc: # Per-role failure — drop this role from the baked set and let # any test parametrized against it skip with the actionable # message. Other roles still proceed. per_role_errors[role] = f"device_info failed: {exc!r}" continue # `device_info` surfaces region/primary_channel but not modem preset # or channel_num directly; pull those via a separate get_config call. try: lora_cfg = admin.get_config(section="lora", port=port)["config"]["lora"] except Exception as exc: per_role_errors[role] = f"get_config(lora) failed: {exc!r}" continue channel_num = int(lora_cfg.get("channel_num", 0)) modem_preset = lora_cfg.get("modem_preset") region_short = live.get("region") primary = live.get("primary_channel") mismatches = [] if region_short and not expected_region.endswith(str(region_short)): mismatches.append(f"region={region_short} (expected {expected_region})") # `modem_preset` is omitted from the protobuf→JSON dump when it's the # default (LONG_FAST, value 0). Missing + expected-LONG_FAST = match. if modem_preset is None: if not expected_preset.endswith("_LONG_FAST"): mismatches.append( f"modem_preset= (expected {expected_preset})" ) elif not expected_preset.endswith(str(modem_preset)): mismatches.append( f"modem_preset={modem_preset} (expected {expected_preset})" ) if channel_num != expected_slot: mismatches.append(f"channel_num={channel_num} (expected {expected_slot})") if primary and primary != expected_channel_name: mismatches.append( f"primary_channel={primary!r} (expected {expected_channel_name!r})" ) if mismatches: per_role_errors[role] = "not baked with session profile: " + "; ".join( mismatches ) continue out[role] = { "port": port, "my_node_num": live.get("my_node_num"), "firmware_version": live.get("firmware_version"), } # NOTE: we intentionally do NOT auto-enable `security.debug_log_api_enabled` # here. Firmware's `emitLogRecord` (src/mesh/StreamAPI.cpp:196) shares the # `fromRadioScratch` / `txBuf` buffers with the main packet-emission path; # LOG_ calls that race in-flight FromRadio emissions corrupt the byte # stream, triggering protobuf DecodeError in meshtastic-python and killing # the SerialInterface. Operators who want log capture can opt in via the # `set_debug_log_api` MCP tool (or `admin.set_debug_log_api` directly) on # a case-by-case basis. The autouse `_debug_log_buffer` fixture is still # armed below — if a test explicitly enables the flag, its output will # be captured and attached to failures. Firmware-side fix would need # a separate tx buffer or a mutex — out of scope for the MCP harness. # If EVERY detected role errored, skip the session — nothing testable. # Otherwise yield the partial set. Tests parametrized against a role # not in `out` will skip via the `baked_single`/`mesh_pair` presence # check with "role not present on the hub". if not out: details = "\n ".join(f"{r}: {e}" for r, e in per_role_errors.items()) pytest.skip( "no devices matched the session bake profile:\n " + details + "\nRun `pytest tests/test_00_bake.py --force-bake` first." ) return out def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: """Auto-parametrize `baked_single` over every detected hub role, and `mesh_pair` over every ordered (tx, rx) pair. This is the "tests are context-aware of the device they're against" layer: a test that takes `baked_single` runs once per connected device, so its report ID reads `test_owner_survives_reboot[nrf52]` / `test_owner_survives_reboot[esp32s3]`. Cross-device tests that take `mesh_pair` run for every direction, so A→B and B→A are both asserted. Both fall back to a hardcoded default set when hardware isn't present so the test still COLLECTS cleanly (it'll just skip via the `hub_devices` missing-role check inside the fixture). Honors `--hub-profile=` for non-default hardware — when set, only roles defined in the YAML are parametrized. (So e.g. a yaml with only `esp32s3` skips every `[nrf52]` variant at collection time.) """ # Resolve the role → VID map, honoring --hub-profile if passed profile_path = metafunc.config.getoption("--hub-profile", default=None) if profile_path: import yaml with open(profile_path, "r", encoding="utf-8") as f: hub = yaml.safe_load(f) or {} # Flatten _alt entries into canonical-role map (keep first occurrence) default_roles: dict[str, int] = {} for role, spec in hub.items(): default_roles[role] = spec["vid"] else: default_roles = {"nrf52": 0x239A, "esp32s3": 0x303A, "esp32s3_alt": 0x10C4} try: from meshtastic_mcp import devices as _dev found = _dev.list_devices(include_unknown=True) except Exception: found = [] detected: list[str] = [] for role, target_vid in default_roles.items(): canonical = role.split("_alt", 1)[0] if canonical in detected: continue for d in found: vid = d.get("vid") if isinstance(vid, str): try: vid = int(vid, 16) except ValueError: vid = None if vid == target_vid: detected.append(canonical) break # When --hub-profile is explicit, honor its role list even if detection # failed (operator knows what they plugged in; let the fixture skip # unbaked roles at runtime with an actionable message). if profile_path: roles = detected or [r.split("_alt", 1)[0] for r in default_roles] else: roles = detected or ["nrf52", "esp32s3"] if "baked_single_role" in metafunc.fixturenames: metafunc.parametrize("baked_single_role", roles, ids=roles, scope="function") if "mesh_pair_roles" in metafunc.fixturenames: pairs = [(a, b) for a in roles for b in roles if a != b] ids = [f"{a}->{b}" for a, b in pairs] metafunc.parametrize("mesh_pair_roles", pairs, ids=ids, scope="function") @pytest.fixture def baked_single( baked_mesh: dict[str, Any], baked_single_role: str, hub_devices: dict[str, str], ) -> dict[str, Any]: """Function-scoped: a single verified baked device. Auto-parametrized by `pytest_generate_tests` over every detected hub role — so any test taking this fixture runs once per connected device (e.g. `test_owner_survives_reboot[nrf52]` + `test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role and never skip a device that happens to be connected. Auto-recovery: if the baked device fails a pre-test `device_info` probe AND uhubctl is available, power-cycle the port once and retry. Without uhubctl, surface the wedge as a clear skip. This catches "device got stuck between tests" without masking persistent regressions (a second wedge after cycling still skips). """ if baked_single_role not in baked_mesh: pytest.skip(f"role {baked_single_role!r} not present on the hub") entry = baked_mesh[baked_single_role] port = entry.get("port") if port: try: _run_with_timeout(lambda: info.device_info(port=port, timeout_s=3.0), 5.0) except Exception: # Device didn't respond. Try a power-cycle recovery if uhubctl # is installed; otherwise surface a skip that names the root # cause clearly. from tests import _power if not _power.is_uhubctl_available(): pytest.skip( f"device {baked_single_role!r} unresponsive on {port}; " "install uhubctl (`brew install uhubctl` / `apt install " "uhubctl`) for auto power-cycle recovery" ) try: new_port = _power.power_cycle(baked_single_role, delay_s=2) except Exception as exc: # noqa: BLE001 pytest.skip( f"device {baked_single_role!r} wedged and power-cycle " f"failed: {exc}" ) # Mutate both the session-scoped `hub_devices` map AND the # baked_mesh entry so downstream fixtures see the recovered port. hub_devices[baked_single_role] = new_port baked_mesh[baked_single_role]["port"] = new_port entry = baked_mesh[baked_single_role] return {"role": baked_single_role, **entry} @pytest.fixture def power_cycle( hub_devices: dict[str, str], ) -> Callable[..., str]: """Return a callable `(role, delay_s=2) -> new_port` that hard-resets the hub port hosting `role`. Skips the test cleanly when uhubctl isn't installed — never want "no uhubctl" to look like a test failure. The callable mutates `hub_devices[role]` in place so subsequent fixture lookups pick up the post-cycle port (mirrors the pattern in provisioning/test_userprefs_survive_factory_reset.py). """ from tests import _power if not _power.is_uhubctl_available(): pytest.skip( "uhubctl not installed; this test needs it for power control. " "Install via `brew install uhubctl` (macOS) or `apt install " "uhubctl` (Debian/Ubuntu)." ) def _cycle(role: str, delay_s: int = 2) -> str: new_port = _power.power_cycle(role, delay_s=delay_s) hub_devices[role] = new_port return new_port return _cycle _DEFAULT_ROLE_ENVS = { "nrf52": "rak4631", "esp32s3": "heltec-v3", } @pytest.fixture def role_env() -> Callable[[str], str]: """Resolve `role` → PlatformIO env name. Falls back to a default map tuned for the lab's default hardware (RAK4631 + Heltec V3). Override per-role via env vars like `MESHTASTIC_MCP_ENV_NRF52=my-custom-nrf-env`. Used by tests that need to reflash a device (provisioning/fleet tiers). """ def _resolve(role: str) -> str: override = os.environ.get(f"MESHTASTIC_MCP_ENV_{role.upper()}") if override: return override if role not in _DEFAULT_ROLE_ENVS: raise KeyError( f"no default env for role {role!r}; " f"set MESHTASTIC_MCP_ENV_{role.upper()}" ) return _DEFAULT_ROLE_ENVS[role] return _resolve @pytest.fixture def mesh_pair( baked_mesh: dict[str, Any], mesh_pair_roles: tuple[str, str], ) -> dict[str, Any]: """Function-scoped: an ordered (tx, rx) pair of baked devices. Auto-parametrized over every directed role pair, so a test that takes `mesh_pair` runs for `nrf52->esp32s3` AND `esp32s3->nrf52` and asserts communication in both directions independently. Cross-device tests (mesh formation, broadcast delivery, direct+ACK) should prefer this over `baked_mesh` so both directions are validated. """ tx_role, rx_role = mesh_pair_roles for role in (tx_role, rx_role): if role not in baked_mesh: pytest.skip(f"role {role!r} not present on the hub") return { "tx_role": tx_role, "rx_role": rx_role, "tx": {"role": tx_role, **baked_mesh[tx_role]}, "rx": {"role": rx_role, **baked_mesh[rx_role]}, } # ---------- Failure-artifact fixtures ------------------------------------- class _SerialCapture: """Active-session wrapper that lazily opens + closes a pio monitor.""" def __init__(self, port: str, env: str | None = None) -> None: self._port = port self._env = env self._session = None self._last_cursor: int | None = None def start(self) -> None: self._session = serial_session.open_session(port=self._port, env=self._env) def snapshot(self, max_lines: int = 500) -> list[str]: if self._session is None: return [] out = serial_session.read_session( self._session, max_lines=max_lines, since_cursor=0 ) return out.get("lines", []) def stop(self) -> None: if self._session is not None: try: serial_session.close_session(self._session) except Exception: pass self._session = None @pytest.fixture def serial_capture(hub_devices: dict[str, str], request: pytest.FixtureRequest) -> Any: """Return a `_SerialCapture` factory. Usage: cap = serial_capture("esp32s3") cap.start() ... run test ... # on failure, serial buffer is attached via pytest_runtest_makereport """ captures: list[_SerialCapture] = [] def factory(role: str, env: str | None = None) -> _SerialCapture: if role not in hub_devices: pytest.skip(f"role {role!r} not present on the hub") cap = _SerialCapture(port=hub_devices[role], env=env) cap.start() captures.append(cap) request.node._serial_captures = captures # type: ignore[attr-defined] return cap yield factory for cap in captures: cap.stop() @pytest.fixture def wait_until() -> Callable[..., Any]: """Exponential-backoff polling helper. Usage: wait_until(lambda: b.node_num in a.iface.nodesByNum, timeout=60) """ def _impl( predicate: Callable[[], Any], timeout: float = 60.0, backoff_start: float = 0.5, backoff_max: float = 5.0, ) -> Any: deadline = time.monotonic() + timeout delay = backoff_start last: Any = None while time.monotonic() < deadline: last = predicate() if last: return last time.sleep(delay) delay = min(delay * 1.5, backoff_max) raise AssertionError( f"predicate did not return truthy within {timeout}s (last={last!r})" ) return _impl # ---------- Firmware log capture (per-test autouse) ----------------------- @pytest.fixture(scope="session", autouse=True) def _firmware_log_stream() -> Any: """Mirror every `meshtastic.log.line` pubsub event to `tests/fwlog.jsonl`. Why this exists: the v1 `_debug_log_buffer` per-test fixture captures firmware logs *in memory* for pytest-html failure attachments, but a live viewer (``meshtastic-mcp-test-tui``) can't read in-process pubsub events from a different process. This fixture adds a session-long, durable mirror — one JSON object per line, with ``port``, ``ts``, and ``line`` fields — that the TUI tails from a worker thread. Schema (kept trivially small so the file grows slowly): {"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "INFO | ... [SerialConsole] Boot..."} The file is truncated at session start (no append across runs — the TUI also unlinks it on launch, so double-truncate is deliberate). Gitignored via ``mcp-server/.gitignore``. Runs alongside ``_debug_log_buffer`` — both subscribe to the same pubsub topic; pubsub fans out to every subscriber so there's no interference. """ import threading from pubsub import pub # type: ignore[import-untyped] out_path = _HERE / "fwlog.jsonl" # Truncate at session start. TUI also unlinks on launch; this is the # plain-CLI path's turn to start clean. try: out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text("") except Exception: # Non-fatal: if we can't open the file, the TUI just gets no # firmware log stream. Tests still run. yield return lock = threading.Lock() fh = out_path.open("a", encoding="utf-8") def handler(line: str, interface: Any) -> None: # `interface` is the meshtastic SerialInterface; `.devPath` # carries the /dev/cu.* we care about. Defensive about missing # attribute — the pubsub handler must never raise. try: port = getattr(interface, "devPath", None) or getattr( interface, "stream", None ) if port and hasattr(port, "port"): port = port.port record = { "ts": time.time(), "port": str(port) if port else None, "line": str(line), } with lock: fh.write(json.dumps(record) + "\n") fh.flush() except Exception: # Swallow — firmware log mirroring is best-effort. pass pub.subscribe(handler, "meshtastic.log.line") try: yield finally: try: pub.unsubscribe(handler, "meshtastic.log.line") except Exception: pass try: fh.close() except Exception: pass @pytest.fixture(autouse=True) def _debug_log_buffer(request: pytest.FixtureRequest) -> Any: """Per-test capture of `meshtastic.log.line` pubsub events. Automatic — every test gets this for free. The pubsub topic fires when a connected device has `security.debug_log_api_enabled=True` AND the client (us) is talking protobufs over its SerialInterface. `baked_mesh` flips the flag on at session start, so every subsequent test that opens any SerialInterface (directly via `connect()` or via a `ReceiveCollector`) picks up the device's log stream automatically. The captured lines are attached to the test's pytest-html failure report by `pytest_runtest_makereport`, so mesh/telemetry failures ship with the firmware-side log context inline — no separate pio monitor, no port-lock conflict. """ import threading as _threading from pubsub import pub # type: ignore[import-untyped] lines: list[str] = [] lock = _threading.Lock() def handler(line: str, interface: Any) -> None: with lock: lines.append(line) pub.subscribe(handler, "meshtastic.log.line") # Stash a strong ref on the test item so pubsub's weakref doesn't GC # the closure before the test ends (same trick ReceiveCollector uses). request.node._debug_log_buffer = lines # type: ignore[attr-defined] request.node._debug_log_handler_ref = handler # type: ignore[attr-defined] try: yield lines finally: try: pub.unsubscribe(handler, "meshtastic.log.line") except Exception: pass # ---------- pytest hooks: report attachments + coverage ------------------- def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any: """Run `fn()` in a worker thread; raise TimeoutError if it takes > `timeout`s. `meshtastic.SerialInterface` construction can hang indefinitely on a misconfigured or unresponsive port. pytest-timeout fires from the main thread via SIGALRM, which doesn't protect code running inside `pytest_runtest_makereport` — that hook runs outside the test's timer. So we wrap each device query in a bounded worker. """ import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: future = pool.submit(fn) try: return future.result(timeout=timeout) except concurrent.futures.TimeoutError as exc: # The worker thread will keep running in the background (we can't # cancel a blocked SerialInterface). It's a daemon-ish leak for # the session, but better than hanging pytest forever. raise TimeoutError(f"operation did not complete within {timeout}s") from exc def _attach_ui_captures(item: pytest.Item, report: Any) -> None: """Embed per-step UI captures (PNG + OCR) into the pytest-html extras. Runs for every UI-tier test on BOTH pass and fail so the HTML report always shows the image strip + OCR transcript. Silently no-ops if pytest-html isn't installed or the test didn't use `frame_capture`. """ captures = getattr(item, "_ui_captures", None) if not captures: return try: from pytest_html import extras as html_extras # type: ignore[import-untyped] except ImportError: return existing = getattr(report, "extras", None) or [] extras_list = list(existing) for cap in captures: png_path = cap.get("png_path") label = f"{cap.get('step', '?')}: {cap.get('label', '')}" frame = cap.get("frame") or {} frame_str = ( f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else "" ) if png_path: try: with open(png_path, "rb") as fh: import base64 b64 = base64.b64encode(fh.read()).decode("ascii") extras_list.append(html_extras.png(b64, name=f"{label}{frame_str}")) except OSError: pass ocr = (cap.get("ocr_text") or "").strip() if ocr: extras_list.append(html_extras.text(ocr, name=f"OCR: {label}{frame_str}")) report.extras = extras_list # type: ignore[attr-defined] @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any: """On test failure, attach serial capture + device state as report artifacts. Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck port, unbaked firmware, dead board), the dump is skipped rather than hanging the session. For UI-tier tests, also embeds per-step camera captures + OCR on every test (pass or fail) so the HTML report shows visual evidence of what the device did. """ outcome = yield report = outcome.get_result() # Attach UI captures on any outcome (pass + fail) — these are the whole # point of the UI tier. Do this before the failure-only branch below so # passing tests still get their image strip. if report.when == "call": _attach_ui_captures(item, report) if report.when != "call" or report.outcome != "failed": return extras: list[str] = [] # Attach firmware log stream captured via the StreamAPI (populated only # when the device has security.debug_log_api_enabled=True — baked_mesh # flips this on at session start). Cheap and high-signal: last 200 lines # of firmware log interleaved with whatever the test was doing. log_buffer = getattr(item, "_debug_log_buffer", None) if log_buffer: extras.append( f"--- firmware log stream ({len(log_buffer)} lines, last 200) ---\n" + "\n".join(log_buffer[-200:]) ) # Attach serial captures (if the test used `serial_capture`) caps = getattr(item, "_serial_captures", None) if caps: for cap in caps: try: lines = _run_with_timeout(lambda c=cap: c.snapshot(max_lines=2000), 5.0) except Exception as exc: lines = [f""] extras.append( f"--- serial capture [{cap._port}] ({len(lines)} lines) ---\n" + "\n".join(lines[-200:]) ) # Dump device state for any role in hub_devices (if the fixture was used). # Each query is bounded to 6s; if the device is wedged, skip the dump for # that role rather than hanging the pytest session. hub_fixture = ( item.funcargs.get("hub_devices") if hasattr(item, "funcargs") else None ) if hub_fixture: for role, port in hub_fixture.items(): state: dict[str, Any] = {"role": role, "port": port} try: state["device_info"] = _run_with_timeout( lambda p=port: info.device_info(port=p, timeout_s=4.0), 6.0 ) except Exception as exc: state["device_info_error"] = repr(exc) try: state["config"] = _run_with_timeout( lambda p=port: admin.get_config(section="lora", port=p), 6.0 ) except Exception as exc: state["config_error"] = repr(exc) extras.append( f"--- device state [{role}] ---\n{json.dumps(state, indent=2, default=str)}" ) if extras: # Attach to pytest-html via `report.sections`; pytest-html renders these report.sections.append(("Meshtastic debug", "\n\n".join(extras))) def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: """Emit `tool_coverage.json` at session end.""" out_path = pathlib.Path(__file__).parent / "tool_coverage.json" tool_coverage.write_report(out_path) # Activate the tool-coverage tracker at import time so imports in fixtures are # also counted. tool_coverage.install()