/ src / agents / utils / test-runner.js
test-runner.js
  1  /**
  2   * Test Runner Utility for Agent System
  3   *
  4   * Executes tests, parses results, and extracts coverage information.
  5   * Designed for the QA agent to validate code changes.
  6   */
  7  
  8  import { spawn } from 'child_process';
  9  import { readFileSync, existsSync, readdirSync } from 'fs';
 10  import { join, resolve } from 'path';
 11  import Logger from '../../utils/logger.js';
 12  
 13  const logger = new Logger('TestRunner');
 14  
 15  /**
 16   * Run the full test suite
 17   *
 18   * @param {Object} options - Test options
 19   * @param {string[]} [options.files] - Specific test files to run (empty = all tests)
 20   * @param {boolean} [options.coverage=true] - Generate coverage report
 21   * @param {number} [options.timeout=120000] - Timeout in milliseconds
 22   * @returns {Promise<Object>} Test results
 23   *
 24   * @example
 25   * const results = await runTests({ files: ['tests/logger.test.js'] });
 26   * console.log(results.success, results.stats.pass, results.coverage.total.pct);
 27   */
 28  export async function runTests(options = {}) {
 29    const { files = [], coverage = true, timeout = 120000 } = options;
 30  
 31    logger.info(`Running tests${files.length ? ` for ${files.length} files` : ' (full suite)'}`);
 32  
 33    const startTime = Date.now();
 34  
 35    try {
 36      const { stdout, stderr, exitCode } = await executeTestCommand(files, coverage, timeout);
 37  
 38      const results = parseTestOutput(stdout);
 39      const coverageData = coverage ? parseCoverageReport() : null;
 40  
 41      const duration = Date.now() - startTime;
 42  
 43      return {
 44        success: exitCode === 0 && results.stats.fail === 0,
 45        exitCode,
 46        duration,
 47        output: stdout,
 48        error: stderr,
 49        stats: results.stats,
 50        failures: results.failures,
 51        coverage: coverageData,
 52        timestamp: new Date().toISOString(),
 53      };
 54    } catch (error) {
 55      logger.error('Test execution failed', { error: error.message });
 56      return {
 57        success: false,
 58        exitCode: 1,
 59        duration: Date.now() - startTime,
 60        output: '',
 61        error: error.message,
 62        stats: { tests: 0, pass: 0, fail: 0, skipped: 0 },
 63        failures: [],
 64        coverage: null,
 65        timestamp: new Date().toISOString(),
 66      };
 67    }
 68  }
 69  
 70  /**
 71   * Run tests for a specific source file
 72   * Automatically finds corresponding test file(s)
 73   *
 74   * @param {string} sourceFile - Path to source file (e.g., 'src/utils/logger.js')
 75   * @returns {Promise<Object>} Test results
 76   *
 77   * @example
 78   * const results = await runTestsForFile('src/utils/logger.js');
 79   * // Runs tests/logger.test.js
 80   */
 81  export async function runTestsForFile(sourceFile) {
 82    const testFiles = findTestFilesForSource(sourceFile);
 83  
 84    if (testFiles.length === 0) {
 85      logger.warn(`No test files found for ${sourceFile}`);
 86      return {
 87        success: false,
 88        error: `No test files found for ${sourceFile}`,
 89        stats: { tests: 0, pass: 0, fail: 0, skipped: 0 },
 90        failures: [],
 91        coverage: null,
 92      };
 93    }
 94  
 95    logger.info(`Found ${testFiles.length} test file(s) for ${sourceFile}`);
 96    return runTests({ files: testFiles, coverage: true });
 97  }
 98  
 99  /**
100   * Get coverage information for specific files
101   *
102   * @param {string[]} files - Source files to get coverage for
103   * @returns {Object|null} Coverage data for specified files
104   *
105   * @example
106   * const coverage = getCoverageForFiles(['src/utils/logger.js']);
107   * console.log(coverage['src/utils/logger.js'].lines.pct);
108   */
109  export function getCoverageForFiles(files) {
110    const coverageData = parseCoverageReport();
111    if (!coverageData) {
112      return null;
113    }
114  
115    const result = {};
116    const projectRoot = process.cwd();
117  
118    for (const file of files) {
119      const absolutePath = resolve(projectRoot, file);
120      if (coverageData[absolutePath]) {
121        result[file] = coverageData[absolutePath];
122      }
123    }
124  
125    return Object.keys(result).length > 0 ? result : null;
126  }
127  
128  /**
129   * Parse test output from Node.js test runner (TAP format)
130   *
131   * @param {string} output - Raw test output
132   * @returns {Object} Parsed results
133   *
134   * @example
135   * const results = parseTestOutput(stdout);
136   * console.log(results.stats.pass, results.failures);
137   */
138  export function parseTestOutput(output) {
139    const stats = {
140      tests: 0,
141      suites: 0,
142      pass: 0,
143      fail: 0,
144      cancelled: 0,
145      skipped: 0,
146      todo: 0,
147      duration_ms: 0,
148    };
149  
150    const failures = [];
151  
152    // Parse summary lines (e.g., "# tests 20", "# pass 20", "# fail 0")
153    const summaryPattern = /^# (\w+) (\d+)$/gm;
154    let match;
155  
156    while ((match = summaryPattern.exec(output)) !== null) {
157      const [, key, value] = match;
158      if (Object.prototype.hasOwnProperty.call(stats, key)) {
159        stats[key] = parseInt(value, 10);
160      }
161    }
162  
163    // Parse duration (e.g., "# duration_ms 758.972608")
164    const durationMatch = output.match(/^# duration_ms ([\d.]+)$/m);
165    if (durationMatch) {
166      stats.duration_ms = parseFloat(durationMatch[1]);
167    }
168  
169    // Parse failed tests (indented ones are subtests, non-indented are suite-level)
170    // We want subtests for more specific information
171    const failPattern = /^ {4}not ok \d+ - (.+)$/gm;
172    while ((match = failPattern.exec(output)) !== null) {
173      failures.push({
174        name: match[1],
175        message: extractFailureMessage(output, match.index),
176      });
177    }
178  
179    // If no indented failures found, fall back to top-level failures
180    if (failures.length === 0 && stats.fail > 0) {
181      const topLevelFailPattern = /^not ok \d+ - (.+)$/gm;
182      while ((match = topLevelFailPattern.exec(output)) !== null) {
183        failures.push({
184          name: match[1],
185          message: extractFailureMessage(output, match.index),
186        });
187      }
188    }
189  
190    return { stats, failures };
191  }
192  
193  /**
194   * Identify uncovered lines in a specific file
195   *
196   * @param {string} file - Path to source file
197   * @returns {number[]|null} Array of uncovered line numbers, or null if no coverage data
198   *
199   * @example
200   * const uncovered = identifyUncoveredLines('src/utils/logger.js');
201   * console.log(`Lines ${uncovered.join(', ')} are not covered`);
202   */
203  export function identifyUncoveredLines(file) {
204    const coverageDir = process.env.COVERAGE_DIR || join(process.cwd(), 'coverage');
205    const coverageFinalPath = join(coverageDir, 'coverage-final.json');
206  
207    if (!existsSync(coverageFinalPath)) {
208      logger.warn('No coverage-final.json found. Run tests with coverage first.');
209      return null;
210    }
211  
212    try {
213      const coverageData = JSON.parse(readFileSync(coverageFinalPath, 'utf8'));
214      const absolutePath = resolve(process.cwd(), file);
215  
216      const fileData = coverageData[absolutePath];
217      if (!fileData) {
218        logger.warn(`No coverage data found for ${file}`);
219        return null;
220      }
221  
222      // Extract uncovered lines from statement map
223      const uncoveredLines = [];
224      const { statementMap, s } = fileData;
225  
226      for (const [id, count] of Object.entries(s)) {
227        if (count === 0 && statementMap[id]) {
228          const { start } = statementMap[id];
229          if (!uncoveredLines.includes(start.line)) {
230            uncoveredLines.push(start.line);
231          }
232        }
233      }
234  
235      return uncoveredLines.sort((a, b) => a - b);
236    } catch (error) {
237      logger.error('Failed to parse coverage data', { error: error.message });
238      return null;
239    }
240  }
241  
242  /**
243   * Parse c8 coverage report
244   *
245   * @returns {Object|null} Coverage data or null if not found
246   *
247   * @example
248   * const coverage = parseCoverageReport();
249   * console.log(coverage.total.lines.pct);
250   */
251  export function parseCoverageReport() {
252    const coverageDir = process.env.COVERAGE_DIR || join(process.cwd(), 'coverage');
253    const summaryPath = join(coverageDir, 'coverage-summary.json');
254  
255    if (!existsSync(summaryPath)) {
256      logger.warn('No coverage-summary.json found. Run tests with coverage first.');
257      return null;
258    }
259  
260    try {
261      const data = JSON.parse(readFileSync(summaryPath, 'utf8'));
262      return data;
263    } catch (error) {
264      logger.error('Failed to parse coverage summary', { error: error.message });
265      return null;
266    }
267  }
268  
269  /**
270   * Execute test command via child_process
271   * @private
272   */
273  async function executeTestCommand(files, coverage, timeout) {
274    return new Promise((resolve, reject) => {
275      const args = ['--experimental-test-module-mocks', '--test'];
276  
277      // Add test files
278      if (files.length > 0) {
279        args.push(...files);
280      } else {
281        // Run all tests (use the full list from package.json)
282        const allTestFiles = getAllTestFiles();
283        args.push(...allTestFiles);
284      }
285  
286      // Wrap with c8 if coverage is enabled
287      const command = coverage ? 'npx' : 'node';
288      const finalArgs = coverage
289        ? [
290            'c8',
291            '--reporter=html',
292            '--reporter=text',
293            '--reporter=json-summary',
294            '--reporter=json',
295            'node',
296            ...args,
297          ]
298        : args;
299  
300      logger.debug(`Executing: ${command} ${finalArgs.join(' ')}`);
301  
302      const child = spawn('nice', ['-n', '19', command, ...finalArgs], {
303        cwd: process.cwd(),
304        env: { ...process.env, NODE_ENV: 'test' },
305        stdio: ['ignore', 'pipe', 'pipe'],
306      });
307  
308      let stdout = '';
309      let stderr = '';
310  
311      child.stdout.on('data', data => {
312        stdout += data.toString();
313      });
314  
315      child.stderr.on('data', data => {
316        stderr += data.toString();
317      });
318  
319      const timer = setTimeout(() => {
320        child.kill('SIGTERM');
321        reject(new Error(`Test execution timeout after ${timeout}ms`));
322      }, timeout);
323  
324      child.on('close', code => {
325        clearTimeout(timer);
326        resolve({ stdout, stderr, exitCode: code });
327      });
328  
329      child.on('error', error => {
330        clearTimeout(timer);
331        reject(error);
332      });
333    });
334  }
335  
336  /**
337   * Find test files corresponding to a source file
338   * @private
339   */
340  function findTestFilesForSource(sourceFile) {
341    const testFiles = [];
342    const baseName = sourceFile
343      .replace(/^src\//, '')
344      .replace(/\.js$/, '')
345      .split('/')
346      .pop();
347  
348    // Check for common test file patterns
349    const patterns = [
350      `tests/${baseName}.test.js`,
351      `tests/${baseName}.unit.test.js`,
352      `tests/${baseName}.integration.test.js`,
353      `tests/${baseName}-mocked.test.js`,
354    ];
355  
356    for (const pattern of patterns) {
357      const fullPath = join(process.cwd(), pattern);
358      if (existsSync(fullPath)) {
359        testFiles.push(pattern);
360      }
361    }
362  
363    return testFiles;
364  }
365  
366  /**
367   * Get all test files from tests/ directory
368   * @private
369   */
370  function getAllTestFiles() {
371    // This matches the test files from package.json "test" script
372    const testsDir = join(process.cwd(), 'tests');
373  
374    if (!existsSync(testsDir)) {
375      return [];
376    }
377  
378    return readdirSync(testsDir)
379      .filter(file => file.endsWith('.test.js'))
380      .map(file => join('tests', file));
381  }
382  
383  /**
384   * Extract failure message from TAP output
385   * @private
386   */
387  function extractFailureMessage(output, startIndex) {
388    // Look for error details after the "not ok" line
389    const remainingOutput = output.slice(startIndex);
390    const errorMatch = remainingOutput.match(/Error: (.+?)(?:\n|$)/);
391  
392    if (errorMatch) {
393      return errorMatch[1];
394    }
395  
396    // Look for assertion failures
397    const assertMatch = remainingOutput.match(/Expected: (.+?)\nReceived: (.+?)(?:\n|$)/s);
398    if (assertMatch) {
399      return `Expected: ${assertMatch[1]}, Received: ${assertMatch[2]}`;
400    }
401  
402    return 'Unknown error';
403  }
404  
405  export default {
406    runTests,
407    runTestsForFile,
408    getCoverageForFiles,
409    parseTestOutput,
410    identifyUncoveredLines,
411    parseCoverageReport,
412  };