test-runner.test.js
1 /** 2 * Tests for src/agents/utils/test-runner.js 3 * 4 * Covers all exported functions: 5 * - runTests (success, failure, catch block, coverage flag) 6 * - runTestsForFile (found, not found) 7 * - getCoverageForFiles (found, not found, no data) 8 * - parseTestOutput (various TAP patterns) 9 * - identifyUncoveredLines (covered, uncovered, dedup, sort, errors) 10 * - parseCoverageReport (success, missing, malformed) 11 * 12 * Private paths exercised via public API: 13 * - executeTestCommand: timeout, child error, spawn throw, close success 14 * - findTestFilesForSource: via runTestsForFile 15 * - getAllTestFiles: via runTests({files:[]}) 16 * - extractFailureMessage: via parseTestOutput 17 * 18 * Strategy: 19 * 1. mock.module('child_process') at module level (before import) 20 * 2. Control spawn behaviour with shared mutable state 21 * 3. Use isolated COVERAGE_DIR (/tmp/) to avoid polluting real coverage 22 */ 23 24 import { test, describe, mock, before, after } from 'node:test'; 25 import assert from 'node:assert/strict'; 26 import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; 27 import { join, resolve } from 'path'; 28 import { EventEmitter } from 'events'; 29 30 // ── Isolated coverage dir ────────────────────────────────────────────────── 31 const TEMP_COVERAGE_DIR = `/tmp/tr-utils-test-${process.pid}`; 32 process.env.COVERAGE_DIR = TEMP_COVERAGE_DIR; 33 34 // ── Shared spawn control state ───────────────────────────────────────────── 35 // Modes: 36 // 'success' - child emits close(0) immediately 37 // 'fail' - child emits close(1) immediately 38 // 'throw' - spawn() throws synchronously 39 // 'timeout' - child never emits close (timeout fires) 40 // 'child_error' - child emits 'error' event 41 let spawnMode = 'success'; 42 let spawnStdout = ''; 43 let spawnStderr = ''; 44 let killedSignal = null; 45 let childErrorMsg = 'child process error'; 46 47 // ── Mock child_process BEFORE importing module under test ────────────────── 48 mock.module('child_process', { 49 namedExports: { 50 spawn: (_cmd, _args, _opts) => { 51 if (spawnMode === 'throw') { 52 throw new Error('spawn ENOENT: mock throw'); 53 } 54 55 const child = new EventEmitter(); 56 child.stdout = new EventEmitter(); 57 child.stderr = new EventEmitter(); 58 child.kill = signal => { 59 killedSignal = signal; 60 }; 61 62 if (spawnMode === 'success') { 63 setImmediate(() => { 64 child.stdout.emit('data', Buffer.from(spawnStdout)); 65 child.stderr.emit('data', Buffer.from(spawnStderr)); 66 child.emit('close', 0); 67 }); 68 } else if (spawnMode === 'fail') { 69 setImmediate(() => { 70 child.stdout.emit('data', Buffer.from(spawnStdout)); 71 child.stderr.emit('data', Buffer.from(spawnStderr)); 72 child.emit('close', 1); 73 }); 74 } else if (spawnMode === 'child_error') { 75 setImmediate(() => { 76 child.emit('error', new Error(childErrorMsg)); 77 }); 78 } 79 // 'timeout' mode: never emit close — let setTimeout fire 80 return child; 81 }, 82 }, 83 }); 84 85 // ── Import module under test AFTER mocks are registered ─────────────────── 86 const { 87 runTests, 88 runTestsForFile, 89 getCoverageForFiles, 90 parseTestOutput, 91 identifyUncoveredLines, 92 parseCoverageReport, 93 } = await import('../../../src/agents/utils/test-runner.js'); 94 95 // ── Coverage helpers ─────────────────────────────────────────────────────── 96 function setupCoverage(summaryExtra = {}, finalExtra = {}) { 97 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 98 99 const loggerAbsPath = resolve(process.cwd(), 'src/utils/logger.js'); 100 101 const summary = { 102 total: { 103 lines: { total: 200, covered: 160, pct: 80 }, 104 statements: { total: 200, covered: 160, pct: 80 }, 105 functions: { total: 40, covered: 32, pct: 80 }, 106 branches: { total: 80, covered: 64, pct: 80 }, 107 }, 108 [loggerAbsPath]: { 109 lines: { total: 50, covered: 45, pct: 90 }, 110 statements: { total: 50, covered: 45, pct: 90 }, 111 functions: { total: 10, covered: 9, pct: 90 }, 112 branches: { total: 20, covered: 18, pct: 90 }, 113 }, 114 ...summaryExtra, 115 }; 116 117 const finalData = { 118 [loggerAbsPath]: { 119 statementMap: { 120 0: { start: { line: 10, column: 0 } }, 121 1: { start: { line: 15, column: 2 } }, 122 2: { start: { line: 20, column: 4 } }, 123 3: { start: { line: 25, column: 4 } }, 124 }, 125 s: { 126 0: 5, 127 1: 3, 128 2: 0, // uncovered 129 3: 2, 130 }, 131 }, 132 ...finalExtra, 133 }; 134 135 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), JSON.stringify(summary)); 136 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 137 } 138 139 function teardownCoverage() { 140 if (existsSync(TEMP_COVERAGE_DIR)) { 141 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 142 } 143 } 144 145 // ── Reset spawn state between tests ─────────────────────────────────────── 146 function resetSpawn(mode = 'success', stdout = '', stderr = '') { 147 spawnMode = mode; 148 spawnStdout = stdout; 149 spawnStderr = stderr; 150 killedSignal = null; 151 childErrorMsg = 'child process error'; 152 } 153 154 // ───────────────────────────────────────────────────────────────────────────── 155 // parseTestOutput — pure function, no mocking needed 156 // ───────────────────────────────────────────────────────────────────────────── 157 158 describe('parseTestOutput', () => { 159 test('returns zero stats for empty string', () => { 160 const result = parseTestOutput(''); 161 assert.equal(result.stats.tests, 0); 162 assert.equal(result.stats.pass, 0); 163 assert.equal(result.stats.fail, 0); 164 assert.equal(result.stats.skipped, 0); 165 assert.equal(result.stats.cancelled, 0); 166 assert.equal(result.stats.todo, 0); 167 assert.equal(result.stats.suites, 0); 168 assert.equal(result.stats.duration_ms, 0); 169 assert.deepEqual(result.failures, []); 170 }); 171 172 test('parses standard passing TAP summary', () => { 173 const output = `TAP version 13 174 ok 1 - some test 175 1..1 176 # tests 5 177 # suites 2 178 # pass 5 179 # fail 0 180 # cancelled 0 181 # skipped 0 182 # todo 0 183 # duration_ms 123.456`; 184 185 const result = parseTestOutput(output); 186 assert.equal(result.stats.tests, 5); 187 assert.equal(result.stats.suites, 2); 188 assert.equal(result.stats.pass, 5); 189 assert.equal(result.stats.fail, 0); 190 assert.equal(result.stats.cancelled, 0); 191 assert.equal(result.stats.skipped, 0); 192 assert.equal(result.stats.todo, 0); 193 assert.ok(Math.abs(result.stats.duration_ms - 123.456) < 0.001); 194 assert.deepEqual(result.failures, []); 195 }); 196 197 test('parses indented (subtest) failures', () => { 198 const output = `TAP version 13 199 # Subtest: my suite 200 not ok 1 - inner failing test 201 --- 202 error: |- 203 Error: Expected 1 but got 2 204 ... 205 1..1 206 not ok 1 - my suite 207 # tests 1 208 # pass 0 209 # fail 1 210 # duration_ms 10`; 211 212 const result = parseTestOutput(output); 213 assert.equal(result.stats.fail, 1); 214 assert.equal(result.failures.length, 1); 215 assert.equal(result.failures[0].name, 'inner failing test'); 216 assert.ok(result.failures[0].message.length > 0); 217 }); 218 219 test('falls back to top-level failures when no indented failures exist', () => { 220 const output = `TAP version 13 221 not ok 1 - top level failure 222 not ok 2 - another top level 223 # tests 2 224 # pass 0 225 # fail 2 226 # duration_ms 5`; 227 228 const result = parseTestOutput(output); 229 assert.equal(result.stats.fail, 2); 230 assert.ok(result.failures.length > 0); 231 assert.equal(result.failures[0].name, 'top level failure'); 232 }); 233 234 test('does not add top-level fallback when indented failures are already found', () => { 235 const output = `TAP version 13 236 # Subtest: suite 237 not ok 1 - inner fail 238 1..1 239 not ok 1 - suite 240 # tests 1 241 # pass 0 242 # fail 1 243 # duration_ms 5`; 244 245 const result = parseTestOutput(output); 246 // Should have 1 failure (the indented one), not 2 (inner + top-level suite) 247 assert.equal(result.failures.length, 1); 248 assert.equal(result.failures[0].name, 'inner fail'); 249 }); 250 251 test('extracts Error: message from failure output', () => { 252 const output = `TAP version 13 253 # Subtest: suite 254 not ok 1 - broken test 255 --- 256 error: |- 257 Error: Something went wrong badly 258 ... 259 1..1 260 not ok 1 - suite 261 # tests 1 262 # pass 0 263 # fail 1 264 # duration_ms 8`; 265 266 const result = parseTestOutput(output); 267 assert.ok(result.failures[0].message.includes('Something went wrong badly')); 268 }); 269 270 test('extracts Expected/Received assertion failure message', () => { 271 const output = `TAP version 13 272 # Subtest: suite 273 not ok 1 - assertion fail 274 Expected: true 275 Received: false 276 1..1 277 not ok 1 - suite 278 # tests 1 279 # pass 0 280 # fail 1 281 # duration_ms 5`; 282 283 const result = parseTestOutput(output); 284 assert.equal(result.failures.length, 1); 285 // Message should mention Expected / Received 286 assert.ok(result.failures[0].message.length > 0); 287 }); 288 289 test('returns Unknown error when no message pattern matches', () => { 290 const output = `TAP version 13 291 # Subtest: suite 292 not ok 1 - no message test 293 1..1 294 not ok 1 - suite 295 # tests 1 296 # pass 0 297 # fail 1 298 # duration_ms 5`; 299 300 const result = parseTestOutput(output); 301 assert.equal(result.failures.length, 1); 302 assert.equal(result.failures[0].message, 'Unknown error'); 303 }); 304 305 test('parses skipped and todo tests', () => { 306 const output = `# tests 10 307 # pass 6 308 # fail 0 309 # skipped 2 310 # todo 2 311 # duration_ms 50`; 312 313 const result = parseTestOutput(output); 314 assert.equal(result.stats.skipped, 2); 315 assert.equal(result.stats.todo, 2); 316 assert.equal(result.stats.pass, 6); 317 }); 318 319 test('parses cancelled tests', () => { 320 const output = `# tests 5 321 # pass 3 322 # fail 0 323 # cancelled 2 324 # duration_ms 30`; 325 326 const result = parseTestOutput(output); 327 assert.equal(result.stats.cancelled, 2); 328 }); 329 330 test('handles non-float duration_ms', () => { 331 const output = `# tests 1 332 # pass 1 333 # fail 0 334 # duration_ms 758.972608`; 335 336 const result = parseTestOutput(output); 337 assert.ok(Math.abs(result.stats.duration_ms - 758.972608) < 0.001); 338 }); 339 340 test('ignores unknown summary keys', () => { 341 const output = `# tests 1 342 # pass 1 343 # fail 0 344 # unknown_key 99 345 # duration_ms 5`; 346 347 const result = parseTestOutput(output); 348 assert.equal(result.stats.tests, 1); 349 // unknown_key should not appear on stats 350 assert.equal(result.stats.unknown_key, undefined); 351 }); 352 353 test('handles multiple indented failures', () => { 354 const output = `TAP version 13 355 # Subtest: suite 356 not ok 1 - first fail 357 not ok 2 - second fail 358 1..2 359 not ok 1 - suite 360 # tests 2 361 # pass 0 362 # fail 2 363 # duration_ms 10`; 364 365 const result = parseTestOutput(output); 366 assert.equal(result.failures.length, 2); 367 assert.equal(result.failures[0].name, 'first fail'); 368 assert.equal(result.failures[1].name, 'second fail'); 369 }); 370 }); 371 372 // ───────────────────────────────────────────────────────────────────────────── 373 // parseCoverageReport 374 // ───────────────────────────────────────────────────────────────────────────── 375 376 describe('parseCoverageReport', () => { 377 test('returns null when coverage directory does not exist', () => { 378 teardownCoverage(); 379 const result = parseCoverageReport(); 380 assert.equal(result, null); 381 }); 382 383 test('returns null when coverage-summary.json does not exist', () => { 384 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 385 // Only create final, not summary 386 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), '{}'); 387 const result = parseCoverageReport(); 388 assert.equal(result, null); 389 teardownCoverage(); 390 }); 391 392 test('returns null when coverage-summary.json contains invalid JSON', () => { 393 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 394 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), '{INVALID JSON{{'); 395 const result = parseCoverageReport(); 396 assert.equal(result, null); 397 teardownCoverage(); 398 }); 399 400 test('returns parsed coverage data when summary file is valid', () => { 401 setupCoverage(); 402 const result = parseCoverageReport(); 403 assert.ok(result !== null); 404 assert.ok(result.total); 405 assert.equal(result.total.lines.pct, 80); 406 assert.equal(result.total.functions.pct, 80); 407 teardownCoverage(); 408 }); 409 410 test('returns all keys from coverage summary including per-file entries', () => { 411 setupCoverage(); 412 const result = parseCoverageReport(); 413 const loggerPath = resolve(process.cwd(), 'src/utils/logger.js'); 414 assert.ok(result[loggerPath]); 415 assert.equal(result[loggerPath].lines.pct, 90); 416 teardownCoverage(); 417 }); 418 }); 419 420 // ───────────────────────────────────────────────────────────────────────────── 421 // identifyUncoveredLines 422 // ───────────────────────────────────────────────────────────────────────────── 423 424 describe('identifyUncoveredLines', () => { 425 test('returns null when coverage-final.json does not exist', () => { 426 teardownCoverage(); 427 const result = identifyUncoveredLines('src/utils/logger.js'); 428 assert.equal(result, null); 429 }); 430 431 test('returns null when coverage directory does not exist', () => { 432 teardownCoverage(); 433 const result = identifyUncoveredLines('src/utils/logger.js'); 434 assert.equal(result, null); 435 }); 436 437 test('returns null when coverage-final.json contains invalid JSON', () => { 438 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 439 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), 'NOT VALID JSON {{{'); 440 const result = identifyUncoveredLines('src/utils/logger.js'); 441 assert.equal(result, null); 442 teardownCoverage(); 443 }); 444 445 test('returns null when file is not in coverage data', () => { 446 setupCoverage(); 447 const result = identifyUncoveredLines('src/utils/nonexistent-module.js'); 448 assert.equal(result, null); 449 teardownCoverage(); 450 }); 451 452 test('returns array of uncovered line numbers', () => { 453 setupCoverage(); 454 const result = identifyUncoveredLines('src/utils/logger.js'); 455 assert.ok(Array.isArray(result)); 456 assert.ok(result.includes(20)); // statement index 2 has count=0, line=20 457 teardownCoverage(); 458 }); 459 460 test('returns empty array when all lines are covered', () => { 461 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 462 const absPath = resolve(process.cwd(), 'src/utils/logger.js'); 463 const finalData = { 464 [absPath]: { 465 statementMap: { 466 0: { start: { line: 10, column: 0 } }, 467 1: { start: { line: 20, column: 0 } }, 468 }, 469 s: { 0: 5, 1: 3 }, 470 }, 471 }; 472 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 473 const result = identifyUncoveredLines('src/utils/logger.js'); 474 assert.ok(Array.isArray(result)); 475 assert.equal(result.length, 0); 476 teardownCoverage(); 477 }); 478 479 test('returns sorted uncovered line numbers in ascending order', () => { 480 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 481 const absPath = resolve(process.cwd(), 'src/utils/sort-test.js'); 482 const finalData = { 483 [absPath]: { 484 statementMap: { 485 0: { start: { line: 100, column: 0 } }, 486 1: { start: { line: 30, column: 0 } }, 487 2: { start: { line: 60, column: 0 } }, 488 }, 489 s: { 0: 0, 1: 0, 2: 0 }, 490 }, 491 }; 492 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 493 const result = identifyUncoveredLines('src/utils/sort-test.js'); 494 assert.ok(Array.isArray(result)); 495 assert.equal(result.length, 3); 496 for (let i = 1; i < result.length; i++) { 497 assert.ok(result[i] > result[i - 1], 'lines should be sorted ascending'); 498 } 499 teardownCoverage(); 500 }); 501 502 test('deduplicates line numbers when multiple statements share the same line', () => { 503 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 504 const absPath = resolve(process.cwd(), 'src/utils/dedup-test.js'); 505 const finalData = { 506 [absPath]: { 507 statementMap: { 508 0: { start: { line: 15, column: 0 } }, 509 1: { start: { line: 15, column: 20 } }, // same line, different column 510 2: { start: { line: 25, column: 0 } }, 511 }, 512 s: { 0: 0, 1: 0, 2: 1 }, 513 }, 514 }; 515 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 516 const result = identifyUncoveredLines('src/utils/dedup-test.js'); 517 assert.ok(Array.isArray(result)); 518 const line15Count = result.filter(l => l === 15).length; 519 assert.equal(line15Count, 1, 'line 15 should appear only once'); 520 teardownCoverage(); 521 }); 522 523 test('handles multiple uncovered lines across a file', () => { 524 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 525 const absPath = resolve(process.cwd(), 'src/agents/monitor.js'); 526 const finalData = { 527 [absPath]: { 528 statementMap: { 529 0: { start: { line: 10, column: 0 } }, 530 1: { start: { line: 20, column: 0 } }, 531 2: { start: { line: 30, column: 0 } }, 532 3: { start: { line: 40, column: 0 } }, 533 4: { start: { line: 50, column: 0 } }, 534 }, 535 s: { 0: 5, 1: 0, 2: 3, 3: 0, 4: 0 }, 536 }, 537 }; 538 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 539 const result = identifyUncoveredLines('src/agents/monitor.js'); 540 assert.ok(Array.isArray(result)); 541 assert.equal(result.length, 3); 542 assert.ok(result.includes(20)); 543 assert.ok(result.includes(40)); 544 assert.ok(result.includes(50)); 545 teardownCoverage(); 546 }); 547 }); 548 549 // ───────────────────────────────────────────────────────────────────────────── 550 // getCoverageForFiles 551 // ───────────────────────────────────────────────────────────────────────────── 552 553 describe('getCoverageForFiles', () => { 554 test('returns null when no coverage data exists', () => { 555 teardownCoverage(); 556 const result = getCoverageForFiles(['src/utils/logger.js']); 557 assert.equal(result, null); 558 }); 559 560 test('returns null when requested file is not in coverage data', () => { 561 setupCoverage(); 562 const result = getCoverageForFiles(['src/utils/no-such-file.js']); 563 assert.equal(result, null); 564 teardownCoverage(); 565 }); 566 567 test('returns null when empty files array provided and none match', () => { 568 setupCoverage(); 569 const result = getCoverageForFiles([]); 570 assert.equal(result, null); 571 teardownCoverage(); 572 }); 573 574 test('returns coverage entry for a matching file', () => { 575 setupCoverage(); 576 const result = getCoverageForFiles(['src/utils/logger.js']); 577 assert.ok(result !== null); 578 assert.ok(result['src/utils/logger.js']); 579 assert.equal(result['src/utils/logger.js'].lines.pct, 90); 580 teardownCoverage(); 581 }); 582 583 test('returns coverage entries for multiple matching files', () => { 584 const path1 = resolve(process.cwd(), 'src/utils/logger.js'); 585 const path2 = resolve(process.cwd(), 'src/utils/error-handler.js'); 586 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 587 const summary = { 588 total: { 589 lines: { pct: 85 }, 590 statements: { pct: 85 }, 591 functions: { pct: 85 }, 592 branches: { pct: 85 }, 593 }, 594 [path1]: { 595 lines: { total: 50, covered: 45, pct: 90 }, 596 statements: { total: 50, covered: 45, pct: 90 }, 597 functions: { total: 10, covered: 9, pct: 90 }, 598 branches: { total: 20, covered: 18, pct: 90 }, 599 }, 600 [path2]: { 601 lines: { total: 30, covered: 24, pct: 80 }, 602 statements: { total: 30, covered: 24, pct: 80 }, 603 functions: { total: 6, covered: 5, pct: 83 }, 604 branches: { total: 12, covered: 10, pct: 83 }, 605 }, 606 }; 607 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), JSON.stringify(summary)); 608 const result = getCoverageForFiles(['src/utils/logger.js', 'src/utils/error-handler.js']); 609 assert.ok(result !== null); 610 assert.ok(result['src/utils/logger.js']); 611 assert.ok(result['src/utils/error-handler.js']); 612 assert.equal(result['src/utils/logger.js'].lines.pct, 90); 613 assert.equal(result['src/utils/error-handler.js'].lines.pct, 80); 614 teardownCoverage(); 615 }); 616 617 test('returns only matching files when partial match', () => { 618 setupCoverage(); 619 const result = getCoverageForFiles(['src/utils/logger.js', 'src/utils/no-such.js']); 620 assert.ok(result !== null); 621 assert.ok(result['src/utils/logger.js']); 622 assert.equal(result['src/utils/no-such.js'], undefined); 623 teardownCoverage(); 624 }); 625 }); 626 627 // ───────────────────────────────────────────────────────────────────────────── 628 // runTestsForFile 629 // ───────────────────────────────────────────────────────────────────────────── 630 631 describe('runTestsForFile', () => { 632 test('returns error when no test files are found for source file', async () => { 633 const result = await runTestsForFile('src/nonexistent-module-12345.js'); 634 assert.equal(result.success, false); 635 assert.ok(result.error.includes('No test files found')); 636 assert.equal(result.stats.tests, 0); 637 assert.equal(result.stats.pass, 0); 638 assert.equal(result.stats.fail, 0); 639 assert.ok(Array.isArray(result.failures)); 640 assert.equal(result.coverage, null); 641 }); 642 643 test('returns error for deeply nested source with no tests', async () => { 644 const result = await runTestsForFile('src/agents/utils/deeply/nested/no-tests.js'); 645 assert.equal(result.success, false); 646 assert.ok(result.error.includes('No test files found')); 647 }); 648 649 test('returns error result with correct shape', async () => { 650 const result = await runTestsForFile('src/no-tests-here.js'); 651 assert.ok(typeof result === 'object'); 652 assert.equal(result.success, false); 653 assert.ok(typeof result.error === 'string'); 654 assert.ok(typeof result.stats === 'object'); 655 assert.ok(Array.isArray(result.failures)); 656 assert.equal(result.coverage, null); 657 }); 658 }); 659 660 // ───────────────────────────────────────────────────────────────────────────── 661 // runTests — success path 662 // ───────────────────────────────────────────────────────────────────────────── 663 664 describe('runTests - success path (exit code 0)', () => { 665 before(() => teardownCoverage()); 666 after(() => teardownCoverage()); 667 668 test('returns result object with all expected properties', async () => { 669 const tapOutput = `# tests 3 670 # pass 3 671 # fail 0 672 # suites 1 673 # cancelled 0 674 # skipped 0 675 # todo 0 676 # duration_ms 42.5`; 677 resetSpawn('success', tapOutput, ''); 678 679 const result = await runTests({ files: ['tests/fake.test.js'], coverage: false }); 680 681 assert.ok(typeof result === 'object'); 682 assert.ok(typeof result.success === 'boolean'); 683 assert.ok(typeof result.exitCode === 'number'); 684 assert.ok(typeof result.duration === 'number'); 685 assert.ok(typeof result.output === 'string'); 686 assert.ok(typeof result.error === 'string'); 687 assert.ok(typeof result.stats === 'object'); 688 assert.ok(Array.isArray(result.failures)); 689 assert.ok(typeof result.timestamp === 'string'); 690 }); 691 692 test('success=true when exitCode=0 and no failures', async () => { 693 const tapOutput = `# tests 5 694 # pass 5 695 # fail 0 696 # duration_ms 10`; 697 resetSpawn('success', tapOutput, ''); 698 699 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 700 assert.equal(result.success, true); 701 assert.equal(result.exitCode, 0); 702 assert.equal(result.stats.tests, 5); 703 assert.equal(result.stats.pass, 5); 704 assert.equal(result.stats.fail, 0); 705 assert.deepEqual(result.failures, []); 706 }); 707 708 test('success=false when exitCode=0 but failures exist', async () => { 709 const tapOutput = `TAP version 13 710 # Subtest: suite 711 not ok 1 - failing test 712 1..1 713 not ok 1 - suite 714 # tests 1 715 # pass 0 716 # fail 1 717 # duration_ms 5`; 718 resetSpawn('success', tapOutput, ''); 719 720 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 721 // exitCode is 0 from our mock, but stats.fail is 1 → success=false 722 assert.equal(result.stats.fail, 1); 723 assert.equal(result.success, false); 724 }); 725 726 test('success=false when exitCode=1', async () => { 727 const tapOutput = `# tests 1 728 # pass 0 729 # fail 1 730 # duration_ms 5`; 731 resetSpawn('fail', tapOutput, ''); 732 733 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 734 assert.equal(result.exitCode, 1); 735 assert.equal(result.success, false); 736 }); 737 738 test('duration is a non-negative number', async () => { 739 resetSpawn('success', '# tests 0\n# pass 0\n# fail 0\n# duration_ms 1', ''); 740 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 741 assert.ok(result.duration >= 0); 742 }); 743 744 test('timestamp is a valid ISO string', async () => { 745 resetSpawn('success', '# tests 0\n# pass 0\n# fail 0\n# duration_ms 1', ''); 746 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 747 const parsed = new Date(result.timestamp); 748 assert.ok(!isNaN(parsed.getTime()), 'timestamp should be a valid date'); 749 }); 750 751 test('stderr is captured in result.error', async () => { 752 resetSpawn('success', '# tests 1\n# pass 1\n# fail 0\n# duration_ms 1', 'some warning text'); 753 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 754 assert.ok(result.error.includes('some warning text')); 755 }); 756 757 test('coverage=null when coverage option is false', async () => { 758 resetSpawn('success', '# tests 1\n# pass 1\n# fail 0\n# duration_ms 1', ''); 759 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 760 assert.equal(result.coverage, null); 761 }); 762 763 test('coverage is parsed from file when coverage=true and summary exists', async () => { 764 setupCoverage(); 765 resetSpawn('success', '# tests 1\n# pass 1\n# fail 0\n# duration_ms 1', ''); 766 const result = await runTests({ files: ['tests/x.test.js'], coverage: true }); 767 assert.ok(result.coverage !== null); 768 assert.ok(result.coverage.total); 769 teardownCoverage(); 770 }); 771 772 test('coverage=null when coverage=true but no summary file exists', async () => { 773 teardownCoverage(); 774 resetSpawn('success', '# tests 1\n# pass 1\n# fail 0\n# duration_ms 1', ''); 775 const result = await runTests({ files: ['tests/x.test.js'], coverage: true }); 776 assert.equal(result.coverage, null); 777 }); 778 779 test('passes all test files to spawn args', async () => { 780 resetSpawn('success', '# tests 2\n# pass 2\n# fail 0\n# duration_ms 2', ''); 781 const result = await runTests({ 782 files: ['tests/a.test.js', 'tests/b.test.js'], 783 coverage: false, 784 }); 785 assert.ok(typeof result === 'object'); 786 }); 787 788 test('runs with default options when none provided', async () => { 789 // getAllTestFiles is called when files=[] — mock returns clean output 790 resetSpawn('success', '# tests 0\n# pass 0\n# fail 0\n# duration_ms 1', ''); 791 const result = await runTests({}); 792 assert.ok(typeof result === 'object'); 793 assert.ok(typeof result.success === 'boolean'); 794 }); 795 }); 796 797 // ───────────────────────────────────────────────────────────────────────────── 798 // runTests — error paths (spawn throws, timeout, child error) 799 // ───────────────────────────────────────────────────────────────────────────── 800 801 describe('runTests - catch block (spawn throws synchronously)', () => { 802 test('returns failure object when spawn throws', async () => { 803 resetSpawn('throw'); 804 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 805 assert.equal(result.success, false); 806 assert.equal(result.exitCode, 1); 807 assert.equal(result.output, ''); 808 assert.ok(result.error.length > 0); 809 assert.ok(Array.isArray(result.failures)); 810 assert.equal(result.failures.length, 0); 811 assert.equal(result.coverage, null); 812 }); 813 814 test('error message contains text from thrown error', async () => { 815 resetSpawn('throw'); 816 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 817 assert.ok( 818 result.error.includes('spawn') || 819 result.error.includes('ENOENT') || 820 result.error.includes('mock'), 821 `error should reference spawn error, got: "${result.error}"` 822 ); 823 }); 824 825 test('stats are all zero in catch block result', async () => { 826 resetSpawn('throw'); 827 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 828 assert.equal(result.stats.tests, 0); 829 assert.equal(result.stats.pass, 0); 830 assert.equal(result.stats.fail, 0); 831 assert.equal(result.stats.skipped, 0); 832 }); 833 834 test('duration is numeric even on error', async () => { 835 resetSpawn('throw'); 836 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 837 assert.ok(typeof result.duration === 'number'); 838 assert.ok(result.duration >= 0); 839 }); 840 841 test('timestamp is set even on error', async () => { 842 resetSpawn('throw'); 843 const result = await runTests({ files: ['tests/x.test.js'], coverage: false }); 844 const parsed = new Date(result.timestamp); 845 assert.ok(!isNaN(parsed.getTime())); 846 }); 847 848 test('coverage is null in catch result when coverage=true requested', async () => { 849 resetSpawn('throw'); 850 const result = await runTests({ files: ['tests/x.test.js'], coverage: true }); 851 assert.equal(result.coverage, null); 852 }); 853 }); 854 855 describe('runTests - timeout path (child never closes)', () => { 856 test('kills child with SIGTERM and returns failure', async () => { 857 resetSpawn('timeout'); 858 killedSignal = null; 859 860 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 50 }); 861 862 assert.equal(result.success, false); 863 assert.equal(result.exitCode, 1); 864 assert.equal(killedSignal, 'SIGTERM', 'should kill child with SIGTERM'); 865 }); 866 867 test('error message mentions timeout', async () => { 868 resetSpawn('timeout'); 869 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 50 }); 870 assert.ok( 871 result.error.includes('timeout') || result.error.includes('50'), 872 `error should mention timeout, got: "${result.error}"` 873 ); 874 }); 875 876 test('output is empty string on timeout', async () => { 877 resetSpawn('timeout'); 878 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 50 }); 879 assert.equal(result.output, ''); 880 }); 881 882 test('failures array is empty on timeout', async () => { 883 resetSpawn('timeout'); 884 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 50 }); 885 assert.equal(result.failures.length, 0); 886 }); 887 888 test('coverage is null on timeout', async () => { 889 resetSpawn('timeout'); 890 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 50 }); 891 assert.equal(result.coverage, null); 892 }); 893 }); 894 895 describe("runTests - child 'error' event path", () => { 896 test('returns failure when child emits error event', async () => { 897 spawnMode = 'child_error'; 898 childErrorMsg = 'spawn ENOENT no such binary'; 899 900 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 10000 }); 901 902 assert.equal(result.success, false); 903 assert.equal(result.exitCode, 1); 904 }); 905 906 test('error message matches child error message', async () => { 907 spawnMode = 'child_error'; 908 childErrorMsg = 'Very specific child process error message XYZ'; 909 910 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 10000 }); 911 assert.equal(result.error, childErrorMsg); 912 }); 913 914 test('resolves quickly (timeout is cleared after child error)', async () => { 915 spawnMode = 'child_error'; 916 childErrorMsg = 'quick error'; 917 918 const start = Date.now(); 919 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 10000 }); 920 const elapsed = Date.now() - start; 921 922 assert.ok(elapsed < 5000, `Should complete quickly, took ${elapsed}ms`); 923 assert.equal(result.success, false); 924 }); 925 926 test('output is empty string on child error', async () => { 927 spawnMode = 'child_error'; 928 childErrorMsg = 'error event test'; 929 930 const result = await runTests({ files: ['tests/x.test.js'], coverage: false, timeout: 10000 }); 931 assert.equal(result.output, ''); 932 assert.equal(result.coverage, null); 933 assert.equal(result.failures.length, 0); 934 }); 935 }); 936 937 // ───────────────────────────────────────────────────────────────────────────── 938 // getAllTestFiles (private) — exercised via runTests({ files: [] }) 939 // ───────────────────────────────────────────────────────────────────────────── 940 941 describe('getAllTestFiles via runTests (files=[])', () => { 942 test('returns object when no files specified (uses getAllTestFiles)', async () => { 943 resetSpawn('success', '# tests 0\n# pass 0\n# fail 0\n# duration_ms 1', ''); 944 const result = await runTests({ files: [], coverage: false }); 945 assert.ok(typeof result === 'object'); 946 assert.ok(typeof result.success === 'boolean'); 947 }); 948 });