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.
656 lines
20 KiB
Python
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']}%")
|