CmdForge/tests/test_system_deps.py

311 lines
11 KiB
Python

"""Tests for system dependency management."""
import shutil
from unittest.mock import patch, MagicMock
import pytest
from cmdforge.tool import SystemDependency, Tool
from cmdforge.system_deps import (
detect_os, detect_package_manager, check_system_dep,
get_install_command, get_install_command_string,
check_and_report, get_binary_path
)
class TestSystemDependencyDataclass:
"""Tests for SystemDependency dataclass."""
def test_short_form_roundtrip(self):
"""Short form 'ffmpeg' serializes back to string."""
dep = SystemDependency.from_dict("ffmpeg")
assert dep.name == "ffmpeg"
assert dep.to_dict() == "ffmpeg"
def test_long_form_roundtrip(self):
"""Long form with all fields serializes correctly."""
data = {
"name": "ffmpeg",
"description": "Audio/video processing",
"binaries": ["ffplay", "ffmpeg"],
"packages": {"apt": "ffmpeg", "brew": "ffmpeg"}
}
dep = SystemDependency.from_dict(data)
result = dep.to_dict()
assert result["name"] == "ffmpeg"
assert result["description"] == "Audio/video processing"
assert result["binaries"] == ["ffplay", "ffmpeg"]
assert result["packages"]["apt"] == "ffmpeg"
def test_long_form_minimal_becomes_short(self):
"""Long form with only name becomes short form on serialize."""
data = {"name": "curl"}
dep = SystemDependency.from_dict(data)
# Empty fields should serialize to short form
dep._original_format = "short"
assert dep.to_dict() == "curl"
def test_get_binaries_to_check_default(self):
"""get_binaries_to_check returns [name] when binaries is empty."""
dep = SystemDependency(name="curl")
assert dep.get_binaries_to_check() == ["curl"]
def test_get_binaries_to_check_specified(self):
"""get_binaries_to_check returns specified binaries."""
dep = SystemDependency(name="ffmpeg", binaries=["ffplay", "ffmpeg"])
assert dep.get_binaries_to_check() == ["ffplay", "ffmpeg"]
def test_get_package_name_default(self):
"""get_package_name returns name when no override."""
dep = SystemDependency(name="ffmpeg")
assert dep.get_package_name("apt") == "ffmpeg"
assert dep.get_package_name("brew") == "ffmpeg"
def test_get_package_name_override(self):
"""get_package_name returns override when specified."""
dep = SystemDependency(name="ffmpeg", packages={"brew": "ffmpeg-headless"})
assert dep.get_package_name("apt") == "ffmpeg" # Default
assert dep.get_package_name("brew") == "ffmpeg-headless" # Override
class TestPlatformDetection:
"""Tests for platform detection functions."""
@patch('platform.system')
def test_detect_os_linux(self, mock_system):
"""detect_os returns 'linux' for Linux."""
mock_system.return_value = "Linux"
assert detect_os() == "linux"
@patch('platform.system')
def test_detect_os_darwin(self, mock_system):
"""detect_os returns 'darwin' for macOS."""
mock_system.return_value = "Darwin"
assert detect_os() == "darwin"
@patch('platform.system')
def test_detect_os_windows(self, mock_system):
"""detect_os returns 'windows' for Windows."""
mock_system.return_value = "Windows"
assert detect_os() == "windows"
@patch('cmdforge.system_deps.detect_os')
@patch('shutil.which')
def test_detect_package_manager_apt(self, mock_which, mock_os):
"""detect_package_manager finds apt on Linux."""
mock_os.return_value = "linux"
mock_which.side_effect = lambda x: "/usr/bin/apt-get" if x == "apt-get" else None
assert detect_package_manager() == "apt"
@patch('cmdforge.system_deps.detect_os')
@patch('shutil.which')
def test_detect_package_manager_brew_macos(self, mock_which, mock_os):
"""detect_package_manager finds brew on macOS."""
mock_os.return_value = "darwin"
mock_which.side_effect = lambda x: "/usr/local/bin/brew" if x == "brew" else None
assert detect_package_manager() == "brew"
@patch('cmdforge.system_deps.detect_os')
def test_detect_package_manager_windows_returns_none(self, mock_os):
"""detect_package_manager returns None on Windows."""
mock_os.return_value = "windows"
assert detect_package_manager() is None
class TestDependencyChecking:
"""Tests for dependency checking functions."""
@patch('shutil.which')
def test_check_system_dep_found(self, mock_which):
"""check_system_dep returns True when binary exists."""
mock_which.return_value = "/usr/bin/curl"
dep = SystemDependency(name="curl")
assert check_system_dep(dep) is True
@patch('shutil.which')
def test_check_system_dep_not_found(self, mock_which):
"""check_system_dep returns False when binary not found."""
mock_which.return_value = None
dep = SystemDependency(name="nonexistent-pkg")
assert check_system_dep(dep) is False
@patch('shutil.which')
def test_check_system_dep_any_binary_satisfied(self, mock_which):
"""Dep is satisfied if ANY binary is found."""
def which_side_effect(binary):
return "/usr/bin/ffplay" if binary == "ffplay" else None
mock_which.side_effect = which_side_effect
dep = SystemDependency(name="ffmpeg", binaries=["ffplay", "ffmpeg"])
assert check_system_dep(dep) is True
@patch('shutil.which')
def test_check_and_report(self, mock_which):
"""check_and_report categorizes deps correctly."""
def which_side_effect(binary):
if binary in ["curl", "git"]:
return f"/usr/bin/{binary}"
return None
mock_which.side_effect = which_side_effect
deps = [
SystemDependency(name="curl"),
SystemDependency(name="git"),
SystemDependency(name="nonexistent"),
]
installed, missing = check_and_report(deps)
assert len(installed) == 2
assert len(missing) == 1
assert missing[0].name == "nonexistent"
class TestInstallCommands:
"""Tests for install command generation."""
def test_get_install_command_apt(self):
"""get_install_command returns correct apt command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "apt")
assert cmd == ["sudo", "apt-get", "install", "-y", "ffmpeg"]
def test_get_install_command_brew(self):
"""get_install_command returns correct brew command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "brew")
assert cmd == ["brew", "install", "ffmpeg"]
def test_get_install_command_pacman(self):
"""get_install_command returns correct pacman command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "pacman")
assert cmd == ["sudo", "pacman", "-S", "--noconfirm", "ffmpeg"]
def test_get_install_command_dnf(self):
"""get_install_command returns correct dnf command."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "dnf")
assert cmd == ["sudo", "dnf", "install", "-y", "ffmpeg"]
def test_get_install_command_unknown_manager(self):
"""get_install_command returns None for unknown manager."""
dep = SystemDependency(name="ffmpeg")
cmd = get_install_command(dep, "unknown")
assert cmd is None
def test_install_command_no_shell_injection(self):
"""Install command uses list, no shell injection possible."""
# Even malicious input is safe because subprocess with list doesn't use shell
dep = SystemDependency(name="test; rm -rf /")
cmd = get_install_command(dep, "apt")
assert cmd == ["sudo", "apt-get", "install", "-y", "test; rm -rf /"]
# This is safe - the semicolon is passed as literal arg, not interpreted
def test_get_install_command_string(self):
"""get_install_command_string returns readable string."""
dep = SystemDependency(name="ffmpeg")
cmd_str = get_install_command_string(dep, "apt")
assert cmd_str == "sudo apt-get install -y ffmpeg"
class TestToolIntegration:
"""Tests for Tool integration with system dependencies."""
def test_tool_with_system_dependencies_loads(self):
"""Tool config with system_dependencies parses correctly."""
data = {
"name": "test-tool",
"description": "Test",
"system_dependencies": [
"curl",
{
"name": "ffmpeg",
"description": "Audio processing",
"binaries": ["ffplay"],
}
],
"steps": [],
"output": "{input}"
}
tool = Tool.from_dict(data)
assert len(tool.system_dependencies) == 2
assert tool.system_dependencies[0].name == "curl"
assert tool.system_dependencies[1].name == "ffmpeg"
assert tool.system_dependencies[1].binaries == ["ffplay"]
def test_tool_to_dict_includes_system_dependencies(self):
"""Tool.to_dict includes system_dependencies when present."""
tool = Tool(
name="test-tool",
description="Test",
system_dependencies=[
SystemDependency(name="curl", _original_format="short"),
SystemDependency(
name="ffmpeg",
description="Audio",
binaries=["ffplay"],
_original_format="long"
)
]
)
data = tool.to_dict()
assert "system_dependencies" in data
assert len(data["system_dependencies"]) == 2
assert data["system_dependencies"][0] == "curl" # Short form
assert data["system_dependencies"][1]["name"] == "ffmpeg" # Long form
def test_tool_without_system_dependencies(self):
"""Tool without system_dependencies works correctly."""
data = {
"name": "simple-tool",
"description": "Test",
"steps": [],
"output": "{input}"
}
tool = Tool.from_dict(data)
assert tool.system_dependencies == []
# to_dict should not include empty system_dependencies
result = tool.to_dict()
assert "system_dependencies" not in result
class TestRunnerIntegration:
"""Tests for runner.py integration."""
@patch('cmdforge.system_deps.check_system_dep')
def test_check_system_dependencies_function(self, mock_check):
"""check_system_dependencies returns missing deps."""
from cmdforge.runner import check_system_dependencies
mock_check.side_effect = lambda d: d.name != "missing-pkg"
tool = Tool(
name="test-tool",
system_dependencies=[
SystemDependency(name="installed-pkg"),
SystemDependency(name="missing-pkg"),
]
)
missing = check_system_dependencies(tool)
assert len(missing) == 1
assert missing[0].name == "missing-pkg"
def test_check_system_dependencies_empty(self):
"""check_system_dependencies returns empty for no deps."""
from cmdforge.runner import check_system_dependencies
tool = Tool(name="test-tool")
missing = check_system_dependencies(tool)
assert missing == []