Clover/tools/test_generation.py
Jarian Cottingham 392bcfa2ef feat: Implement AI agent with multi-turn conversation and tool calling
Major enhancements to Clover CLI:

 New Features:
- AI agent with multi-turn conversation capabilities
- Tool calling system with 11+ tools for file operations, Git, linting, etc.
- Step-by-step AI assistance with play-by-play commentary
- Enhanced interactive mode with better UX

🔧 Core Components Added:
- ai_agent.py: Main AI agent with conversation management
- models/: API client and model management system
- Comprehensive tool system for development tasks

🛠️ Tools Available:
- File operations (create, read, update, delete)
- Command execution with safety checks
- Git operations (status, diff, commit, push)
- Code linting and formatting
- Project structure analysis
- Security scanning and dependency management

💡 User Experience:
- Real-time tool execution summaries
- File creation with full path visibility
- Error handling and retry mechanisms
- Clean conversation flow until task completion

🧹 Repository Cleanup:
- Added comprehensive .gitignore
- Removed __pycache__ directories and build artifacts
- Organized project structure

The AI can now actually create files, run commands, and work through complex
development tasks step-by-step with full transparency.
2026-01-15 01:23:40 -06:00

656 lines
20 KiB
Python

"""
Test generation tools for Clover - A terminal assistant for AI-powered project management
"""
import ast
import inspect
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
# Add the current directory to Python path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config.settings import load_config
from models.api_client import APIClient
from tools.file_tools import create_file, read_file
class TestGenerator:
"""Handle test generation operations with LLM integration"""
def __init__(self):
self.config = load_config()
self.api_client = APIClient()
self.supported_frameworks = {
"python": ["unittest", "pytest", "nose2"],
"javascript": ["jest", "mocha", "jasmine"],
"typescript": ["jest", "mocha", "jasmine"],
"java": ["junit", "testng"],
"csharp": ["nunit", "mstest", "xunit"],
}
def _detect_language_from_file(self, filepath: str) -> str:
"""
Detect programming language from file extension
Args:
filepath (str): Path to the file
Returns:
str: Detected language
"""
extension_map = {
".py": "python",
".js": "javascript",
".ts": "typescript",
".java": "java",
".cs": "csharp",
".cpp": "cpp",
".c": "c",
".go": "go",
".rs": "rust",
".rb": "ruby",
".php": "php",
}
ext = Path(filepath).suffix.lower()
return extension_map.get(ext, "unknown")
def _analyze_python_file(self, filepath: str) -> Dict[str, Any]:
"""
Analyze Python file to extract functions and classes
Args:
filepath (str): Path to Python file
Returns:
Dict containing analysis results
"""
try:
content = read_file(filepath)
tree = ast.parse(content)
functions = []
classes = []
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
# Extract function info
func_info = {
"name": node.name,
"args": [arg.arg for arg in node.args.args],
"line_number": node.lineno,
"docstring": ast.get_docstring(node),
"is_async": isinstance(node, ast.AsyncFunctionDef),
}
functions.append(func_info)
elif isinstance(node, ast.ClassDef):
# Extract class info
methods = []
for item in node.body:
if isinstance(item, ast.FunctionDef):
methods.append(
{
"name": item.name,
"args": [arg.arg for arg in item.args.args],
"is_async": isinstance(item, ast.AsyncFunctionDef),
}
)
class_info = {
"name": node.name,
"line_number": node.lineno,
"docstring": ast.get_docstring(node),
"methods": methods,
"bases": [
base.id if hasattr(base, "id") else str(base)
for base in node.bases
],
}
classes.append(class_info)
elif isinstance(node, (ast.Import, ast.ImportFrom)):
# Extract import info
if isinstance(node, ast.Import):
for alias in node.names:
imports.append(alias.name)
else:
module = node.module or ""
for alias in node.names:
imports.append(f"{module}.{alias.name}")
return {
"functions": functions,
"classes": classes,
"imports": imports,
"language": "python",
}
except Exception as e:
return {
"error": f"Error analyzing Python file: {str(e)}",
"functions": [],
"classes": [],
"imports": [],
"language": "python",
}
def _generate_test_with_llm(
self, file_analysis: Dict[str, Any], filepath: str, framework: str = "pytest"
) -> str:
"""
Generate test code using LLM
Args:
file_analysis (Dict): Analysis of the source file
filepath (str): Path to the source file
framework (str): Testing framework to use
Returns:
str: Generated test code
"""
try:
# Read the original file content
original_content = read_file(filepath)
# Prepare context for the LLM
functions_info = ""
if file_analysis.get("functions"):
functions_info = "Functions to test:\n"
for func in file_analysis["functions"]:
args_str = ", ".join(func["args"])
functions_info += f"- {func['name']}({args_str})\n"
if func["docstring"]:
functions_info += f" Description: {func['docstring']}\n"
classes_info = ""
if file_analysis.get("classes"):
classes_info = "Classes to test:\n"
for cls in file_analysis["classes"]:
classes_info += f"- {cls['name']}\n"
if cls["methods"]:
classes_info += (
" Methods: "
+ ", ".join([m["name"] for m in cls["methods"]])
+ "\n"
)
if cls["docstring"]:
classes_info += f" Description: {cls['docstring']}\n"
prompt = f"""
Generate comprehensive unit tests for the following Python file using {framework}:
File: {filepath}
{functions_info}
{classes_info}
Original code:
```python
{original_content}
```
Please generate tests that:
1. Test all public functions and methods
2. Include edge cases and error conditions
3. Use proper {framework} conventions
4. Include setup and teardown if needed
5. Test both positive and negative scenarios
6. Include docstrings for test methods
7. Use descriptive test names
Format the output as complete, runnable Python test code.
"""
response = self.api_client.generate_text(
prompt=prompt, model=self.config.get("model", "qwen2.5-coder:7b")
)
if "error" in response:
return f"# Error generating tests: {response['error']}"
if "choices" in response and len(response["choices"]) > 0:
return response["choices"][0]["message"]["content"].strip()
else:
return f"# Generated test template for {filepath}"
except Exception as e:
return f"# Error generating tests: {str(e)}"
def generate_tests(
filepath: str, framework: str = None, output_dir: str = None
) -> Dict[str, Any]:
"""
Create unit tests for a file or module using LLM
Args:
filepath (str): Path to the file to generate tests for
framework (str): Testing framework to use (auto-detected if None)
output_dir (str): Directory to save test files (auto-generated if None)
Returns:
Dict containing test generation results
"""
try:
if not os.path.exists(filepath):
return {
"error": f"File {filepath} does not exist",
"test_file": None,
"framework": framework,
}
generator = TestGenerator()
# Detect language
language = generator._detect_language_from_file(filepath)
if language == "unknown":
return {
"error": f"Unsupported file type: {filepath}",
"test_file": None,
"framework": framework,
}
# Auto-detect framework if not specified
if framework is None:
if language == "python":
framework = "pytest" # Default to pytest for Python
elif language in ["javascript", "typescript"]:
framework = "jest" # Default to jest for JS/TS
else:
framework = "default"
# Analyze the source file
if language == "python":
analysis = generator._analyze_python_file(filepath)
else:
# For non-Python files, do basic analysis
content = read_file(filepath)
analysis = {
"language": language,
"content_length": len(content),
"line_count": len(content.splitlines()),
}
# Generate test file path
if output_dir is None:
output_dir = os.path.dirname(filepath) or "."
# Create test directory if it doesn't exist
test_dir = os.path.join(output_dir, "tests")
os.makedirs(test_dir, exist_ok=True)
# Generate test file name
base_name = Path(filepath).stem
if language == "python":
test_filename = f"test_{base_name}.py"
elif language in ["javascript", "typescript"]:
test_filename = f"{base_name}.test.js"
else:
test_filename = f"test_{base_name}.txt"
test_filepath = os.path.join(test_dir, test_filename)
# Generate test content
if language == "python":
test_content = generator._generate_test_with_llm(
analysis, filepath, framework
)
else:
# For other languages, generate basic template
test_content = f"""// Generated test template for {filepath}
// Framework: {framework}
// TODO: Implement tests for this file
describe('{base_name}', () => {{
test('should implement tests', () => {{
// Add your tests here
expect(true).toBe(true);
}});
}});
"""
# Save test file
success = create_file(test_filepath, test_content)
if not success:
return {
"error": f"Failed to create test file: {test_filepath}",
"test_file": None,
"framework": framework,
}
return {
"test_file": test_filepath,
"source_file": filepath,
"framework": framework,
"language": language,
"analysis": analysis,
"message": f"Successfully generated tests for {filepath}",
}
except Exception as e:
return {
"error": f"Error generating tests: {str(e)}",
"test_file": None,
"framework": framework,
}
def test_coverage(
project_path: str = ".", test_framework: str = "pytest"
) -> Dict[str, Any]:
"""
Analyze test coverage for given files
Args:
project_path (str): Path to the project directory
test_framework (str): Testing framework being used
Returns:
Dict containing coverage analysis
"""
try:
# Find all source files and test files
source_files = []
test_files = []
for root, dirs, files in os.walk(project_path):
# Skip common non-source directories
dirs[:] = [
d
for d in dirs
if d not in {".git", "__pycache__", "node_modules", "venv", "env"}
]
for file in files:
filepath = os.path.join(root, file)
if file.endswith(".py"):
if "test_" in file or file.endswith("_test.py"):
test_files.append(filepath)
elif not file.startswith("__") and file != "setup.py":
source_files.append(filepath)
elif file.endswith((".js", ".ts")):
if ".test." in file or ".spec." in file:
test_files.append(filepath)
else:
source_files.append(filepath)
# Analyze coverage
coverage_info = []
uncovered_files = []
for source_file in source_files:
source_name = Path(source_file).stem
# Look for corresponding test file
has_test = False
corresponding_tests = []
for test_file in test_files:
test_name = Path(test_file).stem
# Check if test file corresponds to source file
if (
f"test_{source_name}" in test_name
or f"{source_name}_test" in test_name
or f"{source_name}.test" in test_name
):
has_test = True
corresponding_tests.append(test_file)
if has_test:
coverage_info.append(
{
"source_file": source_file,
"test_files": corresponding_tests,
"has_coverage": True,
}
)
else:
uncovered_files.append(source_file)
coverage_info.append(
{
"source_file": source_file,
"test_files": [],
"has_coverage": False,
}
)
coverage_percentage = (
(len(coverage_info) - len(uncovered_files)) / len(source_files) * 100
if source_files
else 0
)
return {
"total_source_files": len(source_files),
"total_test_files": len(test_files),
"covered_files": len(source_files) - len(uncovered_files),
"uncovered_files": uncovered_files,
"coverage_percentage": round(coverage_percentage, 2),
"coverage_details": coverage_info,
"framework": test_framework,
"project_path": project_path,
}
except Exception as e:
return {
"error": f"Error analyzing test coverage: {str(e)}",
"coverage_percentage": 0,
"total_source_files": 0,
"total_test_files": 0,
}
def generate_test_suite(
project_path: str = ".", framework: str = None
) -> Dict[str, Any]:
"""
Generate tests for an entire project
Args:
project_path (str): Path to the project directory
framework (str): Testing framework to use
Returns:
Dict containing test suite generation results
"""
try:
# Find all source files that need tests
source_files = []
for root, dirs, files in os.walk(project_path):
# Skip common non-source directories
dirs[:] = [
d
for d in dirs
if d not in {".git", "__pycache__", "node_modules", "venv", "env"}
]
for file in files:
if (
file.endswith(".py")
and not file.startswith("__")
and "test_" not in file
):
filepath = os.path.join(root, file)
source_files.append(filepath)
if not source_files:
return {
"error": "No source files found to generate tests for",
"generated_tests": [],
"total_files": 0,
}
# Generate tests for each file
results = []
successful = 0
failed = 0
for source_file in source_files:
print(f"Generating tests for: {source_file}")
result = generate_tests(source_file, framework)
results.append(result)
if "error" not in result:
successful += 1
print(f"✓ Generated: {result.get('test_file')}")
else:
failed += 1
print(f"✗ Failed: {result.get('error')}")
# Generate test runner configuration
test_config = _generate_test_config(project_path, framework or "pytest")
return {
"total_files": len(source_files),
"successful": successful,
"failed": failed,
"generated_tests": results,
"test_config": test_config,
"framework": framework or "pytest",
"project_path": project_path,
}
except Exception as e:
return {
"error": f"Error generating test suite: {str(e)}",
"generated_tests": [],
"total_files": 0,
}
def _generate_test_config(project_path: str, framework: str) -> Dict[str, str]:
"""
Generate test configuration files
Args:
project_path (str): Path to the project
framework (str): Testing framework
Returns:
Dict containing config file contents
"""
configs = {}
if framework == "pytest":
# Generate pytest.ini
pytest_config = """[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
"""
configs["pytest.ini"] = pytest_config
# Generate conftest.py
conftest_config = '''"""
Pytest configuration and fixtures
"""
import pytest
import os
import sys
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture
def sample_data():
"""Provide sample test data"""
return {"test": True}
@pytest.fixture
def temp_file(tmp_path):
"""Create a temporary file for testing"""
test_file = tmp_path / "test_file.txt"
test_file.write_text("test content")
return test_file
'''
configs["tests/conftest.py"] = conftest_config
elif framework == "jest":
# Generate jest.config.js
jest_config = """module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js'
],
coverageDirectory: 'coverage',
verbose: true
};
"""
configs["jest.config.js"] = jest_config
return configs
def run_tests(test_path: str = "tests", framework: str = "pytest") -> Dict[str, Any]:
"""
Run the generated tests and return results
Args:
test_path (str): Path to test directory
framework (str): Testing framework to use
Returns:
Dict containing test execution results
"""
try:
import subprocess
if framework == "pytest":
cmd = ["python", "-m", "pytest", test_path, "-v"]
elif framework == "jest":
cmd = ["npm", "test"]
else:
return {"error": f"Unsupported test framework: {framework}"}
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
)
return {
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"success": result.returncode == 0,
"framework": framework,
}
except subprocess.TimeoutExpired:
return {"error": "Test execution timed out"}
except Exception as e:
return {"error": f"Error running tests: {str(e)}"}
# Example usage
if __name__ == "__main__":
# Example: Generate tests for a specific file
result = generate_tests("example.py", framework="pytest")
print(f"Test generation result: {result}")
# Example: Analyze test coverage
coverage = test_coverage(".", "pytest")
print(f"Test coverage: {coverage['coverage_percentage']}%")