diff --git a/backend/tests/AGENTS.md b/backend/tests/AGENTS.md index fe2a274616..a956a4cd2b 100644 --- a/backend/tests/AGENTS.md +++ b/backend/tests/AGENTS.md @@ -18,6 +18,12 @@ ownership and capture cleanup. Retain the real XLSX conversion coverage. Backend tests must preserve the runtime invariants they exercise without changing production execution topology. +`test_doctor_script.py` runs a copied doctor from a temporary checkout with an +empty-model config. Drop inherited `DEER_FLOW_*`, `PYTHONPATH` and `PYTHONHOME` +from child processes; cover help, invalid flags, normal reporting and an +unrelated parent config that stays untouched. Keep argument parsing at the CLI +entry point so in-process `doctor.main()` callers do not consume pytest's argv. + `test_run_terminal_protocol.py` exercises authoritative `run.terminal` evidence through real Worker finalization. Keep final commit authority, producer drain, singleton confirmation, and Seal boundaries observable. Release all storage/producer gates before bounded cleanup, diff --git a/backend/tests/test_doctor_script.py b/backend/tests/test_doctor_script.py new file mode 100644 index 0000000000..ba0c6b38c3 --- /dev/null +++ b/backend/tests/test_doctor_script.py @@ -0,0 +1,96 @@ +"""CLI contract for scripts/doctor.py, exercised in a disposable checkout.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture +def doctor_checkout(tmp_path: Path) -> Path: + checkout = tmp_path / "checkout" + scripts_dir = checkout / "scripts" + scripts_dir.mkdir(parents=True) + (checkout / "frontend").mkdir() + shutil.copyfile(REPO_ROOT / "scripts" / "doctor.py", scripts_dir / "doctor.py") + (checkout / "config.yaml").write_text("config_version: 5\nmodels: []\n", encoding="utf-8") + return checkout + + +def _run_doctor(checkout: Path, *args: str) -> subprocess.CompletedProcess[str]: + env = {name: value for name, value in os.environ.items() if not name.startswith("DEER_FLOW_") and name not in {"PYTHONPATH", "PYTHONHOME"}} + return subprocess.run( + [sys.executable, str(checkout / "scripts" / "doctor.py"), *args], + cwd=checkout, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + timeout=30, + ) + + +def test_help_prints_usage_without_running_diagnostics(doctor_checkout: Path): + result = _run_doctor(doctor_checkout, "--help") + + assert result.returncode == 0, result.stderr + assert "usage:" in result.stdout + assert "Status:" not in result.stdout + assert "System Requirements" not in result.stdout + + +def test_unknown_flag_exits_2_with_usage_error(doctor_checkout: Path): + result = _run_doctor(doctor_checkout, "--verbos") + + assert result.returncode == 2 + assert "unrecognized arguments: --verbos" in result.stderr + assert "Status:" not in result.stdout + assert "System Requirements" not in result.stdout + + +def test_no_arguments_still_runs_the_diagnostics(doctor_checkout: Path): + result = _run_doctor(doctor_checkout) + + # Missing host tools or an incomplete fixture config may produce failures; + # either exit code must still come with the normal diagnostic report. + assert result.returncode in (0, 1), result.stderr + assert "Status:" in result.stdout + assert "System Requirements" in result.stdout + + +def test_no_arguments_ignores_inherited_runtime_configuration(doctor_checkout: Path, tmp_path: Path, monkeypatch): + external_root = tmp_path / "external project" + external_root.mkdir() + external_config = external_root / "config.yaml" + config_bytes = b"config_version: 5\nmodels:\n - name: inherited-model\n" + external_config.write_bytes(config_bytes) + external_env = external_root / ".env" + env_bytes = b"# unrelated developer environment\n" + external_env.write_bytes(env_bytes) + external_home = tmp_path / "external home" + for name, value in { + "DEER_FLOW_CONFIG_PATH": external_config, + "DEER_FLOW_PROJECT_ROOT": external_root, + "DEER_FLOW_ENV_FILE": external_env, + "DEER_FLOW_HOME": external_home, + }.items(): + monkeypatch.setenv(name, str(value)) + + result = _run_doctor(doctor_checkout) + + assert result.returncode in (0, 1), result.stderr + assert "Status:" in result.stdout + assert "no models found" in result.stdout + assert "1 model(s)" not in result.stdout + assert external_config.read_bytes() == config_bytes + assert external_env.read_bytes() == env_bytes + assert not external_home.exists() diff --git a/scripts/doctor.py b/scripts/doctor.py index cb16cd98af..95e2001ad1 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -11,6 +11,7 @@ Exit codes: from __future__ import annotations +import argparse import json import os import shutil @@ -979,4 +980,11 @@ def main() -> int: if __name__ == "__main__": + # Parsed here rather than inside main(): the unit tests call main() in-process + # under pytest, whose argv must not reach argparse. + parser = argparse.ArgumentParser( + prog="python scripts/doctor.py", + description=("DeerFlow Health Check (make doctor): checks system requirements, configuration, LLM provider, and optional components, then prints an actionable report."), + ) + parser.parse_args() sys.exit(main())