""" 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']}%")