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 };