import { DiffProviderPort, TestGeneratorPort, OutputPort, Diff, CodeBlock } from '../ports'; import { TestSuggestion } from '../entities/TestSuggestion'; import * as fs from 'fs'; export interface TestSuggestionsOptions { readonly baseRef?: string; readonly headRef?: string; readonly includeStaged?: boolean; readonly includeUnstaged?: boolean; readonly filePatterns?: string[]; readonly excludePatterns?: string[]; readonly outputFormat?: 'console' | 'markdown' | 'json' | 'html' | 'file'; readonly outputPath?: string; readonly framework?: 'jest' | 'mocha' | 'vitest' | 'cypress' | 'playwright' | 'generic'; readonly testType?: 'unit' | 'integration' | 'e2e' | 'performance' | 'security'; readonly language?: string; readonly includeSetup?: boolean; readonly includeTeardown?: boolean; readonly coverageTarget?: number; } export interface SuggestTestsUseCase { execute(options?: TestSuggestionsOptions): Promise; } export class SuggestTestsUseCaseImpl implements SuggestTestsUseCase { constructor( private readonly diffProvider: DiffProviderPort, private readonly testGenerator: TestGeneratorPort, private readonly outputPort: OutputPort ) {} async execute(options: TestSuggestionsOptions = {}): Promise { try { // Get diffs const diffs = await this.diffProvider.getDiffs({ baseRef: options.baseRef, headRef: options.headRef, includeStaged: options.includeStaged, includeUnstaged: options.includeUnstaged, filePatterns: options.filePatterns, excludePatterns: options.excludePatterns, }); // Prioritize file patterns when provided for test generation if (options.filePatterns && options.filePatterns.length > 0) { // Create mock diffs for file pattern analysis const mockDiffs: Diff[] = options.filePatterns.map(filePath => { // Read the actual file content let fileContent = ''; try { if (fs.existsSync(filePath)) { fileContent = fs.readFileSync(filePath, 'utf-8'); } } catch (error) { console.warn(`Warning: Could not read file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } return { filePath, language: this.getLanguageFromFile(filePath), hunks: [], linesAdded: 0, linesRemoved: 0, isBinary: false, oldMode: '100644', newMode: '100644', oldFile: filePath, newFile: filePath, oldContent: '', newContent: fileContent, isNewFile: false, isDeletedFile: false, }; }); // Generate test suggestions for mock diffs const allSuggestions: TestSuggestion[] = []; for (const diff of mockDiffs) { const suggestions = await this.generateTestSuggestions(diff, options); allSuggestions.push(...suggestions); } // Output the suggestions await this.outputPort.displayTestSuggestions(allSuggestions, { format: options.outputFormat || 'console', outputPath: options.outputPath, }); return allSuggestions; } else if (diffs.length === 0) { throw new Error('No changes found to suggest tests for'); } // Generate test suggestions for each diff const allSuggestions: TestSuggestion[] = []; for (const diff of diffs) { const suggestions = await this.generateTestSuggestions(diff, options); allSuggestions.push(...suggestions); } // Output the suggestions await this.outputPort.displayTestSuggestions(allSuggestions, { format: options.outputFormat || 'console', outputPath: options.outputPath, }); return allSuggestions; } catch (error) { await this.outputPort.displayError(`Test suggestion generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } private async generateTestSuggestions(diff: Diff, options: TestSuggestionsOptions): Promise { // Skip binary files if (diff.isBinary) { return []; } // Convert diff to code blocks const codeBlocks = this.convertDiffToCodeBlocks(diff); if (codeBlocks.length === 0) { return []; } // Determine the appropriate framework and language based on file type const detectedLanguage = diff.language || this.getLanguageFromFile(diff.filePath); const detectedFramework = this.getFrameworkFromLanguage(detectedLanguage, options.framework); // Generate test suggestions const suggestions = await this.testGenerator.generateTestsForMultiple(codeBlocks, { framework: detectedFramework, testType: options.testType || 'unit', language: detectedLanguage, includeSetup: options.includeSetup, includeTeardown: options.includeTeardown, coverageTarget: options.coverageTarget, }); return suggestions; } private getFrameworkFromLanguage(language: string, userFramework?: string): 'jest' | 'mocha' | 'vitest' | 'cypress' | 'playwright' | 'generic' { // If user specified a framework, validate and use it if (userFramework) { const validFrameworks = ['jest', 'mocha', 'vitest', 'cypress', 'playwright', 'generic']; if (validFrameworks.includes(userFramework)) { return userFramework as 'jest' | 'mocha' | 'vitest' | 'cypress' | 'playwright' | 'generic'; } } // Auto-detect framework based on language (mapped to supported frameworks) switch (language.toLowerCase()) { case 'dart': return 'generic'; // Use generic for Dart since flutter_test isn't supported, but we'll handle it in the prompt case 'typescript': case 'javascript': return 'jest'; case 'python': return 'generic'; // Use generic for Python since pytest isn't supported case 'java': return 'generic'; // Use generic for Java since JUnit isn't supported case 'csharp': return 'generic'; // Use generic for C# since NUnit isn't supported case 'go': return 'generic'; // Use generic for Go since testing isn't supported case 'rust': return 'generic'; // Use generic for Rust since cargo_test isn't supported case 'php': return 'generic'; // Use generic for PHP since PHPUnit isn't supported case 'ruby': return 'generic'; // Use generic for Ruby since RSpec isn't supported case 'swift': return 'generic'; // Use generic for Swift since XCTest isn't supported case 'kotlin': return 'generic'; // Use generic for Kotlin since JUnit isn't supported case 'cpp': case 'c': return 'generic'; // Use generic for C/C++ since GTest isn't supported default: return 'jest'; // Default fallback for web languages } } private getLanguageFromFile(filePath: string): string { const extension = filePath.split('.').pop()?.toLowerCase(); switch (extension) { case 'ts': case 'tsx': return 'typescript'; case 'js': case 'jsx': return 'javascript'; case 'dart': return 'dart'; case 'py': return 'python'; case 'java': return 'java'; case 'cpp': case 'cc': case 'cxx': return 'cpp'; case 'c': return 'c'; case 'cs': return 'csharp'; case 'go': return 'go'; case 'rs': return 'rust'; case 'php': return 'php'; case 'rb': return 'ruby'; case 'swift': return 'swift'; case 'kt': return 'kotlin'; default: return 'text'; } } private convertDiffToCodeBlocks(diff: Diff): CodeBlock[] { const codeBlocks: any[] = []; // Focus on added lines for test generation const addedLines = diff.hunks .flatMap(hunk => hunk.lines.filter(line => line.type === 'added')) .map(line => line.content); if (addedLines.length === 0) { return codeBlocks; } // Group consecutive lines into code blocks let currentBlock = ''; let startLine = 1; for (let i = 0; i < addedLines.length; i++) { const line = addedLines[i]; // Simple heuristic: if line looks like a function/class start, start a new block if (line && this.isBlockStart(line) && currentBlock) { // Save current block codeBlocks.push({ content: currentBlock.trim(), language: diff.language || 'typescript', startLine, endLine: startLine + currentBlock.split('\n').length - 1, filePath: diff.filePath, }); // Start new block currentBlock = line || ''; startLine = i + 1; } else { currentBlock += (currentBlock ? '\n' : '') + (line || ''); } } // Add the last block if (currentBlock) { codeBlocks.push({ content: currentBlock.trim(), language: diff.language, startLine, endLine: startLine + currentBlock.split('\n').length - 1, filePath: diff.filePath, }); } return codeBlocks; } private isBlockStart(line: string): boolean { const trimmed = line.trim(); // Common patterns that indicate the start of a new code block const patterns = [ /^(export\s+)?(async\s+)?function\s+/, /^(export\s+)?class\s+/, /^(export\s+)?interface\s+/, /^(export\s+)?type\s+/, /^(export\s+)?const\s+\w+\s*=\s*\(/, /^(export\s+)?let\s+\w+\s*=\s*\(/, /^(export\s+)?var\s+\w+\s*=\s*\(/, /^@\w+/, // Decorators /^(public|private|protected)\s+/, /^(static\s+)?(async\s+)?\w+\s*\(/, // Method definitions ]; return patterns.some(pattern => pattern.test(trimmed)); } }