mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-26 10:18:45 +00:00
- Remove ShellTool from TOOLS list (too ambiguous) - Keep zsh, bash, fish, dash as specific shell tools - Update get_shell_tools to include bash - Fix test imports (ShellTool now in shell_tools.py) - Update tests to expect cmd as primary tool - Verify asyncio.gather + uvloop works correctly hanzo-tools-shell: 0.5.1 → 0.5.2
283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""Test shell features including auto-backgrounding and shell detection."""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from hanzo_tools.shell.shell_tools import ShellTool, BashTool
|
|
from hanzo_tools.shell.base_process import ProcessManager
|
|
from hanzo_tools.shell.process_tool import ProcessTool
|
|
from hanzo_mcp.tools.common.permissions import PermissionManager
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_ctx():
|
|
"""Create a mock MCP context."""
|
|
ctx = MagicMock()
|
|
return ctx
|
|
|
|
|
|
@pytest.fixture
|
|
def permission_manager():
|
|
"""Create a permission manager."""
|
|
pm = PermissionManager()
|
|
pm.add_allowed_path("/tmp")
|
|
pm.add_allowed_path(str(Path.home()))
|
|
return pm
|
|
|
|
|
|
@pytest.fixture
|
|
def bash_tool(permission_manager):
|
|
"""Create a bash tool instance."""
|
|
tool = BashTool()
|
|
tool.permission_manager = permission_manager
|
|
return tool
|
|
|
|
|
|
@pytest.fixture
|
|
def shell_tool(permission_manager):
|
|
"""Create a shell tool instance (smart shell detection)."""
|
|
tool = ShellTool()
|
|
tool.permission_manager = permission_manager
|
|
return tool
|
|
|
|
|
|
@pytest.fixture
|
|
def process_tool():
|
|
"""Create a process tool instance."""
|
|
return ProcessTool()
|
|
|
|
|
|
class TestShellDetection:
|
|
"""Test shell detection functionality."""
|
|
|
|
def test_bash_tool_always_uses_bash(self, tool_helper, bash_tool):
|
|
"""Test BashTool always uses bash interpreter."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
interpreter = bash_tool.get_interpreter()
|
|
assert interpreter == "bash"
|
|
assert bash_tool.get_tool_name() == "bash"
|
|
|
|
def test_shell_tool_detects_zsh_with_zshrc(self, tool_helper, tmp_path):
|
|
"""Test ShellTool detects zsh when .zshrc exists."""
|
|
# Create fake .zshrc
|
|
fake_home = tmp_path / "home"
|
|
fake_home.mkdir()
|
|
zshrc = fake_home / ".zshrc"
|
|
zshrc.touch()
|
|
|
|
with patch.dict(os.environ, {"SHELL": "/bin/zsh"}, clear=True):
|
|
with patch("pathlib.Path.home", return_value=fake_home):
|
|
with patch("shutil.which", return_value="/bin/zsh"):
|
|
tool = ShellTool()
|
|
assert tool._best_shell == "zsh"
|
|
|
|
def test_shell_tool_uses_user_shell_without_zshrc(self, tool_helper, tmp_path):
|
|
"""Test ShellTool uses $SHELL when zsh not available or no .zshrc."""
|
|
fake_home = tmp_path / "home"
|
|
fake_home.mkdir()
|
|
|
|
with patch.dict(os.environ, {"SHELL": "/bin/bash"}, clear=True):
|
|
with patch("pathlib.Path.home", return_value=fake_home):
|
|
with patch("shutil.which", return_value=None): # No zsh
|
|
with patch("pathlib.Path.exists", return_value=True): # /bin/bash exists
|
|
tool = ShellTool()
|
|
assert tool._best_shell == "/bin/bash"
|
|
|
|
def test_shell_tool_fallback_to_bash(self, tool_helper, tmp_path):
|
|
"""Test ShellTool falls back to bash when no other shell available."""
|
|
fake_home = tmp_path / "home"
|
|
fake_home.mkdir()
|
|
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
with patch("pathlib.Path.home", return_value=fake_home):
|
|
with patch("shutil.which", return_value=None): # No zsh
|
|
tool = ShellTool()
|
|
assert tool._best_shell == "bash"
|
|
|
|
def test_force_shell_override(self, tool_helper, bash_tool):
|
|
"""Test HANZO_MCP_FORCE_SHELL overrides shell detection."""
|
|
with patch.dict(os.environ, {"HANZO_MCP_FORCE_SHELL": "/bin/zsh"}):
|
|
interpreter = bash_tool.get_interpreter()
|
|
assert interpreter == "/bin/zsh"
|
|
|
|
def test_shell_tool_respects_force_shell(self, tool_helper):
|
|
"""Test ShellTool respects HANZO_MCP_FORCE_SHELL override."""
|
|
with patch.dict(os.environ, {"HANZO_MCP_FORCE_SHELL": "/usr/bin/fish"}):
|
|
tool = ShellTool()
|
|
assert tool._best_shell == "/usr/bin/fish"
|
|
|
|
|
|
class TestAutoBackgrounding:
|
|
"""Test auto-backgrounding functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_command_completes(self, tool_helper, bash_tool, mock_ctx):
|
|
"""Test that quick commands complete normally."""
|
|
result = await bash_tool.call(mock_ctx, command="echo 'Hello World'")
|
|
tool_helper.assert_in_result("Hello World", result)
|
|
assert "backgrounded" not in result.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("hanzo_tools.shell.auto_background.AutoBackgroundExecutor.execute_with_auto_background")
|
|
async def test_long_command_backgrounds(self, mock_execute, tool_helper, bash_tool, mock_ctx):
|
|
"""Test that long-running commands are backgrounded."""
|
|
# Simulate backgrounding
|
|
mock_execute.return_value = (
|
|
"Command automatically backgrounded after 2 minutes.\nProcess ID: test_123",
|
|
True,
|
|
"test_123",
|
|
)
|
|
|
|
result = await bash_tool.call(mock_ctx, command="sleep 300")
|
|
tool_helper.assert_in_result("backgrounded", result)
|
|
tool_helper.assert_in_result("test_123", result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_command_with_timeout_ignored(self, tool_helper, bash_tool, mock_ctx):
|
|
"""Test that timeout parameter is ignored (auto-backgrounding takes precedence)."""
|
|
# Should not fail even with short timeout
|
|
result = await bash_tool.call(
|
|
mock_ctx,
|
|
command="echo 'Quick test'",
|
|
timeout=1, # This should be ignored
|
|
)
|
|
tool_helper.assert_in_result("Quick test", result)
|
|
|
|
|
|
class TestProcessManagement:
|
|
"""Test process management functionality."""
|
|
|
|
@pytest.fixture
|
|
def process_manager(self):
|
|
"""Get process manager instance."""
|
|
return ProcessManager()
|
|
|
|
def test_process_tracking(self, tool_helper, process_manager):
|
|
"""Test process tracking functionality."""
|
|
# Create mock process (asyncio.subprocess.Process uses returncode, not poll())
|
|
mock_process = MagicMock()
|
|
mock_process.pid = 12345
|
|
mock_process.returncode = None # Still running (asyncio.subprocess.Process style)
|
|
|
|
# Add process
|
|
process_manager.add_process("test_123", mock_process, "/tmp/test.log")
|
|
|
|
# Check it's tracked
|
|
assert process_manager.get_process("test_123") == mock_process
|
|
|
|
# List processes
|
|
processes = process_manager.list_processes()
|
|
assert "test_123" in processes
|
|
assert processes["test_123"]["pid"] == 12345
|
|
assert processes["test_123"]["running"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_list_command(self, tool_helper, process_tool, mock_ctx):
|
|
"""Test process list command."""
|
|
# Add a mock process (asyncio.subprocess.Process uses returncode, not poll())
|
|
mock_process = MagicMock()
|
|
mock_process.pid = 12345
|
|
mock_process.returncode = None # Still running
|
|
|
|
process_tool.process_manager.add_process("test_123", mock_process, "/tmp/test.log")
|
|
|
|
result = await process_tool.call(mock_ctx, action="list")
|
|
tool_helper.assert_in_result("test_123", result)
|
|
tool_helper.assert_in_result("12345", result)
|
|
tool_helper.assert_in_result("running", result)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_kill_command(self, tool_helper, process_tool, mock_ctx):
|
|
"""Test process kill command."""
|
|
# Add a mock process
|
|
mock_process = MagicMock()
|
|
mock_process.pid = 12345
|
|
mock_process.send_signal = MagicMock()
|
|
|
|
process_tool.process_manager.add_process("test_123", mock_process, "/tmp/test.log")
|
|
|
|
result = await process_tool.call(mock_ctx, action="kill", id="test_123", signal_type="TERM")
|
|
|
|
tool_helper.assert_in_result("Sent TERM signal", result)
|
|
tool_helper.assert_in_result("12345", result)
|
|
mock_process.send_signal.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_logs_command(self, tool_helper, process_tool, mock_ctx, tmp_path):
|
|
"""Test process logs command."""
|
|
# Create log file
|
|
log_file = tmp_path / "test.log"
|
|
log_file.write_text("Line 1\nLine 2\nLine 3\n")
|
|
|
|
# Add process with log file
|
|
mock_process = MagicMock()
|
|
process_tool.process_manager.add_process("test_123", mock_process, str(log_file))
|
|
|
|
result = await process_tool.call(mock_ctx, action="logs", id="test_123", lines=2)
|
|
|
|
tool_helper.assert_in_result("Line 2", result)
|
|
tool_helper.assert_in_result("Line 3", result)
|
|
assert "Line 1" not in result # Only last 2 lines
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for shell features."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bash_and_process_integration(self, bash_tool, process_tool, mock_ctx):
|
|
"""Test bash and process tools work together."""
|
|
# Execute a command that would be backgrounded
|
|
with patch.object(
|
|
bash_tool.auto_background_executor,
|
|
"execute_with_auto_background",
|
|
return_value=(
|
|
"Command backgrounded. Process ID: bash_abc123",
|
|
True,
|
|
"bash_abc123",
|
|
),
|
|
):
|
|
bash_result = await bash_tool.call(mock_ctx, command="python long_running_script.py")
|
|
assert "backgrounded" in bash_result
|
|
assert "bash_abc123" in bash_result
|
|
|
|
# Now check process list (would need real process tracking)
|
|
# This is more of a structural test
|
|
process_result = await process_tool.call(mock_ctx, action="list")
|
|
assert isinstance(process_result, str)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shell_specific_commands(self, tool_helper, bash_tool, mock_ctx):
|
|
"""Test shell-specific command execution."""
|
|
# BashTool always reports "bash" regardless of interpreter
|
|
assert bash_tool.get_tool_name() == "bash"
|
|
|
|
# ShellTool reports "shell"
|
|
shell_tool = ShellTool()
|
|
assert shell_tool.get_tool_name() == "shell"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_to_end_workflow():
|
|
"""Test complete workflow with real commands."""
|
|
# This would be an integration test that:
|
|
# 1. Runs a long command
|
|
# 2. Verifies it gets backgrounded
|
|
# 3. Checks process list
|
|
# 4. Views logs
|
|
# 5. Kills the process
|
|
|
|
# For now, just ensure the structure is correct
|
|
pm = PermissionManager()
|
|
pm.add_allowed_path("/tmp")
|
|
|
|
bash = BashTool()
|
|
bash.permission_manager = pm
|
|
|
|
process = ProcessTool()
|
|
|
|
assert bash.name == "bash"
|
|
assert process.name == "process"
|
|
assert bash.get_tool_name() in ["bash", "zsh", "fish", "shell"]
|