test-runner.test.js
1 /** 2 * Tests for Test Runner Utility (Agent System) 3 * 4 * Tests test execution, coverage parsing, and result analysis 5 */ 6 7 import { test } from 'node:test'; 8 import assert from 'node:assert'; 9 import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; 10 import { join, resolve } from 'path'; 11 import { 12 runTests, 13 runTestsForFile, 14 getCoverageForFiles, 15 parseTestOutput, 16 identifyUncoveredLines, 17 parseCoverageReport, 18 } from '../../src/agents/utils/test-runner.js'; 19 20 // Use a temp directory for coverage data to avoid corrupting the real coverage/tmp/ dir 21 // (the real coverage/tmp/ is where NODE_V8_COVERAGE writes raw V8 data during npm test) 22 const TEMP_COVERAGE_DIR = `/tmp/test-runner-coverage-${process.pid}`; 23 process.env.COVERAGE_DIR = TEMP_COVERAGE_DIR; 24 25 // Helper to create a temporary test file 26 function createTempTestFile(filename, content) { 27 const testsDir = join(process.cwd(), 'tests'); 28 if (!existsSync(testsDir)) { 29 mkdirSync(testsDir, { recursive: true }); 30 } 31 const filePath = join(testsDir, filename); 32 writeFileSync(filePath, content); 33 return filePath; 34 } 35 36 // Helper to create coverage data 37 function createMockCoverage() { 38 const coverageDir = TEMP_COVERAGE_DIR; 39 if (!existsSync(coverageDir)) { 40 mkdirSync(coverageDir, { recursive: true }); 41 } 42 43 const summary = { 44 total: { 45 lines: { total: 100, covered: 85, pct: 85 }, 46 statements: { total: 100, covered: 85, pct: 85 }, 47 functions: { total: 20, covered: 17, pct: 85 }, 48 branches: { total: 40, covered: 35, pct: 87.5 }, 49 }, 50 '/home/jason/code/333Method/src/utils/logger.js': { 51 lines: { total: 50, covered: 45, pct: 90 }, 52 statements: { total: 50, covered: 45, pct: 90 }, 53 functions: { total: 10, covered: 9, pct: 90 }, 54 branches: { total: 20, covered: 18, pct: 90 }, 55 }, 56 }; 57 58 const final = { 59 '/home/jason/code/333Method/src/utils/logger.js': { 60 statementMap: { 61 0: { start: { line: 10, column: 0 } }, 62 1: { start: { line: 15, column: 2 } }, 63 2: { start: { line: 20, column: 4 } }, 64 3: { start: { line: 25, column: 4 } }, 65 }, 66 s: { 67 0: 1, 68 1: 1, 69 2: 0, // Uncovered 70 3: 1, 71 }, 72 }, 73 }; 74 75 writeFileSync(join(coverageDir, 'coverage-summary.json'), JSON.stringify(summary, null, 2)); 76 writeFileSync(join(coverageDir, 'coverage-final.json'), JSON.stringify(final, null, 2)); 77 } 78 79 test('Test Runner - Parse Test Output', async t => { 80 await t.test('should parse successful test output', () => { 81 const output = `TAP version 13 82 # Subtest: Logger Module 83 ok 1 - should log info 84 ok 2 - should log error 85 1..2 86 ok 1 - Logger Module 87 --- 88 duration_ms: 92.116802 89 ... 90 1..1 91 # tests 2 92 # suites 1 93 # pass 2 94 # fail 0 95 # cancelled 0 96 # skipped 0 97 # todo 0 98 # duration_ms 92.116802`; 99 100 const result = parseTestOutput(output); 101 102 assert.strictEqual(result.stats.tests, 2); 103 assert.strictEqual(result.stats.pass, 2); 104 assert.strictEqual(result.stats.fail, 0); 105 assert.strictEqual(result.stats.suites, 1); 106 assert.strictEqual(result.stats.duration_ms, 92.116802); 107 assert.strictEqual(result.failures.length, 0); 108 }); 109 110 await t.test('should parse failed test output', () => { 111 const output = `TAP version 13 112 # Subtest: Math Module 113 ok 1 - should add numbers 114 not ok 2 - should subtract numbers 115 --- 116 duration_ms: 5.0 117 error: |- 118 Error: Expected 5 but got 3 119 ... 120 1..2 121 not ok 1 - Math Module 122 1..1 123 # tests 2 124 # suites 1 125 # pass 1 126 # fail 1 127 # duration_ms 10.0`; 128 129 const result = parseTestOutput(output); 130 131 assert.strictEqual(result.stats.tests, 2); 132 assert.strictEqual(result.stats.pass, 1); 133 assert.strictEqual(result.stats.fail, 1); 134 assert.strictEqual(result.failures.length, 1); 135 assert.strictEqual(result.failures[0].name, 'should subtract numbers'); 136 }); 137 138 await t.test('should handle empty output', () => { 139 const result = parseTestOutput(''); 140 141 assert.strictEqual(result.stats.tests, 0); 142 assert.strictEqual(result.stats.pass, 0); 143 assert.strictEqual(result.stats.fail, 0); 144 assert.strictEqual(result.failures.length, 0); 145 }); 146 }); 147 148 test('Test Runner - Parse Coverage Report', async t => { 149 await t.test('should parse coverage summary', () => { 150 createMockCoverage(); 151 152 const coverage = parseCoverageReport(); 153 154 assert.ok(coverage); 155 assert.ok(coverage.total); 156 assert.strictEqual(coverage.total.lines.pct, 85); 157 assert.strictEqual(coverage.total.functions.pct, 85); 158 assert.ok(coverage['/home/jason/code/333Method/src/utils/logger.js']); 159 160 // Cleanup 161 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 162 }); 163 164 await t.test('should return null if no coverage data exists', () => { 165 // Ensure no coverage directory 166 if (existsSync(TEMP_COVERAGE_DIR)) { 167 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 168 } 169 170 const coverage = parseCoverageReport(); 171 assert.strictEqual(coverage, null); 172 }); 173 }); 174 175 test('Test Runner - Get Coverage for Files', async t => { 176 await t.test('should get coverage for specific files', () => { 177 createMockCoverage(); 178 179 const coverage = getCoverageForFiles(['src/utils/logger.js']); 180 181 assert.ok(coverage); 182 assert.ok(coverage['src/utils/logger.js']); 183 assert.strictEqual(coverage['src/utils/logger.js'].lines.pct, 90); 184 185 // Cleanup 186 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 187 }); 188 189 await t.test('should return null if file not in coverage', () => { 190 createMockCoverage(); 191 192 const coverage = getCoverageForFiles(['src/utils/nonexistent.js']); 193 194 assert.strictEqual(coverage, null); 195 196 // Cleanup 197 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 198 }); 199 200 await t.test('should return null if no coverage data', () => { 201 // Ensure no coverage directory 202 if (existsSync(TEMP_COVERAGE_DIR)) { 203 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 204 } 205 206 const coverage = getCoverageForFiles(['src/utils/logger.js']); 207 assert.strictEqual(coverage, null); 208 }); 209 }); 210 211 test('Test Runner - Identify Uncovered Lines', async t => { 212 await t.test('should identify uncovered lines', () => { 213 createMockCoverage(); 214 215 const uncovered = identifyUncoveredLines('src/utils/logger.js'); 216 217 assert.ok(Array.isArray(uncovered)); 218 assert.strictEqual(uncovered.length, 1); 219 assert.strictEqual(uncovered[0], 20); // Line 20 is uncovered in mock data 220 221 // Cleanup 222 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 223 }); 224 225 await t.test('should return null if no coverage data', () => { 226 // Ensure no coverage directory 227 if (existsSync(TEMP_COVERAGE_DIR)) { 228 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 229 } 230 231 const uncovered = identifyUncoveredLines('src/utils/logger.js'); 232 assert.strictEqual(uncovered, null); 233 }); 234 235 await t.test('should return null if file not in coverage', () => { 236 createMockCoverage(); 237 238 const uncovered = identifyUncoveredLines('src/utils/nonexistent.js'); 239 assert.strictEqual(uncovered, null); 240 241 // Cleanup 242 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 243 }); 244 }); 245 246 // Note: Integration tests (runTests, timeout handling) are verified manually 247 // due to Node.js test runner's recursive test detection when running tests within tests 248 // See standalone test scripts for validation 249 250 test('Test Runner - Run Tests for File', async t => { 251 await t.test('should handle source file with no test', async () => { 252 const result = await runTestsForFile('src/nonexistent-file.js'); 253 254 assert.strictEqual(result.success, false); 255 assert.ok(result.error.includes('No test files found')); 256 }); 257 }); 258 259 // Coverage integration tests are covered by the unit tests above 260 // We don't need to run actual tests since we test parsing with mock data 261 262 // Note: Timeout handling is tested manually via direct script execution 263 // Cannot test within the Node.js test runner due to recursive test detection 264 265 test('Test Runner - parseCoverageReport error handling', async t => { 266 await t.test('should return null when coverage-summary.json is invalid JSON', () => { 267 const coverageDir = TEMP_COVERAGE_DIR; 268 mkdirSync(coverageDir, { recursive: true }); 269 writeFileSync(join(coverageDir, 'coverage-summary.json'), 'INVALID JSON {{{'); 270 271 const result = parseCoverageReport(); 272 assert.strictEqual(result, null, 'Should return null for invalid JSON'); 273 274 rmSync(coverageDir, { recursive: true, force: true }); 275 }); 276 277 await t.test('should return null when coverage directory does not exist', () => { 278 const coverageDir = TEMP_COVERAGE_DIR; 279 if (existsSync(coverageDir)) { 280 rmSync(coverageDir, { recursive: true, force: true }); 281 } 282 283 const result = parseCoverageReport(); 284 assert.strictEqual(result, null); 285 }); 286 }); 287 288 test('Test Runner - identifyUncoveredLines error handling', async t => { 289 await t.test('should return null when coverage-final.json is invalid JSON', () => { 290 const coverageDir = TEMP_COVERAGE_DIR; 291 mkdirSync(coverageDir, { recursive: true }); 292 writeFileSync(join(coverageDir, 'coverage-final.json'), 'INVALID JSON'); 293 294 const result = identifyUncoveredLines('src/utils/logger.js'); 295 assert.strictEqual(result, null, 'Should return null for invalid JSON'); 296 297 rmSync(coverageDir, { recursive: true, force: true }); 298 }); 299 300 await t.test('should return empty array when all lines are covered', () => { 301 const coverageDir = TEMP_COVERAGE_DIR; 302 mkdirSync(coverageDir, { recursive: true }); 303 304 const absolutePath = resolve(process.cwd(), 'src/utils/logger.js'); 305 const finalCoverage = { 306 [absolutePath]: { 307 statementMap: { 308 0: { start: { line: 10, column: 0 } }, 309 1: { start: { line: 15, column: 2 } }, 310 }, 311 s: { 312 0: 5, // covered 313 1: 3, // covered 314 }, 315 }, 316 }; 317 writeFileSync(join(coverageDir, 'coverage-final.json'), JSON.stringify(finalCoverage)); 318 319 const result = identifyUncoveredLines('src/utils/logger.js'); 320 assert.ok(Array.isArray(result)); 321 assert.strictEqual(result.length, 0, 'No uncovered lines when all are hit'); 322 323 rmSync(coverageDir, { recursive: true, force: true }); 324 }); 325 }); 326 327 test('Test Runner - parseTestOutput edge cases', async t => { 328 await t.test('should handle output with assertion failure message', () => { 329 const output = `TAP version 13 330 # Subtest: test suite 331 not ok 1 - should work 332 Expected: true 333 Received: false 334 1..1 335 not ok 1 - test suite 336 1..1 337 # tests 1 338 # pass 0 339 # fail 1 340 # duration_ms 10 341 `; 342 const results = parseTestOutput(output); 343 assert.strictEqual(results.stats.fail, 1); 344 assert.ok(results.failures.length > 0); 345 }); 346 347 await t.test('should handle output with multiple failures', () => { 348 const output = `TAP version 13 349 # tests 3 350 # pass 1 351 # fail 2 352 # duration_ms 50.5 353 not ok 1 - first test 354 not ok 2 - second test 355 `; 356 const results = parseTestOutput(output); 357 assert.strictEqual(results.stats.tests, 3); 358 assert.strictEqual(results.stats.fail, 2); 359 assert.strictEqual(results.stats.pass, 1); 360 }); 361 362 await t.test('should parse duration_ms correctly', () => { 363 const output = `# tests 5 364 # pass 5 365 # fail 0 366 # duration_ms 758.972608 367 `; 368 const results = parseTestOutput(output); 369 assert.ok(Math.abs(results.stats.duration_ms - 758.972608) < 0.001); 370 }); 371 372 await t.test('should handle output with top-level failures when no indented failures', () => { 373 const output = `TAP version 13 374 not ok 1 - top level test failure 375 # tests 1 376 # pass 0 377 # fail 1 378 # duration_ms 5 379 `; 380 const results = parseTestOutput(output); 381 assert.strictEqual(results.stats.fail, 1); 382 assert.ok(results.failures.length > 0, 'Should find top-level failure'); 383 }); 384 385 await t.test('should handle skipped tests', () => { 386 const output = `# tests 5 387 # pass 3 388 # fail 0 389 # skipped 2 390 # duration_ms 100 391 `; 392 const results = parseTestOutput(output); 393 assert.strictEqual(results.stats.skipped, 2); 394 assert.strictEqual(results.stats.pass, 3); 395 }); 396 397 await t.test('should parse cancelled tests', () => { 398 const output = `# tests 5 399 # pass 3 400 # fail 0 401 # cancelled 2 402 # duration_ms 100 403 `; 404 const results = parseTestOutput(output); 405 assert.strictEqual(results.stats.cancelled, 2); 406 }); 407 }); 408 409 test('Test Runner - getCoverageForFiles edge cases', async t => { 410 await t.test('should return null when no coverage data exists', () => { 411 const coverageDir = TEMP_COVERAGE_DIR; 412 if (existsSync(coverageDir)) { 413 rmSync(coverageDir, { recursive: true, force: true }); 414 } 415 416 const result = getCoverageForFiles(['src/utils/logger.js']); 417 assert.strictEqual(result, null); 418 }); 419 420 await t.test('should return null when requested files are not in coverage', () => { 421 createMockCoverage(); 422 423 const result = getCoverageForFiles(['src/nonexistent/file.js']); 424 assert.strictEqual(result, null); 425 426 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 427 }); 428 }); 429 430 // ─── Additional coverage tests using module mocks ──────────────────────────── 431 432 import { mock } from 'node:test'; 433 import { EventEmitter } from 'events'; 434 435 // Test runTests with mocked child_process 436 test('Test Runner - runTests executes and returns results', async t => { 437 await t.test('should handle runTests with specific files', async () => { 438 // Create a tiny real test file that will complete quickly 439 const testContent = ` 440 import { test } from 'node:test'; 441 import assert from 'node:assert/strict'; 442 test('mini test', () => { assert.ok(true); }); 443 `; 444 const testFile = join(process.cwd(), 'tests', 'tmp-mini-coverage.test.js'); 445 writeFileSync(testFile, testContent); 446 447 try { 448 // Run the actual tiny test file - this exercises the runTests code path 449 const result = await runTests({ files: [testFile], coverage: false, timeout: 30000 }); 450 451 assert.ok(typeof result === 'object', 'Should return result object'); 452 assert.ok(typeof result.success === 'boolean', 'Should have success property'); 453 assert.ok(typeof result.exitCode === 'number', 'Should have exitCode'); 454 assert.ok(typeof result.duration === 'number', 'Should have duration'); 455 assert.ok(typeof result.output === 'string', 'Should have output'); 456 assert.ok(typeof result.stats === 'object', 'Should have stats'); 457 assert.ok(Array.isArray(result.failures), 'Should have failures array'); 458 assert.ok(typeof result.timestamp === 'string', 'Should have timestamp'); 459 } finally { 460 try { 461 rmSync(testFile); 462 } catch { 463 /* ignore */ 464 } 465 } 466 }); 467 468 await t.test('should return failure result when test command fails', async () => { 469 // Run a test file that doesn't exist - should fail gracefully 470 const result = await runTests({ 471 files: ['/nonexistent/test/file.js'], 472 coverage: false, 473 timeout: 15000, 474 }); 475 assert.ok(typeof result === 'object', 'Should return result object even on failure'); 476 assert.ok(typeof result.success === 'boolean'); 477 assert.strictEqual(result.success, false, 'Should fail for nonexistent file'); 478 }); 479 }); 480 481 test('Test Runner - runTestsForFile with existing test file', async t => { 482 await t.test('should find and run test when test file exists', async () => { 483 // logger.test.js should have a corresponding tests/logger.test.js 484 // This exercises the findTestFilesForSource success path 485 const result = await runTestsForFile('src/utils/logger.js'); 486 487 // Either it finds a test file and runs it, or it doesn't find one 488 assert.ok(typeof result === 'object', 'Should return result object'); 489 // If found, should have run tests 490 if (result.error && result.error.includes('No test files found')) { 491 // Expected when logger.test.js doesn't exist at tests/logger.test.js 492 assert.ok(true, 'Correctly reports no test files found'); 493 } else { 494 assert.ok(typeof result.success === 'boolean'); 495 } 496 }); 497 }); 498 499 test('Test Runner - getAllTestFiles (via runTests with no files)', async t => { 500 await t.test('should use getAllTestFiles when no files specified', async () => { 501 // runTests with empty files array calls getAllTestFiles 502 // We can verify this by checking a very short timeout that still invokes the path 503 // Actually to avoid running ALL tests, we'll just check that it handles the error gracefully 504 505 // Create the tests directory just to verify it exists 506 const testsDir = join(process.cwd(), 'tests'); 507 assert.ok(existsSync(testsDir), 'tests directory should exist'); 508 509 // We can verify getAllTestFiles is working by running runTests and seeing it starts 510 // without error (even if we kill it quickly via short timeout) 511 const result = await runTests({ files: [], coverage: false, timeout: 5000 }); 512 // This will likely timeout or fail but tests the code path 513 assert.ok(typeof result === 'object', 'Should return object even on timeout'); 514 }); 515 }); 516 517 test('Test Runner - identifyUncoveredLines (additional edge cases)', async t => { 518 await t.test('should handle coverage with multiple uncovered lines', () => { 519 const coverageDir = TEMP_COVERAGE_DIR; 520 mkdirSync(coverageDir, { recursive: true }); 521 522 const absolutePath = resolve(process.cwd(), 'src/agents/monitor.js'); 523 const finalCoverage = { 524 [absolutePath]: { 525 statementMap: { 526 0: { start: { line: 10, column: 0 } }, 527 1: { start: { line: 15, column: 2 } }, 528 2: { start: { line: 20, column: 4 } }, 529 3: { start: { line: 25, column: 4 } }, 530 4: { start: { line: 30, column: 6 } }, 531 }, 532 s: { 533 0: 5, // covered 534 1: 0, // uncovered 535 2: 3, // covered 536 3: 0, // uncovered 537 4: 0, // uncovered 538 }, 539 }, 540 }; 541 writeFileSync(join(coverageDir, 'coverage-final.json'), JSON.stringify(finalCoverage)); 542 543 const uncovered = identifyUncoveredLines('src/agents/monitor.js'); 544 assert.ok(Array.isArray(uncovered)); 545 assert.strictEqual(uncovered.length, 3, 'Should find 3 uncovered lines'); 546 assert.ok(uncovered.includes(15)); 547 assert.ok(uncovered.includes(25)); 548 assert.ok(uncovered.includes(30)); 549 550 rmSync(coverageDir, { recursive: true, force: true }); 551 }); 552 553 await t.test('should sort uncovered lines in ascending order', () => { 554 const coverageDir = TEMP_COVERAGE_DIR; 555 mkdirSync(coverageDir, { recursive: true }); 556 557 const absolutePath = resolve(process.cwd(), 'src/test-sorting.js'); 558 const finalCoverage = { 559 [absolutePath]: { 560 statementMap: { 561 0: { start: { line: 100, column: 0 } }, 562 1: { start: { line: 50, column: 2 } }, 563 2: { start: { line: 75, column: 4 } }, 564 }, 565 s: { 566 0: 0, // uncovered 567 1: 0, // uncovered 568 2: 0, // uncovered 569 }, 570 }, 571 }; 572 writeFileSync(join(coverageDir, 'coverage-final.json'), JSON.stringify(finalCoverage)); 573 574 const uncovered = identifyUncoveredLines('src/test-sorting.js'); 575 assert.ok(Array.isArray(uncovered)); 576 // Should be sorted ascending 577 for (let i = 1; i < uncovered.length; i++) { 578 assert.ok(uncovered[i] > uncovered[i - 1], 'Lines should be sorted ascending'); 579 } 580 581 rmSync(coverageDir, { recursive: true, force: true }); 582 }); 583 }); 584 585 test('Test Runner - parseTestOutput additional patterns', async t => { 586 await t.test('should handle output with todo tests', () => { 587 const output = `# tests 5 588 # pass 3 589 # fail 0 590 # todo 2 591 # duration_ms 100 592 `; 593 const results = parseTestOutput(output); 594 assert.strictEqual(results.stats.todo, 2); 595 assert.strictEqual(results.stats.pass, 3); 596 }); 597 598 await t.test('should handle failure with Error message in stack', () => { 599 const output = `TAP version 13 600 not ok 1 - should work 601 --- 602 duration_ms: 5 603 type: test 604 error: |- 605 Error: Expected value to be true 606 ... 607 1..1 608 # tests 1 609 # pass 0 610 # fail 1 611 # duration_ms 10 612 `; 613 const results = parseTestOutput(output); 614 assert.strictEqual(results.stats.fail, 1); 615 // Failure message extraction 616 const failure = results.failures[0]; 617 assert.ok(failure); 618 assert.ok(failure.message.length > 0); 619 }); 620 }); 621 622 test('Test Runner - getCoverageForFiles multiple files', async t => { 623 await t.test('should return coverage for multiple files', () => { 624 const coverageDir = TEMP_COVERAGE_DIR; 625 mkdirSync(coverageDir, { recursive: true }); 626 627 const path1 = resolve(process.cwd(), 'src/utils/logger.js'); 628 const path2 = resolve(process.cwd(), 'src/utils/error-handler.js'); 629 const summary = { 630 total: { 631 lines: { total: 100, covered: 90, pct: 90 }, 632 statements: { total: 100, covered: 90, pct: 90 }, 633 functions: { total: 20, covered: 18, pct: 90 }, 634 branches: { total: 40, covered: 36, pct: 90 }, 635 }, 636 [path1]: { 637 lines: { total: 50, covered: 45, pct: 90 }, 638 statements: { total: 50, covered: 45, pct: 90 }, 639 functions: { total: 10, covered: 9, pct: 90 }, 640 branches: { total: 20, covered: 18, pct: 90 }, 641 }, 642 [path2]: { 643 lines: { total: 30, covered: 25, pct: 83 }, 644 statements: { total: 30, covered: 25, pct: 83 }, 645 functions: { total: 6, covered: 5, pct: 83 }, 646 branches: { total: 12, covered: 10, pct: 83 }, 647 }, 648 }; 649 writeFileSync(join(coverageDir, 'coverage-summary.json'), JSON.stringify(summary)); 650 651 const coverage = getCoverageForFiles(['src/utils/logger.js', 'src/utils/error-handler.js']); 652 assert.ok(coverage); 653 assert.ok(coverage['src/utils/logger.js']); 654 assert.ok(coverage['src/utils/error-handler.js']); 655 assert.strictEqual(coverage['src/utils/logger.js'].lines.pct, 90); 656 assert.strictEqual(coverage['src/utils/error-handler.js'].lines.pct, 83); 657 658 rmSync(coverageDir, { recursive: true, force: true }); 659 }); 660 });