"""Portable preflight inference uses fixtures only and never installs or starts tools.""" from argparse import Namespace from pathlib import Path import sys from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit")) from govoplan_devkit import doctor from govoplan_devkit.workspace import Project, Repository def project(tmp_path, checks, *, tools=None, profiles=None): return Project( "Fixture", (Repository("app", tmp_path / "app"), Repository("other", tmp_path / "other")), { "checks": checks, "profiles": profiles if profiles is not None else {"quick": [item["id"] for item in checks]}, "tools": tools or {}, }, ) def check(identity, executable, *, repos=None, deps=None): return { "id": identity, "argv": [executable, "--version"], "cwd": ".", "repos": repos or [], "deps": deps or [], } def diagnose(tmp_path, configured, *, profile=None, repos=None, versions=None): args = Namespace( workspace_root=tmp_path, project=tmp_path / "not-read.json", repo=repos or [], profile=profile, ) tools = {"python": sys.executable, "node": "fixture-node", "npm": "fixture-npm"} version_map = versions or { sys.executable: "Python 3", "fixture-node": "unavailable", "fixture-npm": "unavailable", } with ( patch.object(doctor, "load_project", return_value=configured), patch.object(doctor, "resolve_tools", return_value=tools), patch.object( doctor, "tool_version", side_effect=lambda executable, _env: version_map[executable], ) as probe, patch.object(doctor, "inspect_repository", return_value={"errors": []}), ): result = doctor.diagnose(args) return result, probe def test_python_only_portable_project_does_not_probe_or_block_on_unused_node(tmp_path): configured = project(tmp_path, [check("python-test", "{python}")]) result, probe = diagnose(tmp_path, configured) assert result["_exit_code"] == 0 assert result["required_tools"] == ["python"] assert probe.call_count == 1 assert { item["id"] for item in result["checks"] if item["status"] == "not_required" } == {"node", "npm"} def test_selected_profile_excludes_other_profile_tool_requirements(tmp_path): configured = project( tmp_path, [check("py", "{python}"), check("js", "{npm}")], profiles={"quick": ["py"], "ui": ["js"]}, ) result, _ = diagnose(tmp_path, configured, profile="quick") assert result["required_tools"] == ["python"] result, _ = diagnose(tmp_path, configured) assert result["required_tools"] == ["node", "npm", "python"] assert result["_exit_code"] == 1 def test_selected_repository_includes_dependency_tool_requirements(tmp_path): configured = project( tmp_path, [ check("build", "{npm}", repos=["other"]), check("test", "{python}", repos=["app"], deps=["build"]), ], ) result, _ = diagnose(tmp_path, configured, repos=["app"]) assert result["required_tools"] == ["node", "npm", "python"] def test_unselected_repository_does_not_require_its_tool(tmp_path): configured = project( tmp_path, [check("js", "{npm}", repos=["other"]), check("py", "{python}", repos=["app"])], ) result, _ = diagnose(tmp_path, configured, repos=["app"]) assert result["required_tools"] == ["python"] def test_explicit_tool_configuration_declares_indirect_script_dependency(tmp_path): configured = project(tmp_path, [check("shell", "sh")], tools={"npm": "fixture-npm"}) result, _ = diagnose(tmp_path, configured) assert result["required_tools"] == ["node", "npm", "python"] assert result["_exit_code"] == 1 @pytest.mark.parametrize( "executable,expected", [ ("/opt/node/bin/node", ["node", "python"]), ("npm", ["node", "npm", "python"]), ("npx", ["node", "npm", "python"]), ], ) def test_direct_tool_names_are_inferred(tmp_path, executable, expected): result, _ = diagnose(tmp_path, project(tmp_path, [check("test", executable)])) assert result["required_tools"] == expected def test_context_only_project_needs_no_node_and_makes_no_files(tmp_path): before = set(tmp_path.iterdir()) result, _ = diagnose(tmp_path, project(tmp_path, [], profiles={})) assert result["required_tools"] == ["python"] assert set(tmp_path.iterdir()) == before def test_missing_profile_is_not_silently_widened(tmp_path): with pytest.raises(ValueError, match="does not declare profile"): diagnose(tmp_path, project(tmp_path, []), profile="ui") def test_native_govoplan_still_requires_all_three_tools(tmp_path): configured = project(tmp_path, []) args = Namespace(workspace_root=tmp_path, project=None, repo=[], profile=None) with ( patch.object(doctor, "load_project", return_value=configured), patch.object( doctor, "resolve_tools", return_value={"python": sys.executable, "node": "node", "npm": "npm"}, ), patch.object( doctor, "tool_version", side_effect=lambda executable, _env: ( "Python" if executable == sys.executable else "unavailable" ), ), patch.object(doctor, "inspect_repository", return_value={"errors": []}), ): result = doctor.diagnose(args) assert result["required_tools"] == ["node", "npm", "python"] assert result["_exit_code"] == 1