diff --git a/src/roam/commands/cmd_verify.py b/src/roam/commands/cmd_verify.py index f8472fc81..ccd54b5d1 100644 --- a/src/roam/commands/cmd_verify.py +++ b/src/roam/commands/cmd_verify.py @@ -3716,14 +3716,44 @@ def _gather_and_rank_tests(conn, sym_ids, src_paths, changed_paths, root): return _existing_ranked_paths(ranked, root), None +def _resolve_test_interpreter(root: Path, first_target: str) -> str: + """Find the interpreter that owns the target project's test dependencies. + + roam is commonly installed isolated from any one project (``uv tool + install``, pipx), so ``sys.executable`` is roam's own interpreter, not + the project under test — it has neither pytest nor the project's + dependencies, so collection fails before any test runs. Walk up from the + first impacted test file (not just ``root``, so a venv nested under a + subdirectory of a monorepo is found before a repo-root one) looking for + a ``.venv``/``venv``. Falls back to ``sys.executable``, preserving + today's behaviour when no project venv is found (e.g. roam is already + running inside the project's own venv). + """ + import sys + + bin_dir = "Scripts" if os.name == "nt" else "bin" + exe_name = "python.exe" if os.name == "nt" else "python" + + start = (root / first_target).parent + for directory in (start, *start.parents): + for venv_name in (".venv", "venv"): + candidate = directory / venv_name / bin_dir / exe_name + if candidate.exists(): + return str(candidate) + if directory == root: + break + + return sys.executable + + def _run_impacted_pytest(ordered: list[str], root: Path, timeout: int) -> dict: """Run pytest over the impacted (capped) test files and report failures.""" import subprocess - import sys capped = len(ordered) > _MAX_TEST_FILES targets = ordered[:_MAX_TEST_FILES] - cmd = [sys.executable, "-B", "-m", "pytest", *targets, "--tb=line", "-q", "-p", "no:cacheprovider"] + interpreter = _resolve_test_interpreter(root, targets[0]) + cmd = [interpreter, "-B", "-m", "pytest", *targets, "--tb=line", "-q", "-p", "no:cacheprovider"] try: proc = subprocess.run(cmd, cwd=str(root), capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: diff --git a/tests/test_verify_autofire_hardening.py b/tests/test_verify_autofire_hardening.py index 3a5dd9e3e..dbc8516a8 100644 --- a/tests/test_verify_autofire_hardening.py +++ b/tests/test_verify_autofire_hardening.py @@ -139,6 +139,85 @@ class Result: assert result["violations"][0]["hard_block"] is True +def test_impacted_pytest_prefers_the_target_projects_own_venv(monkeypatch, tmp_path): + """roam commonly runs isolated from any one project (uv tool install, + pipx) — sys.executable is roam's own interpreter, which has neither + pytest nor the target project's dependencies. It must prefer a venv + that actually belongs to the project under test.""" + from roam.commands import cmd_verify + + bin_dir = "Scripts" if os.name == "nt" else "bin" + exe_name = "python.exe" if os.name == "nt" else "python" + venv_python = tmp_path / ".venv" / bin_dir / exe_name + venv_python.parent.mkdir(parents=True) + venv_python.touch() + + class Result: + returncode = 0 + stdout = "1 passed" + stderr = "" + + captured = {} + monkeypatch.setattr( + cmd_verify.subprocess, + "run", + lambda cmd, **kwargs: captured.setdefault("cmd", cmd) and Result(), + ) + cmd_verify._run_impacted_pytest(["tests/test_app.py"], tmp_path, timeout=1) + + assert captured["cmd"][0] == str(venv_python) + + +def test_impacted_pytest_prefers_a_venv_nested_under_the_target_file(monkeypatch, tmp_path): + """A monorepo subproject's own venv (e.g. a nested Python service in an + otherwise non-Python repo) must win over a repo-root venv, since that is + the interpreter that actually has the subproject's dependencies.""" + from roam.commands import cmd_verify + + bin_dir = "Scripts" if os.name == "nt" else "bin" + exe_name = "python.exe" if os.name == "nt" else "python" + root_venv = tmp_path / ".venv" / bin_dir / exe_name + root_venv.parent.mkdir(parents=True) + root_venv.touch() + nested_venv = tmp_path / "service" / ".venv" / bin_dir / exe_name + nested_venv.parent.mkdir(parents=True) + nested_venv.touch() + + class Result: + returncode = 0 + stdout = "1 passed" + stderr = "" + + captured = {} + monkeypatch.setattr( + cmd_verify.subprocess, + "run", + lambda cmd, **kwargs: captured.setdefault("cmd", cmd) and Result(), + ) + cmd_verify._run_impacted_pytest(["service/tests/test_app.py"], tmp_path, timeout=1) + + assert captured["cmd"][0] == str(nested_venv) + + +def test_impacted_pytest_falls_back_to_roams_own_interpreter_with_no_project_venv(monkeypatch, tmp_path): + from roam.commands import cmd_verify + + class Result: + returncode = 0 + stdout = "1 passed" + stderr = "" + + captured = {} + monkeypatch.setattr( + cmd_verify.subprocess, + "run", + lambda cmd, **kwargs: captured.setdefault("cmd", cmd) and Result(), + ) + cmd_verify._run_impacted_pytest(["tests/test_app.py"], tmp_path, timeout=1) + + assert captured["cmd"][0] == sys.executable + + def test_capped_impacted_tests_are_disclosed_as_partial(monkeypatch, tmp_path): from roam.commands import cmd_verify