Unix Manual Server
by tizee
- mcp-unix-manual
- tests
import os
import subprocess
import pytest
from unittest.mock import patch, MagicMock
import sys
# Add parent directory to path to import the server module
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import unix_manual_server
@pytest.fixture
def mock_subprocess_run():
"""Fixture to mock subprocess.run"""
with patch('subprocess.run') as mock_run:
yield mock_run
@pytest.fixture
def mock_os_path_exists():
"""Fixture to mock os.path.exists"""
with patch('os.path.exists') as mock_exists:
mock_exists.return_value = True
yield mock_exists
@pytest.fixture
def mock_os_path_isdir():
"""Fixture to mock os.path.isdir"""
with patch('os.path.isdir') as mock_isdir:
mock_isdir.return_value = True
yield mock_isdir
@pytest.fixture
def mock_os_path_isfile():
"""Fixture to mock os.path.isfile"""
with patch('os.path.isfile') as mock_isfile:
mock_isfile.return_value = True
yield mock_isfile
@pytest.fixture
def mock_os_access():
"""Fixture to mock os.access"""
with patch('os.access') as mock_access:
mock_access.return_value = True
yield mock_access
@pytest.fixture
def mock_os_listdir():
"""Fixture to mock os.listdir"""
with patch('os.listdir') as mock_listdir:
mock_listdir.return_value = ['ls', 'cat', 'grep']
yield mock_listdir
@pytest.fixture
def mock_logger():
"""Fixture to mock the logger"""
with patch('unix_manual_server.logger') as mock_logger:
yield mock_logger
def test_get_command_path_success(mock_subprocess_run, mock_logger):
"""Test get_command_path when command is found"""
# Configure the mock to return a valid path
process_mock = MagicMock()
process_mock.stdout = "/usr/bin/ls\n"
process_mock.returncode = 0
mock_subprocess_run.return_value = process_mock
result = unix_manual_server.get_command_path("ls")
assert result == "/usr/bin/ls"
mock_subprocess_run.assert_called_once()
mock_logger.debug.assert_any_call("Searching for command path: ls")
mock_logger.debug.assert_any_call("Found command path: /usr/bin/ls")
def test_get_command_path_not_found(mock_subprocess_run, mock_logger):
"""Test get_command_path when command is not found"""
# Configure the mock to return empty output
process_mock = MagicMock()
process_mock.stdout = "\n"
process_mock.returncode = 1
mock_subprocess_run.return_value = process_mock
result = unix_manual_server.get_command_path("nonexistent-command")
assert result is None
mock_subprocess_run.assert_called_once()
mock_logger.warning.assert_called_with("Command not found: nonexistent-command")
def test_get_command_path_exception(mock_subprocess_run, mock_logger):
"""Test get_command_path when subprocess raises exception"""
# Configure the mock to raise an exception
mock_subprocess_run.side_effect = subprocess.SubprocessError("Test error")
result = unix_manual_server.get_command_path("ls")
assert result is None
mock_subprocess_run.assert_called_once()
mock_logger.error.assert_called_once()
def test_safe_execute_success(mock_subprocess_run, mock_logger):
"""Test safe_execute when command succeeds"""
# Configure the mock to return successful execution
process_mock = MagicMock()
process_mock.stdout = "Success output"
process_mock.returncode = 0
mock_subprocess_run.return_value = process_mock
result = unix_manual_server.safe_execute(["ls", "-l"])
assert result == process_mock
mock_subprocess_run.assert_called_once_with(
["ls", "-l"],
capture_output=True,
text=True,
timeout=10,
shell=False
)
mock_logger.debug.assert_any_call("Executing command: ['ls', '-l'] with timeout=10")
mock_logger.debug.assert_any_call("Command exit code: 0")
mock_logger.debug.assert_any_call("Command stdout first 100 chars: Success output")
def test_safe_execute_timeout(mock_subprocess_run, mock_logger):
"""Test safe_execute when command times out"""
# Configure the mock to raise TimeoutExpired
mock_subprocess_run.side_effect = subprocess.TimeoutExpired(cmd="ls", timeout=10)
result = unix_manual_server.safe_execute(["ls", "-l"])
assert result is None
mock_subprocess_run.assert_called_once()
mock_logger.warning.assert_called_once()
def test_safe_execute_error(mock_subprocess_run, mock_logger):
"""Test safe_execute when command raises error"""
# Configure the mock to raise SubprocessError
mock_subprocess_run.side_effect = subprocess.SubprocessError("Test error")
result = unix_manual_server.safe_execute(["ls", "-l"])
assert result is None
mock_subprocess_run.assert_called_once()
mock_logger.error.assert_called_once()
def test_search_help_documentation_with_h(mock_logger):
"""Test search_help_documentation with -h option"""
with patch('unix_manual_server.safe_execute') as mock_safe_execute:
# First call (--help) returns output that doesn't match help pattern
help_mock1 = MagicMock()
help_mock1.returncode = 0
help_mock1.stdout = "Some output that doesn't look like help"
# Second call (-h) returns valid help that matches pattern
help_mock2 = MagicMock()
help_mock2.returncode = 0
help_mock2.stdout = "Usage: command\nOptions available"
mock_safe_execute.side_effect = [help_mock1, help_mock2]
# We need to patch re.search to control the regex matching behavior
with patch('re.search') as mock_re_search:
# Make the first regex check for --help fail, second for -h succeed
mock_re_search.side_effect = [False, False, True]
result = unix_manual_server.search_help_documentation("command", "/usr/bin/command")
assert "Help output for 'command'" in result
assert "Usage: command" in result
mock_logger.info.assert_called_with("Found help documentation using -h for command")
def test_search_help_documentation_with_help_subcommand(mock_logger):
"""Test search_help_documentation with help subcommand"""
with patch('unix_manual_server.safe_execute') as mock_safe_execute:
# First two calls return outputs that don't match help pattern
help_mock1 = MagicMock()
help_mock1.returncode = 0
help_mock1.stdout = "Some output that doesn't look like help"
help_mock2 = MagicMock()
help_mock2.returncode = 0
help_mock2.stdout = "Another output that doesn't look like help"
# Third call returns valid help
help_mock3 = MagicMock()
help_mock3.returncode = 0
help_mock3.stdout = "Usage: command\nOptions available"
mock_safe_execute.side_effect = [help_mock1, help_mock2, help_mock3]
# We need to patch re.search to control the regex matching behavior
with patch('re.search') as mock_re_search:
# First four regex checks fail (two for --help, two for -h), last one succeeds
mock_re_search.side_effect = [False, False, False, False, True]
result = unix_manual_server.search_help_documentation("command", "/usr/bin/command")
assert "Help output for 'command'" in result
assert "Usage: command" in result
mock_logger.info.assert_called_with("Found help documentation using help subcommand for command")
def test_search_help_documentation_no_help_found(mock_logger):
"""Test search_help_documentation when no help found"""
with patch('unix_manual_server.safe_execute') as mock_safe_execute:
# All calls return no match
help_mock = MagicMock()
help_mock.returncode = 0
help_mock.stdout = "Some output that doesn't look like help"
mock_safe_execute.return_value = help_mock
# Mock regex search to always return False
with patch('re.search') as mock_re_search:
mock_re_search.return_value = False
result = unix_manual_server.search_help_documentation("command", "/usr/bin/command")
assert result == ""
assert mock_safe_execute.call_count == 3
mock_logger.warning.assert_called_with("No help documentation found for command")
def test_get_command_documentation_with_script_path(mock_logger):
"""Test that script paths like 'python script.py' are handled correctly"""
with patch('unix_manual_server.get_command_path') as mock_get_path, \
patch('unix_manual_server.search_help_documentation') as mock_search_help, \
patch('unix_manual_server.safe_execute') as mock_safe_execute:
command = "python script.py"
# Configure mocks
mock_get_path.return_value = "/usr/bin/python"
# We expect it to try the subcommand first (script.py)
mock_safe_execute.return_value = None # subcommand help fails
# Then fall back to main command
mock_search_help.return_value = "Help output for 'python':\n\nPYTHON HELP CONTENT"
result = unix_manual_server.get_command_documentation(command, prefer_economic=True)
assert "Help output for 'python'" in result
mock_logger.debug.assert_any_call("Detected subcommand: 'script.py', will try 'python script.py' first")
@pytest.mark.parametrize(
"command,has_subcommand,subcommand_help_succeeds,expected_result",
[
# Command with subcommand and help for subcommand succeeds
("uv run test.py", True, True, "Help output for 'uv run'"),
# Command with subcommand but help for subcommand fails, fallback to main command
("git commit file.txt", True, False, "Help output for 'git'"),
# Command without subcommand
("ls -la", False, False, "Help output for 'ls'"),
]
)
def test_get_command_documentation_with_subcommand(command, has_subcommand, subcommand_help_succeeds,
expected_result, mock_logger):
"""Test get_command_documentation with subcommands"""
with patch('unix_manual_server.get_command_path') as mock_get_path, \
patch('unix_manual_server.search_help_documentation') as mock_search_help, \
patch('unix_manual_server.safe_execute') as mock_safe_execute:
# Configure mocks
mock_get_path.return_value = "/usr/bin/{}".format(command.split()[0])
if has_subcommand:
if subcommand_help_succeeds:
# Subcommand help succeeds
subcommand_help_mock = MagicMock()
subcommand_help_mock.returncode = 0
subcommand_help_mock.stdout = "SUBCOMMAND HELP CONTENT"
# The first three calls will be for the subcommand with --help, -h, help
# Return success for the first one to simulate --help working
mock_safe_execute.side_effect = [subcommand_help_mock, None, None]
else:
# Subcommand help fails, main command help succeeds
mock_safe_execute.return_value = None
mock_search_help.return_value = f"Help output for '{command.split()[0]}':\n\nMAIN COMMAND HELP CONTENT"
else:
# No subcommand, main command help succeeds
mock_search_help.return_value = f"Help output for '{command.split()[0]}':\n\nMAIN COMMAND HELP CONTENT"
result = unix_manual_server.get_command_documentation(command, prefer_economic=True)
assert expected_result in result
if has_subcommand:
mock_logger.debug.assert_any_call(f"Detected subcommand: '{command.split()[1]}', will try '{command.split()[0]} {command.split()[1]}' first")
@pytest.mark.parametrize(
"command,valid_name,command_exists,prefer_economic,man_section,expected_result",
[
# Valid command, economic approach succeeds
("ls", True, True, True, None, "Help output for 'ls'"),
# Valid command, man approach succeeds
("ls", True, True, False, None, "Manual page for 'ls'"),
# Valid command with man section
("ls", True, True, False, 1, "Manual page for 'ls'"),
# Invalid command name
("invalid;command", False, False, True, None, "Invalid command name: 'invalid;command'"),
# Command not found
("nonexistent", True, False, True, None, "Command not found: 'nonexistent'"),
# Economic approach fails, man succeeds as fallback
("ls-fallback", True, True, True, None, "Manual page for 'ls-fallback'"),
# Both approaches fail
("ls-all-fail", True, True, True, None, "No documentation available for 'ls-all-fail'"),
]
)
def test_get_command_documentation(command, valid_name, command_exists, prefer_economic,
man_section, expected_result, mock_logger):
"""Test get_command_documentation with various scenarios"""
with patch('unix_manual_server.get_command_path') as mock_get_path, \
patch('unix_manual_server.search_help_documentation') as mock_search_help, \
patch('subprocess.run') as mock_run, \
patch('unix_manual_server.safe_execute') as mock_safe_execute:
# Configure mocks based on parameters
if not valid_name:
# Return early due to invalid name check
result = unix_manual_server.get_command_documentation(command, prefer_economic, man_section)
assert "Invalid command name" in result
return
if not command_exists:
mock_get_path.return_value = None
result = unix_manual_server.get_command_documentation(command, prefer_economic, man_section)
assert "Command not found" in result
return
# Command exists
mock_get_path.return_value = f"/usr/bin/{command.split()[0]}"
if command == "ls-fallback":
# Economic approach fails, man succeeds
mock_search_help.return_value = ""
# Mock the man page command success
man_result = MagicMock()
man_result.returncode = 0
man_result.stdout = "MAN PAGE CONTENT"
col_result = MagicMock()
col_result.stdout = "FORMATTED MAN PAGE"
mock_run.side_effect = [man_result, col_result]
# This is key - direct --help should also fail in this scenario
mock_safe_execute.return_value = None
elif command == "ls-all-fail":
# Both approaches fail
mock_search_help.return_value = ""
# Man command fails
man_result = MagicMock()
man_result.returncode = 1
man_result.stderr = "No manual entry"
mock_run.return_value = man_result
# Direct --help fails too
mock_safe_execute.return_value = None
else:
# Standard success case
if prefer_economic:
mock_search_help.return_value = f"Help output for '{command}':\n\nHELP CONTENT"
else:
mock_search_help.return_value = ""
man_result = MagicMock()
man_result.returncode = 0
man_result.stdout = "MAN PAGE CONTENT"
col_result = MagicMock()
col_result.stdout = "FORMATTED MAN PAGE"
mock_run.side_effect = [man_result, col_result]
result = unix_manual_server.get_command_documentation(command, prefer_economic, man_section)
assert expected_result in result
def test_list_common_commands(mock_os_path_exists, mock_os_path_isdir,
mock_os_path_isfile, mock_os_access,
mock_os_listdir, mock_logger):
"""Test list_common_commands"""
result = unix_manual_server.list_common_commands()
assert "Common Unix commands available on this system:" in result
assert "ls" in result
assert "cat" in result
assert "grep" in result
assert "Total commands found:" in result
mock_logger.info.assert_any_call("Listing common commands")
mock_logger.info.assert_any_call("Found 3 unique commands")
def test_list_common_commands_directory_error(mock_os_path_exists, mock_os_path_isdir, mock_logger):
"""Test list_common_commands with error listing directory"""
with patch('os.listdir') as mock_listdir:
mock_listdir.side_effect = OSError("Permission denied")
result = unix_manual_server.list_common_commands()
assert "Common Unix commands available on this system:" in result
assert "Total commands found: 0" in result
mock_logger.error.assert_called()
@pytest.mark.parametrize(
"command,valid_name,command_exists,version_output,expected_result",
[
# Valid command with version info
("ls", True, True, "ls version 8.32", "Command 'ls' exists at /usr/bin/ls.\nVersion information: ls version 8.32"),
# Valid command without version info
("ls-no-version", True, True, None, "Command 'ls-no-version' exists on this system at /usr/bin/ls-no-version."),
# Invalid command name
("invalid;command", False, False, None, "Invalid command name: 'invalid;command'"),
# Command not found
("nonexistent", True, False, None, "Command 'nonexistent' does not exist or is not in the PATH."),
]
)
def test_check_command_exists(command, valid_name, command_exists, version_output,
expected_result, mock_logger):
"""Test check_command_exists with various scenarios"""
with patch('unix_manual_server.get_command_path') as mock_get_path, \
patch('unix_manual_server.safe_execute') as mock_safe_execute:
# Configure mocks based on parameters
if not valid_name:
# Return early due to invalid name check
result = unix_manual_server.check_command_exists(command)
assert "Invalid command name" in result
return
if not command_exists:
mock_get_path.return_value = None
result = unix_manual_server.check_command_exists(command)
assert "does not exist" in result
return
# Command exists
mock_get_path.return_value = f"/usr/bin/{command.split()[0]}"
if version_output:
# Command has version info
version_mock = MagicMock()
version_mock.returncode = 0
version_mock.stdout = version_output
mock_safe_execute.return_value = version_mock
else:
# Command has no version info
version_mock = MagicMock()
version_mock.returncode = 1
version_mock.stdout = ""
mock_safe_execute.return_value = version_mock
result = unix_manual_server.check_command_exists(command)
assert expected_result in result
def test_main_success():
"""Test main function successful execution"""
with patch('unix_manual_server.mcp.run') as mock_run, \
patch('unix_manual_server.logger') as mock_logger:
unix_manual_server.main()
mock_run.assert_called_once()
mock_logger.info.assert_called_with("Starting unix-manual-server")
def test_main_exception():
"""Test main function with exception"""
with patch('unix_manual_server.mcp.run') as mock_run, \
patch('unix_manual_server.logger') as mock_logger:
mock_run.side_effect = Exception("Test error")
unix_manual_server.main()
mock_run.assert_called_once()
mock_logger.critical.assert_called_once()