Files
zeekay 6db5cf2d7e feat: Add GitHub Actions workflow for Docker builds
- Add docker-publish.yml workflow for ghcr.io
- Add production-ready Dockerfile (if missing)
- Enable automated builds on push to main
- Support for semantic versioning tags
2025-07-23 23:06:26 -05:00

627 lines
21 KiB
Python

"""Comprehensive test suite for search functionality."""
import asyncio
import os
import tempfile
import time
from pathlib import Path
from typing import List, Dict, Any
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.tools.common.permissions import PermissionManager
from hanzo_mcp.tools.filesystem.search_tool import (
SearchTool,
SearchResult,
SearchType
)
class TestSearchTool:
"""Test suite for the SearchTool."""
@pytest.fixture
def permission_manager(self):
"""Create a permission manager for testing."""
pm = PermissionManager()
pm.add_allowed_path("/tmp")
pm.add_allowed_path(".")
return pm
@pytest.fixture
def mock_project_manager(self):
"""Create a mock project manager."""
mock_pm = MagicMock()
mock_pm.search_all_projects = AsyncMock(return_value={})
mock_pm.get_project_for_path = MagicMock(return_value=None)
mock_pm._get_global_store = MagicMock()
mock_pm.projects = {}
return mock_pm
@pytest.fixture
def search_tool(self, permission_manager, mock_project_manager):
"""Create a search tool instance."""
return SearchTool(permission_manager, mock_project_manager)
@pytest.fixture
def mock_context(self):
"""Create a mock MCP context."""
ctx = MagicMock()
ctx.meta = MagicMock()
return ctx
@pytest.fixture
def test_files(self):
"""Create temporary test files."""
with tempfile.TemporaryDirectory() as tmpdir:
test_dir = Path(tmpdir)
# Create test Python file
python_file = test_dir / "test_module.py"
python_file.write_text("""
def hello_world():
'''Say hello to the world.'''
print("Hello, world!")
return "greeting"
class TestClass:
'''A test class for demonstration.'''
def test_method(self):
'''Test method with error handling.'''
try:
result = hello_world()
return result
except Exception as e:
print(f"Error occurred: {e}")
raise
def complex_function(data, options=None):
'''Complex function for testing search.'''
if options is None:
options = {}
# Process data with error handling
processed = []
for item in data:
try:
processed.append(item.upper())
except AttributeError:
processed.append(str(item))
return processed
""")
# Create test JavaScript file
js_file = test_dir / "test_script.js"
js_file.write_text("""
function helloWorld() {
console.log("Hello, world!");
return "greeting";
}
class TestClass {
constructor() {
this.name = "test";
}
testMethod() {
try {
return helloWorld();
} catch (error) {
console.error("Error occurred:", error);
throw error;
}
}
}
function complexFunction(data, options = {}) {
const processed = data.map(item => {
try {
return item.toUpperCase();
} catch (error) {
return String(item);
}
});
return processed;
}
""")
# Create test documentation file
md_file = test_dir / "README.md"
md_file.write_text("""
# Test Project
This is a test project for demonstrating search capabilities.
## Features
- Hello world functionality
- Error handling
- Complex data processing
- Multi-language support
## Usage
```python
from test_module import hello_world
result = hello_world()
```
## Error Handling
The project includes comprehensive error handling throughout.
""")
yield {
"dir": test_dir,
"python": python_file,
"javascript": js_file,
"markdown": md_file
}
def test_search_intent_detection(self, search_tool):
"""Test the search intent detection logic."""
# Regex pattern should disable vector search
use_vector, use_ast, use_symbol = search_tool._detect_search_intent(".*error")
assert not use_vector
assert use_ast
assert use_symbol
# Function name should enable symbol and AST
use_vector, use_ast, use_symbol = search_tool._detect_search_intent("hello_world")
assert use_vector # Could be semantic
assert use_ast
assert use_symbol
# Natural language should enable vector search
use_vector, use_ast, use_symbol = search_tool._detect_search_intent("error handling functionality")
assert use_vector
assert use_ast
assert use_symbol
@pytest.mark.asyncio
async def test_grep_search(self, search_tool, test_files, mock_context):
"""Test grep search functionality."""
tool_ctx = MagicMock()
tool_ctx.info = AsyncMock()
tool_ctx.error = AsyncMock()
tool_ctx.mcp_context = mock_context
with patch.object(search_tool, 'create_tool_context', return_value=tool_ctx):
with patch.object(search_tool.grep_tool, 'call') as mock_grep:
mock_grep.return_value = """Found 2 matches in 1 file:
test_module.py:3: def hello_world():
test_module.py:15: result = hello_world()"""
results = await search_tool._run_grep_search(
"hello_world", str(test_files["dir"]), "*", tool_ctx, 10
)
assert len(results) == 2
assert all(r.search_type == SearchType.GREP for r in results)
assert results[0].file_path == "test_module.py"
assert results[0].line_number == 3
assert "def hello_world():" in results[0].content
@pytest.mark.asyncio
async def test_ast_search(self, search_tool, test_files, mock_context):
"""Test AST search functionality."""
tool_ctx = MagicMock()
tool_ctx.info = AsyncMock()
tool_ctx.error = AsyncMock()
tool_ctx.mcp_context = mock_context
with patch.object(search_tool, 'create_tool_context', return_value=tool_ctx):
with patch.object(search_tool.grep_ast_tool, 'call') as mock_ast:
mock_ast.return_value = f"""
{test_files["python"]}:
3: def hello_world():
4: '''Say hello to the world.'''
5: print("Hello, world!")
6: return "greeting"
"""
results = await search_tool._run_ast_search(
"hello_world", str(test_files["dir"]), "*", tool_ctx, 10
)
assert len(results) > 0
assert all(r.search_type == SearchType.AST for r in results)
@pytest.mark.asyncio
async def test_symbol_search(self, search_tool, test_files, mock_context):
"""Test symbol search functionality."""
tool_ctx = MagicMock()
tool_ctx.info = AsyncMock()
tool_ctx.error = AsyncMock()
with patch.object(search_tool, 'create_tool_context', return_value=tool_ctx):
# Mock the AST analyzer
with patch.object(search_tool.ast_analyzer, 'analyze_file') as mock_analyze:
from hanzo_mcp.tools.vector.ast_analyzer import Symbol, FileAST
# Create mock symbols
symbol = Symbol(
name="hello_world",
type="function",
file_path=str(test_files["python"]),
line_start=3,
line_end=6,
column_start=0,
column_end=20,
scope="global",
signature="hello_world()"
)
mock_ast = FileAST(
file_path=str(test_files["python"]),
file_hash="test_hash",
language="python",
symbols=[symbol],
ast_nodes=[],
imports=[],
exports=[],
dependencies=[]
)
mock_analyze.return_value = mock_ast
results = await search_tool._run_symbol_search(
"hello_world", str(test_files["dir"]), tool_ctx, 10
)
assert len(results) > 0
assert all(r.search_type == SearchType.SYMBOL for r in results)
assert results[0].symbol_info is not None
assert results[0].symbol_info.name == "hello_world"
@pytest.mark.asyncio
async def test_vector_search(self, search_tool, test_files, mock_context):
"""Test vector search functionality."""
tool_ctx = MagicMock()
tool_ctx.info = AsyncMock()
tool_ctx.error = AsyncMock()
tool_ctx.mcp_context = mock_context
# Mock vector tool
search_tool.vector_tool = MagicMock()
search_tool.vector_tool.call = AsyncMock()
search_tool.vector_tool.call.return_value = """Found 1 results for query: 'error handling'
Result 1 (Score: 85.5%) - Project: test
test_module.py [Chunk 0]
Content:
Test method with error handling.
try:
result = hello_world()
return result
except Exception as e:
print(f"Error occurred: {e}")"""
with patch.object(search_tool, 'create_tool_context', return_value=tool_ctx):
results = await search_tool._run_vector_search(
"error handling", str(test_files["dir"]), tool_ctx, 10
)
assert len(results) > 0
assert all(r.search_type == SearchType.VECTOR for r in results)
@pytest.mark.asyncio
async def test_full_search(self, search_tool, test_files, mock_context):
"""Test complete search functionality."""
with patch.object(search_tool, 'validate_path') as mock_validate:
mock_validate.return_value = MagicMock(is_error=False)
with patch.object(search_tool, 'check_path_allowed') as mock_allowed:
mock_allowed.return_value = (True, None)
with patch.object(search_tool, 'check_path_exists') as mock_exists:
mock_exists.return_value = (True, None)
with patch.object(search_tool, 'create_tool_context') as mock_tool_ctx:
tool_ctx = MagicMock()
tool_ctx.info = AsyncMock()
tool_ctx.error = AsyncMock()
tool_ctx.mcp_context = mock_context
mock_tool_ctx.return_value = tool_ctx
# Mock all search methods
with patch.object(search_tool, '_run_grep_search') as mock_grep:
with patch.object(search_tool, '_run_ast_search') as mock_ast:
with patch.object(search_tool, '_run_symbol_search') as mock_symbol:
# Setup mock results
grep_result = SearchResult(
file_path="test.py",
line_number=1,
content="def hello_world():",
search_type=SearchType.GREP,
score=1.0
)
ast_result = SearchResult(
file_path="test.py",
line_number=1,
content="def hello_world():",
search_type=SearchType.AST,
score=0.9,
context="function definition"
)
symbol_result = SearchResult(
file_path="test.py",
line_number=1,
content="function hello_world",
search_type=SearchType.SYMBOL,
score=0.95
)
mock_grep.return_value = [grep_result]
mock_ast.return_value = [ast_result]
mock_symbol.return_value = [symbol_result]
# Execute search
result = await search_tool.call(
mock_context,
pattern="hello_world",
path=str(test_files["dir"]),
max_results=10
)
assert "Unified Search Results" in result
assert "hello_world" in result
assert "Found" in result
def test_result_combination_and_ranking(self, search_tool):
"""Test result combination and ranking logic."""
# Create test results
grep_result = SearchResult(
file_path="test.py",
line_number=1,
content="def hello_world():",
search_type=SearchType.GREP,
score=1.0
)
ast_result = SearchResult(
file_path="test.py",
line_number=1,
content="def hello_world():",
search_type=SearchType.AST,
score=0.9,
context="function definition"
)
symbol_result = SearchResult(
file_path="test.py",
line_number=1,
content="function hello_world",
search_type=SearchType.SYMBOL,
score=0.95
)
vector_result = SearchResult(
file_path="other.py",
line_number=5,
content="call hello_world function",
search_type=SearchType.VECTOR,
score=0.8
)
results_by_type = {
SearchType.GREP: [grep_result],
SearchType.AST: [ast_result],
SearchType.SYMBOL: [symbol_result],
SearchType.VECTOR: [vector_result]
}
combined = search_tool._combine_and_rank_results(results_by_type)
# Should have 2 unique results (3 duplicates merged into 1)
assert len(combined) == 2
# Should be sorted by score (symbol score should win for duplicates)
assert combined[0].score == 0.95 # Symbol result wins
assert combined[1].score == 0.8 # Vector result
def test_search_result_serialization(self):
"""Test SearchResult and UnifiedSearchResults serialization."""
from hanzo_mcp.tools.vector.ast_analyzer import Symbol
symbol = Symbol(
name="test_func",
type="function",
file_path="test.py",
line_start=1,
line_end=5,
column_start=0,
column_end=20,
scope="global"
)
result = SearchResult(
file_path="test.py",
line_number=1,
content="def test_func():",
search_type=SearchType.GREP,
score=1.0,
context="function definition",
symbol_info=symbol
)
# Test serialization
result_dict = result.to_dict()
assert result_dict["file_path"] == "test.py"
assert result_dict["search_type"] == "grep"
assert result_dict["symbol_info"]["name"] == "test_func"
# Test UnifiedSearchResults
unified_results = UnifiedSearchResults(
query="test",
total_results=1,
results_by_type={SearchType.GREP: [result]},
combined_results=[result],
search_time_ms=100.5
)
unified_dict = unified_results.to_dict()
assert unified_dict["query"] == "test"
assert unified_dict["total_results"] == 1
assert unified_dict["search_time_ms"] == 100.5
class TestUnifiedSearchIntegration:
"""Integration tests for search with real file operations."""
@pytest.fixture
def real_test_environment(self):
"""Create a real test environment with actual files."""
with tempfile.TemporaryDirectory() as tmpdir:
test_dir = Path(tmpdir)
# Create a small Python project
(test_dir / "__init__.py").touch()
main_file = test_dir / "main.py"
main_file.write_text("""
#!/usr/bin/env python3
'''Main module for testing search.'''
import logging
from typing import List, Optional
logger = logging.getLogger(__name__)
class DataProcessor:
'''Processes data with error handling.'''
def __init__(self, config: Optional[dict] = None):
self.config = config or {}
logger.info("DataProcessor initialized")
def process_items(self, items: List[str]) -> List[str]:
'''Process a list of items with error handling.'''
processed = []
for item in items:
try:
result = self._process_single_item(item)
processed.append(result)
except ValueError as e:
logger.error(f"Error processing item {item}: {e}")
continue
except Exception as e:
logger.critical(f"Unexpected error: {e}")
raise
return processed
def _process_single_item(self, item: str) -> str:
'''Process a single item.'''
if not item.strip():
raise ValueError("Empty item")
return item.upper().strip()
def main():
'''Main function with comprehensive error handling.'''
try:
processor = DataProcessor()
test_data = ["hello", "world", "", "test"]
results = processor.process_items(test_data)
print(f"Processed {len(results)} items successfully")
return results
except Exception as e:
logger.error(f"Main execution failed: {e}")
return []
if __name__ == "__main__":
main()
""")
utils_file = test_dir / "utils.py"
utils_file.write_text("""
'''Utility functions for the test project.'''
import re
from typing import Union, Optional
def validate_input(data: Union[str, int, float]) -> bool:
'''Validate input data with error handling.'''
try:
if isinstance(data, str):
return bool(data.strip())
elif isinstance(data, (int, float)):
return not (data is None or data != data) # Check for NaN
else:
return False
except Exception:
return False
def format_error_message(error: Exception, context: Optional[str] = None) -> str:
'''Format error messages consistently.'''
base_msg = f"{type(error).__name__}: {str(error)}"
if context:
return f"[{context}] {base_msg}"
return base_msg
def extract_function_names(code: str) -> List[str]:
'''Extract function names from Python code.'''
pattern = r'def\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s*\\('
matches = re.findall(pattern, code)
return matches
""")
yield {
"dir": test_dir,
"main": main_file,
"utils": utils_file
}
@pytest.mark.asyncio
async def test_real_search(self, real_test_environment):
"""Test search on real files."""
permission_manager = PermissionManager()
permission_manager.add_allowed_path(str(real_test_environment["dir"]))
# Test without vector search (no project manager)
unified_tool = SearchTool(permission_manager, None)
# Mock context
ctx = MagicMock()
# Test search for "error handling"
with patch.object(unified_tool, 'validate_path') as mock_validate:
mock_validate.return_value = MagicMock(is_error=False)
with patch.object(unified_tool, 'check_path_allowed') as mock_allowed:
mock_allowed.return_value = (True, None)
with patch.object(unified_tool, 'check_path_exists') as mock_exists:
mock_exists.return_value = (True, None)
result = await unified_tool.call(
ctx,
pattern="error.handling",
path=str(real_test_environment["dir"]),
enable_vector=False, # Disable vector search
max_results=20
)
# Should find multiple instances of error handling
assert "Unified Search Results" in result
assert "error" in result.lower()
if __name__ == "__main__":
# Run tests
pytest.main([__file__, "-v"])