from unittest.mock import patch, MagicMock from scripts.runner import ( run_tool, tool_available, run_tools_parallel, ) def test_tool_available_returns_true_when_on_path(): with patch("shutil.which", return_value="/usr/bin/bandit"): assert tool_available("bandit") is True def test_tool_available_returns_false_when_missing(): with patch("shutil.which", return_value=None): assert tool_available("nope") is False def test_run_tool_captures_stdout(): fake = MagicMock(returncode=0, stdout='{"results": []}', stderr="") with patch("subprocess.run", return_value=fake): with patch("shutil.which", return_value="/usr/bin/bandit"): r = run_tool(["bandit", "-r", "."], cwd="/repo") assert r.ran is True assert r.exit_code == 0 assert r.stdout == '{"results": []}' def test_run_tool_reports_missing_binary(): with patch("shutil.which", return_value=None): r = run_tool(["nope"], cwd="/repo") assert r.ran is False assert r.reason == "not on PATH" def test_run_tool_reports_timeout(): import subprocess def _raise(*a, **kw): raise subprocess.TimeoutExpired(cmd="x", timeout=1) with patch("subprocess.run", side_effect=_raise): with patch("shutil.which", return_value="/usr/bin/x"): r = run_tool(["x"], cwd="/repo", timeout=1) assert r.ran is False assert "timeout" in r.reason.lower() def test_run_tools_parallel_respects_cap(): def _fake(args, **kw): return MagicMock(returncode=0, stdout="", stderr="") with patch("subprocess.run", side_effect=_fake): with patch("shutil.which", return_value="/usr/bin/x"): jobs = [(["a"], "/r"), (["b"], "/r"), (["c"], "/r")] results = run_tools_parallel(jobs, max_workers=2) assert len(results) == 3 assert {r.binary for r in results} == {"a", "b", "c"} def test_resolve_tool_prefers_skill_venv(tmp_path): """If a tool exists in the skill's .venv/bin/, prefer it over PATH.""" from scripts import runner venv_bin = tmp_path / "venv" / "bin" venv_bin.mkdir(parents=True) bandit_local = venv_bin / "bandit" bandit_local.write_text("#!/bin/sh\necho hi\n") bandit_local.chmod(0o755) with patch.object(runner, "_SKILL_VENV_BIN", venv_bin): with patch("shutil.which", return_value="/usr/bin/bandit"): resolved = runner.resolve_tool("bandit") assert resolved == str(bandit_local) def test_resolve_tool_falls_back_to_path(tmp_path): """Without a local venv copy, fall back to PATH.""" from scripts import runner venv_bin = tmp_path / "venv" / "bin" venv_bin.mkdir(parents=True) with patch.object(runner, "_SKILL_VENV_BIN", venv_bin): with patch("shutil.which", return_value="/usr/bin/bandit"): resolved = runner.resolve_tool("bandit") assert resolved == "/usr/bin/bandit" def test_run_tool_uses_resolved_path(tmp_path): """run_tool should invoke the resolved binary, not the bare name.""" from scripts import runner venv_bin = tmp_path / "venv" / "bin" venv_bin.mkdir(parents=True) bandit_local = venv_bin / "bandit" bandit_local.write_text("#!/bin/sh\necho hi\n") bandit_local.chmod(0o755) captured: list[list[str]] = [] def _fake(args, **kw): captured.append(list(args)) return MagicMock(returncode=0, stdout="", stderr="") with patch.object(runner, "_SKILL_VENV_BIN", venv_bin): with patch("subprocess.run", side_effect=_fake): runner.run_tool(["bandit", "-r", "."], cwd="/repo") assert captured[0][0] == str(bandit_local) assert captured[0][1:] == ["-r", "."]