generate-tests.js
1 #!/usr/bin/env node 2 3 /** 4 * Automated Test Generation 5 * 6 * Uses Claude CLI to generate unit tests for files with low/no coverage. 7 * 8 * Process: 9 * 1. Read coverage report to find files below target (80%) 10 * 2. For each file, read source code 11 * 3. Use Claude CLI to generate appropriate tests 12 * 4. Write test file 13 * 5. Run tests to verify they work 14 * 6. Keep passing tests, discard failing ones 15 */ 16 17 import { execSync, execFileSync } from 'child_process'; 18 import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; 19 import { join, basename, dirname } from 'path'; 20 import { fileURLToPath } from 'url'; 21 22 const __filename = fileURLToPath(import.meta.url); 23 const __dirname = dirname(__filename); 24 const projectRoot = join(__dirname, '..'); 25 26 // Configuration 27 const MODEL = process.env.TEST_GEN_MODEL || 'sonnet'; 28 const COVERAGE_TARGET = parseInt(process.env.COVERAGE_TARGET || '80', 10); 29 const MAX_FILES = parseInt(process.env.MAX_TEST_FILES || '5', 10); 30 31 console.log('Automated Test Generation\n'); 32 console.log(`Model: ${MODEL}`); 33 console.log(`Coverage Target: ${COVERAGE_TARGET}%`); 34 console.log(`Max Files: ${MAX_FILES}\n`); 35 36 // Validate claude CLI is available 37 try { 38 execSync('which claude', { stdio: 'ignore' }); 39 } catch { 40 console.error('Error: claude CLI not found in PATH'); 41 process.exit(1); 42 } 43 44 const stats = { 45 filesAnalyzed: 0, 46 testsGenerated: 0, 47 testsPassed: 0, 48 testsFailed: 0, 49 testsCreated: [], 50 }; 51 52 /** 53 * Get files with low coverage 54 */ 55 function getLowCoverageFiles() { 56 const coveragePath = join(projectRoot, 'coverage', 'coverage-summary.json'); 57 58 if (!existsSync(coveragePath)) { 59 console.error('โ No coverage report found. Run `npm test` first.'); 60 process.exit(1); 61 } 62 63 const coverage = JSON.parse(readFileSync(coveragePath, 'utf-8')); 64 const lowCoverageFiles = []; 65 66 for (const [filePath, metrics] of Object.entries(coverage)) { 67 // Skip total summary 68 if (filePath === 'total') continue; 69 70 const lineCoverage = metrics.lines.pct; 71 72 // Only include files below target that are in src/ (not tests/) 73 if ( 74 lineCoverage < COVERAGE_TARGET && 75 filePath.includes('/src/') && 76 !filePath.includes('.test.') 77 ) { 78 lowCoverageFiles.push({ 79 path: filePath, 80 coverage: lineCoverage, 81 lines: metrics.lines, 82 }); 83 } 84 } 85 86 // Sort by lowest coverage first 87 lowCoverageFiles.sort((a, b) => a.coverage - b.coverage); 88 89 return lowCoverageFiles.slice(0, MAX_FILES); 90 } 91 92 /** 93 * Generate test for a file using Claude API 94 */ 95 async function generateTest(file) { 96 console.log(`\n๐ Generating tests for ${file.path.replace(projectRoot, '.')}`); 97 console.log(` Current coverage: ${file.coverage.toFixed(1)}%`); 98 99 // Read source file 100 const sourceCode = readFileSync(file.path, 'utf-8'); 101 102 // Determine test file path 103 const relativePath = file.path.replace(projectRoot, '.'); 104 const testPath = relativePath.replace('/src/', '/tests/').replace('.js', '.test.js'); 105 const testFilePath = join(projectRoot, testPath); 106 107 // Check if test file already exists 108 let existingTests = ''; 109 if (existsSync(testFilePath)) { 110 existingTests = readFileSync(testFilePath, 'utf-8'); 111 } 112 113 // Build prompt for Claude 114 const prompt = `You are a Node.js testing expert. Generate comprehensive unit tests for this file. 115 116 FILE: ${relativePath} 117 CURRENT COVERAGE: ${file.coverage.toFixed(1)}% 118 COVERAGE TARGET: ${COVERAGE_TARGET}% 119 120 SOURCE CODE: 121 \`\`\`javascript 122 ${sourceCode} 123 \`\`\` 124 125 ${existingTests ? `EXISTING TESTS (enhance these, don't replace):\n\`\`\`javascript\n${existingTests}\n\`\`\`` : ''} 126 127 REQUIREMENTS: 128 - Use Node.js native test runner (node:test, node:assert) 129 - Import functions/classes from source file using ESM syntax 130 - Mock external dependencies (database, API calls, file I/O) 131 - Test both success and error paths 132 - Aim for ${COVERAGE_TARGET}%+ coverage 133 - Follow existing test patterns in the codebase 134 - Include edge cases and boundary conditions 135 ${existingTests ? '- ADD tests to existing file, do NOT replace existing tests' : ''} 136 - Use descriptive test names 137 - No comments explaining what you added 138 139 OUTPUT THE COMPLETE TEST FILE (${existingTests ? 'including existing tests' : 'new file'}):`; 140 141 try { 142 const testCode = execFileSync('claude', ['-p', '--model', MODEL, '--output-format', 'text'], { 143 input: prompt, 144 encoding: 'utf-8', 145 timeout: 120000, 146 maxBuffer: 10 * 1024 * 1024, 147 }).trim(); 148 149 // Basic validation 150 if (!testCode.includes('import') || !testCode.includes('test(')) { 151 console.error(' โ Generated code does not look like tests - skipping'); 152 stats.testsFailed++; 153 return false; 154 } 155 156 // Write test file 157 const testDir = dirname(testFilePath); 158 if (!existsSync(testDir)) { 159 mkdirSync(testDir, { recursive: true }); 160 } 161 162 writeFileSync(testFilePath, testCode); 163 console.log(` โ Test file written: ${testPath}`); 164 stats.testsGenerated++; 165 166 // Run the tests to verify they work 167 console.log(' ๐งช Running tests to verify...'); 168 try { 169 execSync(`npm test ${testFilePath}`, { 170 cwd: projectRoot, 171 stdio: 'inherit', 172 }); 173 174 console.log(` โ Tests passed!`); 175 stats.testsPassed++; 176 stats.testsCreated.push(testPath); 177 return true; 178 } catch (error) { 179 console.error(` โ Tests failed - discarding generated tests`); 180 // Restore original file if it existed 181 if (existingTests) { 182 writeFileSync(testFilePath, existingTests); 183 } else { 184 // Delete the bad test file 185 execSync(`rm ${testFilePath}`, { cwd: projectRoot }); 186 } 187 stats.testsFailed++; 188 return false; 189 } 190 } catch (error) { 191 console.error(` โ Claude API error: ${error.message}`); 192 stats.testsFailed++; 193 return false; 194 } 195 } 196 197 /** 198 * Main execution 199 */ 200 async function main() { 201 // Get files with low coverage 202 console.log('๐ Analyzing coverage report...\n'); 203 const lowCoverageFiles = getLowCoverageFiles(); 204 205 if (lowCoverageFiles.length === 0) { 206 console.log('โ All files meet coverage target!'); 207 return; 208 } 209 210 console.log(`Found ${lowCoverageFiles.length} files below ${COVERAGE_TARGET}% coverage:`); 211 lowCoverageFiles.forEach(file => { 212 console.log(` - ${file.path.replace(projectRoot, '.')} (${file.coverage.toFixed(1)}%)`); 213 }); 214 215 // Generate tests 216 for (const file of lowCoverageFiles) { 217 await generateTest(file); 218 stats.filesAnalyzed++; 219 } 220 221 // Print summary 222 console.log(`\n${'โ'.repeat(60)}`); 223 console.log('\n๐ Test Generation Summary\n'); 224 console.log('โ'.repeat(60)); 225 console.log(`Files Analyzed: ${stats.filesAnalyzed}`); 226 console.log(`Tests Generated: ${stats.testsGenerated}`); 227 console.log(`Tests Passed: ${stats.testsPassed}`); 228 console.log(`Tests Failed: ${stats.testsFailed}`); 229 230 if (stats.testsCreated.length > 0) { 231 console.log('\nTests Created:'); 232 stats.testsCreated.forEach(test => console.log(` โ ${test}`)); 233 } 234 235 console.log(`\n${'โ'.repeat(60)}\n`); 236 237 // Run full test suite to see new coverage 238 if (stats.testsPassed > 0) { 239 console.log('๐งช Running full test suite to calculate new coverage...\n'); 240 try { 241 execSync('npm test', { 242 cwd: projectRoot, 243 stdio: 'inherit', 244 }); 245 } catch { 246 console.log('โ ๏ธ Some tests failed - review before committing'); 247 } 248 } 249 250 process.exit(stats.testsPassed > 0 ? 0 : 1); 251 } 252 253 // Run 254 main().catch(error => { 255 console.error('โ Fatal error:', error); 256 process.exit(1); 257 });