/ scripts / generate-tests.js
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  });