"""Plan 07-05 Wave 5 R9 acceptance — doctor 6-row PASS/FAIL checklist. Each individual failure scenario produces a FAIL on the matching check and the doctor exits with the documented code (D7-13: 0=all pass, 1=any FAIL no --apply, 2=--apply but FAIL persists). Checks (D7-11 ordering): (a) daemon process alive — daemon_pid in .daemon-state.json (b) socket file fresh — connect+status round-trip <250ms (c) lock file healthy — fcntl probe doesn't error (d) no orphan iai_mcp.core procs — psutil scan returns 0 (e) daemon state file valid — fsm_state ∈ {WAKE, SLEEPING, DREAMING} (f) lancedb store readable — MemoryStore() opens without error Tests use monkeypatching to construct each failure scenario in isolation without booting a real daemon (test_doctor_apply_recovery.py covers the end-to-end recovery scenario with a real subprocess daemon). """ from __future__ import annotations import argparse import io import json import os import sys from contextlib import redirect_stdout from pathlib import Path import pytest # --------------------------------------------------------------------------- # Fixtures: tmp socket + state + lock + store paths # --------------------------------------------------------------------------- @pytest.fixture def short_socket_paths(tmp_path, monkeypatch): """Yield (lock_path, sock_path, state_path) under tmp dirs. AF_UNIX on macOS caps socket paths at ~104 bytes; pytest's tmp_path can be too long under xdist. Use a short /tmp/iai-doc--/ fallback for the socket. Monkeypatches: - IAI_DAEMON_SOCKET_PATH env (read by doctor._resolve_socket_path) - iai_mcp.daemon_state.STATE_PATH (read by check (a)/(e) load_state) - iai_mcp.cli.LOCK_PATH (read by check (c) ProcessLock) - IAI_MCP_STORE env (read by check (f) MemoryStore) """ lock_path = tmp_path / ".lock" sock_dir = Path(f"/tmp/iai-doc-{os.getpid()}-{id(tmp_path)}") sock_dir.mkdir(parents=True, exist_ok=True) sock_path = sock_dir / "d.sock" state_path = tmp_path / ".daemon-state.json" store_dir = tmp_path / "store" store_dir.mkdir(parents=True, exist_ok=True) from iai_mcp import cli, daemon_state monkeypatch.setenv("IAI_DAEMON_SOCKET_PATH", str(sock_path)) monkeypatch.setenv("IAI_MCP_STORE", str(store_dir)) monkeypatch.setattr(daemon_state, "STATE_PATH", state_path) monkeypatch.setattr(cli, "LOCK_PATH", lock_path) # Also patch cli.SOCKET_PATH as a defensive fallback — doctor's # _resolve_socket_path prefers the env var, but if env propagation is # ever removed this guarantees test isolation. monkeypatch.setattr(cli, "SOCKET_PATH", sock_path) try: yield lock_path, sock_path, state_path finally: try: if sock_path.exists(): sock_path.unlink() except OSError: pass try: sock_dir.rmdir() except OSError: pass # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_clean_environment_yields_check_a_fail_exit_1(short_socket_paths, capsys): """Clean tmp env (no daemon, no state file) → cmd_doctor returns 1. Check (a) reports ABSENT (no daemon_pid). Check (e) PASSES (no state file is acceptable — daemon never booted). Other FAILs depend on host process table for (d), but exit code is 1 either way (any FAIL → 1 without --apply). """ from iai_mcp.doctor import cmd_doctor args = argparse.Namespace(apply=False, yes=False) rc = cmd_doctor(args) captured = capsys.readouterr() assert rc == 1, f"expected 1 (FAIL no --apply), got {rc}" assert "IAI-MCP Doctor" in captured.out assert "(a) daemon process alive" in captured.out assert "ABSENT" in captured.out, "check (a) should say ABSENT when no daemon_pid" @pytest.mark.parametrize( "scenario,expected_fail_check", [ ("no_daemon_pid", "(a) daemon process alive"), ("dead_pid_in_state", "(a) daemon process alive"), ("stale_socket_unconnectable", "(b) socket file fresh"), ("orphan_core_procs", "(d) no orphan iai_mcp.core procs"), ("corrupt_state_fsm", "(e) daemon state file valid"), ], ) def test_individual_failure_modes( scenario, expected_fail_check, short_socket_paths, monkeypatch ): """R9: each failure scenario produces a FAIL on the matching check. Cascading FAILs are allowed (e.g. dead daemon → check_a + check_b both fail) but the named expected_fail_check MUST appear in the FAIL list. """ _, sock_path, state_path = short_socket_paths if scenario == "no_daemon_pid": # State file absent → check (a) FAIL with ABSENT. # Default fixture state — nothing more to do. pass elif scenario == "dead_pid_in_state": # Stamp a high PID that almost certainly doesn't exist on a fresh # macOS / Linux box. Stay well under INT_MAX (2^31-1) so os.kill # doesn't raise OverflowError before the ProcessLookupError path. # PID_MAX defaults: macOS 99_999, Linux 4_194_304 — value 2_000_000 # is above both default ranges (effectively guaranteed unallocated). state_path.write_text(json.dumps({"daemon_pid": 2_000_000, "fsm_state": "WAKE"})) elif scenario == "stale_socket_unconnectable": # Create the socket file as a regular file (not a real socket) → connect # raises ConnectionRefusedError or OSError. check (b) FAIL. sock_path.write_text("") elif scenario == "orphan_core_procs": # Monkeypatch psutil.process_iter to return a synthetic orphan hit. # Avoids actually spawning python -m iai_mcp.core (which would launch # a real Python core and pollute the process table for sibling tests). import psutil class _FakeProc: def __init__(self, pid: int, cmdline: list[str]): self.info = {"pid": pid, "cmdline": cmdline} fake = _FakeProc(99_999, ["python", "-m", "iai_mcp.core"]) monkeypatch.setattr( psutil, "process_iter", lambda *a, **kw: [fake] ) elif scenario == "corrupt_state_fsm": # Write an invalid fsm_state value → check (e) FAIL. state_path.write_text(json.dumps({"fsm_state": "INVALID_STATE_VALUE"})) from iai_mcp.doctor import run_diagnosis results = run_diagnosis() fail_names = [r.name for r in results if not r.passed] assert expected_fail_check in fail_names, ( f"Expected FAIL on '{expected_fail_check}' for scenario '{scenario}'; " f"got fails: {fail_names}" ) def test_print_checklist_format_six_rows(short_socket_paths, monkeypatch, capsys): """R9: print_checklist always emits 6 PASS/FAIL rows with consistent header. Forces all 6 checks to PASS via monkeypatching to verify the formatter handles a fully-green checklist (default scenario in the other tests only verifies the FAIL path). """ from iai_mcp import doctor forced_results = [ doctor.CheckResult("(a) daemon process alive", True, "PID 99999 (iai_mcp.daemon)"), doctor.CheckResult("(b) socket file fresh", True, "connected in 5 ms"), doctor.CheckResult("(c) lock file healthy", True, "acquirable"), doctor.CheckResult("(d) no orphan iai_mcp.core procs", True, "0 found"), doctor.CheckResult("(e) daemon state file valid", True, "fsm_state=WAKE"), doctor.CheckResult("(f) lancedb store readable", True, "opens without error"), ] doctor.print_checklist(forced_results) out = capsys.readouterr().out assert "IAI-MCP Doctor" in out assert out.count("[PASS]") == 6 assert out.count("[FAIL]") == 0 def test_all_pass_returns_exit_0(short_socket_paths, monkeypatch, capsys): """D7-13 exit 0: when run_diagnosis returns all PASS, cmd_doctor returns 0. Monkeypatches run_diagnosis itself rather than constructing a passing world — the latter requires a real daemon subprocess (covered by test_doctor_apply_recovery.py). """ from iai_mcp import doctor forced_pass = [ doctor.CheckResult(name, True, "synthetic pass") for name in ( "(a) daemon process alive", "(b) socket file fresh", "(c) lock file healthy", "(d) no orphan iai_mcp.core procs", "(e) daemon state file valid", "(f) lancedb store readable", ) ] monkeypatch.setattr(doctor, "run_diagnosis", lambda: forced_pass) args = argparse.Namespace(apply=False, yes=False) rc = doctor.cmd_doctor(args) out = capsys.readouterr().out assert rc == 0 assert "All checks passed" in out def test_apply_without_yes_warns_when_yes_alone(short_socket_paths, monkeypatch, capsys): """R9 UX: --yes without --apply prints a warning to stderr but still runs diagnosis (does not block the user). """ from iai_mcp import doctor args = argparse.Namespace(apply=False, yes=True) rc = doctor.cmd_doctor(args) captured = capsys.readouterr() # The warning goes to stderr. assert "--yes without --apply is meaningless" in captured.err # Diagnosis still runs — exit code mirrors check outcome (likely 1 # because no daemon is running in the tmp env). assert rc in (0, 1) def test_exit_code_2_when_apply_cannot_fix(short_socket_paths, monkeypatch, capsys): """D7-13: --apply runs all repair actions but final re-check still has FAIL → exit 2. Construct a scenario where the FAIL is unfixable: corrupt fsm_state in the state file. _plan_repair_actions has no action mapped to check (e), so the FAIL persists through the re-check and cmd_doctor returns 2. """ _, _, state_path = short_socket_paths # Write an invalid fsm_state so check (e) always FAILs. state_path.write_text(json.dumps({"fsm_state": "TOTALLY_BOGUS"})) # Also force every other check to PASS via run_diagnosis monkeypatch # so we isolate check (e) as the persistent FAIL. The first call returns # the bogus-state results; the second (after --apply) returns the same. from iai_mcp import doctor def _forced_fail_e_only(): return [ doctor.CheckResult("(a) daemon process alive", True, "synthetic"), doctor.CheckResult("(b) socket file fresh", True, "synthetic"), doctor.CheckResult("(c) lock file healthy", True, "synthetic"), doctor.CheckResult("(d) no orphan iai_mcp.core procs", True, "synthetic"), doctor.CheckResult( "(e) daemon state file valid", False, "fsm_state='TOTALLY_BOGUS' not in [...]", ), doctor.CheckResult("(f) lancedb store readable", True, "synthetic"), ] monkeypatch.setattr(doctor, "run_diagnosis", _forced_fail_e_only) args = argparse.Namespace(apply=True, yes=True) rc = doctor.cmd_doctor(args) out = capsys.readouterr().out assert rc == 2, f"expected 2 (--apply tried but FAIL persists), got {rc}" assert "STILL BROKEN" in out assert "(e) daemon state file valid" in out def test_check_b_returns_fail_when_socket_missing(short_socket_paths): """Check (b) returns FAIL with explicit "does not exist" diagnosis when the socket file is missing entirely (not just unreachable). """ _, sock_path, _ = short_socket_paths # Defensive: ensure socket truly absent. if sock_path.exists(): sock_path.unlink() from iai_mcp.doctor import check_b_socket_fresh result = check_b_socket_fresh() assert result.passed is False assert "does not exist" in result.detail def test_check_e_passes_when_state_file_absent(short_socket_paths): """Check (e) PASSES when state file is absent (daemon never booted is not a bug at this layer — check (a) catches it as ABSENT). """ _, _, state_path = short_socket_paths if state_path.exists(): state_path.unlink() from iai_mcp.doctor import check_e_state_file_valid result = check_e_state_file_valid() assert result.passed is True assert "no state file" in result.detail