test-runner-coverage2.test.js
1 /** 2 * Test Runner Coverage 2 Tests 3 * 4 * Targets the remaining uncovered lines in src/agents/utils/test-runner.js: 5 * - Lines 55-67: catch block in runTests() when executeTestCommand throws 6 * - Lines 320-321: timeout handler (kill + reject) in executeTestCommand 7 * - Lines 330-331: child.on('error') handler in executeTestCommand 8 * 9 * Strategy: Register mock.module('child_process') at module level (before any 10 * imports), control spawn behavior via a shared mutable state flag, then 11 * dynamically import the module under test. This is required because 12 * node:test only allows a module to be mocked once per test file. 13 * 14 * Note: Lines 374-376 (getAllTestFiles early return when tests/ dir missing) 15 * require mocking 'fs', which conflicts with the existing fs imports in this 16 * file. Those 2 lines are covered by the broader test suite via other paths. 17 */ 18 19 import { test, describe, mock } from 'node:test'; 20 import assert from 'node:assert/strict'; 21 import { EventEmitter } from 'events'; 22 import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; 23 import { join, resolve } from 'path'; 24 25 // Use an isolated coverage dir so we don't corrupt real coverage output 26 const TEMP_COVERAGE_DIR = `/tmp/test-runner-cov2-coverage-${process.pid}`; 27 process.env.COVERAGE_DIR = TEMP_COVERAGE_DIR; 28 29 // ── Shared state controlling how the mocked spawn behaves ──────────────────── 30 // Each test sets spawnMode before calling runTests. 31 // Modes: 32 // 'throw_sync' - spawn() throws synchronously => outer catch (lines 54-67) 33 // 'timeout' - child never emits 'close' => setTimeout fires (lines 319-322) 34 // 'child_error' - child emits 'error' event => handler fires (lines 329-332) 35 // 'close_ok' - child emits 'close' with code 0 => success 36 let spawnMode = 'throw_sync'; 37 38 // Signal captured from child.kill() call (lines 320) 39 let killedSignal = null; 40 41 // Error message emitted by child 'error' event (lines 330-331) 42 let childErrorMessage = 'Simulated child process error'; 43 44 // Short timeout value so timeout tests complete quickly 45 const SPAWN_TIMEOUT_MS = 50; 46 47 // ── Mock child_process BEFORE importing test-runner.js ─────────────────────── 48 // This ensures that when test-runner.js is dynamically imported below, its 49 // `import { spawn }` from 'child_process' resolves to our mock. 50 mock.module('child_process', { 51 namedExports: { 52 spawn: (_command, _args, _options) => { 53 if (spawnMode === 'throw_sync') { 54 // Throwing synchronously inside the Promise constructor in executeTestCommand 55 // causes the Promise to reject, which is caught at lines 54-67 in runTests(). 56 throw new Error('spawn ENOENT simulated for coverage'); 57 } 58 59 // Build a fake ChildProcess-like EventEmitter 60 const child = new EventEmitter(); 61 child.stdout = new EventEmitter(); 62 child.stderr = new EventEmitter(); 63 child.kill = signal => { 64 killedSignal = signal; 65 // In 'timeout' mode, deliberately do NOT emit 'close' here. 66 // The real setTimeout in executeTestCommand will fire first (lines 319-321). 67 }; 68 69 if (spawnMode === 'close_ok') { 70 // Emit 'close' with exit code 0 on next tick 71 setImmediate(() => child.emit('close', 0)); 72 } else if (spawnMode === 'child_error') { 73 // Emit 'error' event asynchronously (exercises lines 329-332) 74 const err = new Error(childErrorMessage); 75 setImmediate(() => child.emit('error', err)); 76 } 77 // For 'timeout' mode: never emit 'close' — let setTimeout fire at line 319. 78 79 return child; 80 }, 81 }, 82 }); 83 84 // ── Import test-runner AFTER mocks are registered ──────────────────────────── 85 const { 86 runTests, 87 runTestsForFile, 88 getCoverageForFiles, 89 parseTestOutput, 90 identifyUncoveredLines, 91 parseCoverageReport, 92 } = await import('../../src/agents/utils/test-runner.js'); 93 94 // ── Coverage dir helpers ────────────────────────────────────────────────────── 95 function createMockCoverage() { 96 if (!existsSync(TEMP_COVERAGE_DIR)) { 97 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 98 } 99 const summary = { 100 total: { 101 lines: { total: 100, covered: 80, pct: 80 }, 102 statements: { total: 100, covered: 80, pct: 80 }, 103 functions: { total: 20, covered: 16, pct: 80 }, 104 branches: { total: 40, covered: 32, pct: 80 }, 105 }, 106 [resolve(process.cwd(), 'src/utils/logger.js')]: { 107 lines: { total: 50, covered: 45, pct: 90 }, 108 statements: { total: 50, covered: 45, pct: 90 }, 109 functions: { total: 10, covered: 9, pct: 90 }, 110 branches: { total: 20, covered: 18, pct: 90 }, 111 }, 112 }; 113 const finalData = { 114 [resolve(process.cwd(), 'src/utils/logger.js')]: { 115 statementMap: { 116 0: { start: { line: 10, column: 0 } }, 117 1: { start: { line: 20, column: 0 } }, 118 }, 119 s: { 0: 1, 1: 0 }, 120 }, 121 }; 122 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), JSON.stringify(summary)); 123 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 124 } 125 126 function cleanupCoverage() { 127 if (existsSync(TEMP_COVERAGE_DIR)) { 128 rmSync(TEMP_COVERAGE_DIR, { recursive: true, force: true }); 129 } 130 } 131 132 // ============================================================================= 133 // Lines 55-67: catch block in runTests() when executeTestCommand() throws 134 // ============================================================================= 135 136 describe('runTests - catch block (lines 55-67)', () => { 137 test('returns failure object when spawn throws synchronously', async () => { 138 spawnMode = 'throw_sync'; 139 140 const result = await runTests({ 141 files: ['tests/tmp-nonexistent.test.js'], 142 coverage: false, 143 }); 144 145 // The synchronous throw rejects the Promise created in executeTestCommand. 146 // That rejection propagates to the try/catch in runTests (lines 54-67). 147 assert.equal(result.success, false, 'Should be false on error'); 148 assert.equal(result.exitCode, 1, 'exitCode should be 1'); 149 assert.equal(result.output, '', 'output should be empty string'); 150 assert.ok(result.error.length > 0, 'error message should be set'); 151 assert.ok(Array.isArray(result.failures), 'failures should be array'); 152 assert.equal(result.failures.length, 0, 'failures should be empty'); 153 assert.equal(result.coverage, null, 'coverage should be null'); 154 assert.equal(result.stats.tests, 0, 'stats.tests should be 0'); 155 assert.equal(result.stats.pass, 0, 'stats.pass should be 0'); 156 assert.equal(result.stats.fail, 0, 'stats.fail should be 0'); 157 assert.ok(typeof result.duration === 'number', 'duration should be a number'); 158 assert.ok(typeof result.timestamp === 'string', 'timestamp should be a string'); 159 }); 160 161 test('catch block includes error.message from thrown error', async () => { 162 spawnMode = 'throw_sync'; 163 164 const result = await runTests({ files: ['fake.test.js'], coverage: false }); 165 assert.equal(result.success, false); 166 // Line 55: logger.error('Test execution failed', { error: error.message }) 167 // Line 61: error: error.message 168 assert.ok( 169 result.error.includes('spawn') || result.error.includes('simulated'), 170 `error should include spawn-related text, got: "${result.error}"` 171 ); 172 }); 173 174 test('catch block returns all-zero stats', async () => { 175 spawnMode = 'throw_sync'; 176 177 const result = await runTests({ files: ['nonexistent.test.js'], coverage: false }); 178 // The catch block at line 62 returns: { tests: 0, pass: 0, fail: 0, skipped: 0 } 179 assert.equal(result.stats.skipped, 0, 'skipped should be 0'); 180 assert.equal(result.stats.tests, 0, 'tests should be 0'); 181 assert.equal(result.stats.pass, 0, 'pass should be 0'); 182 assert.equal(result.stats.fail, 0, 'fail should be 0'); 183 }); 184 185 test('catch block result has null coverage even when coverage=true requested', async () => { 186 spawnMode = 'throw_sync'; 187 188 const result = await runTests({ files: ['nonexistent.test.js'], coverage: true }); 189 assert.equal(result.success, false); 190 // coverage is null because executeTestCommand threw before we could run 191 assert.equal(result.coverage, null); 192 }); 193 }); 194 195 // ============================================================================= 196 // Lines 320-321: timeout handler — child.kill('SIGTERM') + reject 197 // ============================================================================= 198 199 describe('executeTestCommand - timeout path (lines 320-321)', () => { 200 test('kills child with SIGTERM and rejects when timeout fires', async () => { 201 spawnMode = 'timeout'; 202 killedSignal = null; 203 204 const result = await runTests({ 205 files: ['tests/logger.test.js'], 206 coverage: false, 207 timeout: SPAWN_TIMEOUT_MS, 208 }); 209 210 // The setTimeout at line 319 fires, calling child.kill('SIGTERM') at line 320 211 // and rejecting the promise at line 321. The rejection is caught at lines 54-67. 212 assert.equal(result.success, false, 'Should fail on timeout'); 213 assert.equal(result.exitCode, 1, 'exitCode should be 1 on timeout'); 214 assert.ok(result.error.length > 0, 'Should have error message'); 215 assert.equal(killedSignal, 'SIGTERM', 'Should kill child with SIGTERM on timeout (line 320)'); 216 }); 217 218 test('timeout error message mentions timeout duration', async () => { 219 spawnMode = 'timeout'; 220 killedSignal = null; 221 222 const result = await runTests({ 223 files: ['tests/logger.test.js'], 224 coverage: false, 225 timeout: SPAWN_TIMEOUT_MS, 226 }); 227 228 // Line 321: new Error(`Test execution timeout after ${timeout}ms`) 229 assert.ok( 230 result.error.includes('timeout') || result.error.includes(String(SPAWN_TIMEOUT_MS)), 231 `Error should mention timeout or duration, got: "${result.error}"` 232 ); 233 }); 234 235 test('timeout result has null coverage and empty failures array', async () => { 236 spawnMode = 'timeout'; 237 238 const result = await runTests({ 239 files: ['tests/logger.test.js'], 240 coverage: false, 241 timeout: SPAWN_TIMEOUT_MS, 242 }); 243 244 assert.equal(result.coverage, null, 'coverage should be null on timeout'); 245 assert.ok(Array.isArray(result.failures), 'failures should be array'); 246 assert.equal(result.failures.length, 0, 'failures should be empty on timeout'); 247 assert.equal(result.output, '', 'output should be empty string on timeout'); 248 }); 249 250 test('timeout result has correct numeric properties', async () => { 251 spawnMode = 'timeout'; 252 253 const result = await runTests({ 254 files: ['tests/logger.test.js'], 255 coverage: false, 256 timeout: SPAWN_TIMEOUT_MS, 257 }); 258 259 assert.ok(typeof result.duration === 'number', 'duration should be a number'); 260 assert.ok(result.duration >= 0, 'duration should be non-negative'); 261 assert.ok(typeof result.timestamp === 'string', 'timestamp should be ISO string'); 262 }); 263 }); 264 265 // ============================================================================= 266 // Lines 330-331: child.on('error') handler — clearTimeout + reject(error) 267 // ============================================================================= 268 269 describe("executeTestCommand - child 'error' event (lines 330-331)", () => { 270 test("returns failure when child emits 'error' event", async () => { 271 spawnMode = 'child_error'; 272 childErrorMessage = 'spawn ENOENT: no such file or directory, spawn /fake/binary'; 273 274 const result = await runTests({ 275 files: ['tests/logger.test.js'], 276 coverage: false, 277 timeout: 10000, 278 }); 279 280 // The child 'error' event triggers the handler at lines 329-332, 281 // which calls clearTimeout and rejects the promise with the error. 282 // That rejection is caught by the outer try/catch at lines 54-67. 283 assert.equal(result.success, false, 'Should fail when child errors'); 284 assert.equal(result.exitCode, 1, 'exitCode should be 1'); 285 assert.ok(result.error.length > 0, 'Should have error message'); 286 }); 287 288 test("child 'error' event preserves exact error message (line 331: reject(error))", async () => { 289 spawnMode = 'child_error'; 290 childErrorMessage = 'Custom specific child process error for coverage test'; 291 292 const result = await runTests({ 293 files: ['tests/logger.test.js'], 294 coverage: false, 295 timeout: 10000, 296 }); 297 298 // The rejection at line 331 passes the error object, which bubbles to 299 // the catch block at line 54-55 where error.message is stored. 300 assert.equal( 301 result.error, 302 childErrorMessage, 303 'Should preserve child error message from lines 330-331 path' 304 ); 305 }); 306 307 test("child 'error' event result has correct shape with empty output", async () => { 308 spawnMode = 'child_error'; 309 childErrorMessage = 'ENOENT error for shape test'; 310 311 const result = await runTests({ 312 files: ['tests/logger.test.js'], 313 coverage: false, 314 timeout: 10000, 315 }); 316 317 assert.equal(result.output, '', 'output should be empty string'); 318 assert.equal(result.coverage, null, 'coverage should be null'); 319 assert.ok(Array.isArray(result.failures), 'failures should be array'); 320 assert.equal(result.failures.length, 0, 'failures should be empty'); 321 assert.equal(result.stats.tests, 0, 'stats.tests should be 0'); 322 assert.equal(result.stats.pass, 0, 'stats.pass should be 0'); 323 assert.equal(result.stats.fail, 0, 'stats.fail should be 0'); 324 }); 325 326 test("child 'error' event clears the timeout (timer does not fire after error)", async () => { 327 // If clearTimeout at line 330 works correctly, the timeout won't also reject. 328 // We verify by checking that only one error path fires (not double-reject). 329 spawnMode = 'child_error'; 330 childErrorMessage = 'Timer clear test error'; 331 332 const startTime = Date.now(); 333 const result = await runTests({ 334 files: ['tests/logger.test.js'], 335 coverage: false, 336 timeout: 10000, // long timeout that should be cleared 337 }); 338 339 const elapsed = Date.now() - startTime; 340 341 // If clearTimeout works, the promise resolves quickly via the 'error' event 342 // rather than waiting for the 10s timeout 343 assert.ok(elapsed < 5000, `Should complete quickly (< 5s), took ${elapsed}ms`); 344 assert.equal(result.success, false); 345 assert.equal(result.error, childErrorMessage); 346 }); 347 }); 348 349 // ============================================================================= 350 // Additional tests for other code paths 351 // ============================================================================= 352 353 describe('parseCoverageReport - additional cases', () => { 354 test('returns coverage data when summary exists', () => { 355 createMockCoverage(); 356 const result = parseCoverageReport(); 357 assert.ok(result !== null, 'Should return coverage data'); 358 assert.ok(result.total, 'Should have total key'); 359 assert.equal(result.total.lines.pct, 80); 360 cleanupCoverage(); 361 }); 362 363 test('returns null when summary file is malformed JSON', () => { 364 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 365 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), '{bad json'); 366 const result = parseCoverageReport(); 367 assert.equal(result, null, 'Should return null for malformed JSON'); 368 cleanupCoverage(); 369 }); 370 371 test('returns null when directory does not exist', () => { 372 cleanupCoverage(); 373 const result = parseCoverageReport(); 374 assert.equal(result, null, 'Should return null when coverage dir absent'); 375 }); 376 }); 377 378 describe('identifyUncoveredLines - additional cases', () => { 379 test('returns null when coverage-final.json missing', () => { 380 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 381 const result = identifyUncoveredLines('src/utils/logger.js'); 382 assert.equal(result, null, 'Should return null when coverage-final.json absent'); 383 cleanupCoverage(); 384 }); 385 386 test('returns sorted uncovered lines from coverage data', () => { 387 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 388 const absPath = resolve(process.cwd(), 'src/utils/logger.js'); 389 const finalData = { 390 [absPath]: { 391 statementMap: { 392 0: { start: { line: 30, column: 0 } }, 393 1: { start: { line: 10, column: 0 } }, 394 2: { start: { line: 20, column: 0 } }, 395 }, 396 s: { 0: 0, 1: 0, 2: 1 }, 397 }, 398 }; 399 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 400 const result = identifyUncoveredLines('src/utils/logger.js'); 401 assert.ok(Array.isArray(result), 'Should return an array'); 402 assert.equal(result.length, 2, 'Should have 2 uncovered lines'); 403 assert.ok(result[0] < result[1], 'Lines should be sorted ascending'); 404 assert.ok(result.includes(10), 'Should include line 10'); 405 assert.ok(result.includes(30), 'Should include line 30'); 406 cleanupCoverage(); 407 }); 408 409 test('deduplicates lines when multiple statements are on the same line', () => { 410 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 411 const absPath = resolve(process.cwd(), 'src/dedupe-test.js'); 412 const finalData = { 413 [absPath]: { 414 statementMap: { 415 0: { start: { line: 15, column: 0 } }, 416 1: { start: { line: 15, column: 10 } }, // same line, different column 417 2: { start: { line: 25, column: 0 } }, 418 }, 419 s: { 0: 0, 1: 0, 2: 1 }, 420 }, 421 }; 422 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 423 const result = identifyUncoveredLines('src/dedupe-test.js'); 424 assert.ok(Array.isArray(result)); 425 const line15Count = result.filter(l => l === 15).length; 426 assert.equal(line15Count, 1, 'Line 15 should appear only once despite 2 uncovered statements'); 427 cleanupCoverage(); 428 }); 429 430 test('returns null when file not found in coverage data', () => { 431 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 432 const finalData = { 433 [resolve(process.cwd(), 'src/other-file.js')]: { 434 statementMap: {}, 435 s: {}, 436 }, 437 }; 438 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), JSON.stringify(finalData)); 439 const result = identifyUncoveredLines('src/utils/logger.js'); 440 assert.equal(result, null, 'Should return null when file not in coverage data'); 441 cleanupCoverage(); 442 }); 443 444 test('returns null when coverage-final.json is invalid JSON', () => { 445 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 446 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-final.json'), 'NOT JSON {{{'); 447 const result = identifyUncoveredLines('src/utils/logger.js'); 448 assert.equal(result, null, 'Should return null for invalid JSON'); 449 cleanupCoverage(); 450 }); 451 }); 452 453 describe('getCoverageForFiles - additional cases', () => { 454 test('returns null when coverage report has no matching entries', () => { 455 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 456 writeFileSync( 457 join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), 458 JSON.stringify({ total: { lines: { pct: 80 } } }) 459 ); 460 const result = getCoverageForFiles(['src/utils/logger.js']); 461 assert.equal(result, null, 'Should return null when file not in coverage'); 462 cleanupCoverage(); 463 }); 464 465 test('returns coverage for file found in coverage data', () => { 466 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 467 const absPath = resolve(process.cwd(), 'src/utils/logger.js'); 468 const summary = { 469 total: { 470 lines: { pct: 85 }, 471 statements: { pct: 85 }, 472 functions: { pct: 85 }, 473 branches: { pct: 85 }, 474 }, 475 [absPath]: { 476 lines: { total: 100, covered: 85, pct: 85 }, 477 statements: { total: 100, covered: 85, pct: 85 }, 478 functions: { total: 20, covered: 17, pct: 85 }, 479 branches: { total: 40, covered: 35, pct: 87.5 }, 480 }, 481 }; 482 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), JSON.stringify(summary)); 483 const result = getCoverageForFiles(['src/utils/logger.js']); 484 assert.ok(result !== null, 'Should find coverage for file'); 485 assert.ok(result['src/utils/logger.js'], 'Should have entry for requested file'); 486 assert.equal(result['src/utils/logger.js'].lines.pct, 85); 487 cleanupCoverage(); 488 }); 489 490 test('returns null when no coverage data exists at all', () => { 491 cleanupCoverage(); 492 const result = getCoverageForFiles(['src/utils/logger.js']); 493 assert.equal(result, null, 'Should return null when no coverage files exist'); 494 }); 495 496 test('returns coverage for multiple files when all exist', () => { 497 mkdirSync(TEMP_COVERAGE_DIR, { recursive: true }); 498 const path1 = resolve(process.cwd(), 'src/utils/logger.js'); 499 const path2 = resolve(process.cwd(), 'src/utils/error-handler.js'); 500 const summary = { 501 total: { 502 lines: { pct: 90 }, 503 statements: { pct: 90 }, 504 functions: { pct: 90 }, 505 branches: { pct: 90 }, 506 }, 507 [path1]: { 508 lines: { total: 50, covered: 45, pct: 90 }, 509 statements: { total: 50, covered: 45, pct: 90 }, 510 functions: { total: 10, covered: 9, pct: 90 }, 511 branches: { total: 20, covered: 18, pct: 90 }, 512 }, 513 [path2]: { 514 lines: { total: 30, covered: 25, pct: 83 }, 515 statements: { total: 30, covered: 25, pct: 83 }, 516 functions: { total: 6, covered: 5, pct: 83 }, 517 branches: { total: 12, covered: 10, pct: 83 }, 518 }, 519 }; 520 writeFileSync(join(TEMP_COVERAGE_DIR, 'coverage-summary.json'), JSON.stringify(summary)); 521 const result = getCoverageForFiles(['src/utils/logger.js', 'src/utils/error-handler.js']); 522 assert.ok(result !== null); 523 assert.ok(result['src/utils/logger.js']); 524 assert.ok(result['src/utils/error-handler.js']); 525 assert.equal(result['src/utils/logger.js'].lines.pct, 90); 526 assert.equal(result['src/utils/error-handler.js'].lines.pct, 83); 527 cleanupCoverage(); 528 }); 529 }); 530 531 describe('parseTestOutput - additional edge cases', () => { 532 test('handles output with no summary lines', () => { 533 const result = parseTestOutput('some random output with no TAP summary lines'); 534 assert.equal(result.stats.tests, 0); 535 assert.equal(result.stats.pass, 0); 536 assert.equal(result.stats.fail, 0); 537 assert.equal(result.failures.length, 0); 538 }); 539 540 test('handles output with only pass/fail numbers but no failures', () => { 541 const output = `# tests 10\n# pass 10\n# fail 0\n# duration_ms 50`; 542 const result = parseTestOutput(output); 543 assert.equal(result.stats.tests, 10); 544 assert.equal(result.stats.pass, 10); 545 assert.equal(result.stats.fail, 0); 546 assert.equal(result.failures.length, 0); 547 }); 548 549 test('falls back to top-level failures when no indented failures present', () => { 550 const output = `TAP version 13 551 not ok 1 - top level failure name 552 # tests 1 553 # pass 0 554 # fail 1 555 # duration_ms 10`; 556 const result = parseTestOutput(output); 557 assert.equal(result.stats.fail, 1); 558 assert.ok(result.failures.length > 0, 'Should extract top-level failure'); 559 assert.equal(result.failures[0].name, 'top level failure name'); 560 }); 561 562 test('counts todo tests correctly', () => { 563 const output = `# tests 5\n# pass 3\n# fail 0\n# todo 2\n# duration_ms 100`; 564 const result = parseTestOutput(output); 565 assert.equal(result.stats.todo, 2); 566 }); 567 568 test('counts cancelled tests correctly', () => { 569 const output = `# tests 5\n# pass 3\n# fail 0\n# cancelled 2\n# duration_ms 100`; 570 const result = parseTestOutput(output); 571 assert.equal(result.stats.cancelled, 2); 572 }); 573 574 test('parses duration_ms as float', () => { 575 const output = `# tests 1\n# pass 1\n# fail 0\n# duration_ms 123.456789`; 576 const result = parseTestOutput(output); 577 assert.ok(Math.abs(result.stats.duration_ms - 123.456789) < 0.001); 578 }); 579 580 test('extracts indented failure messages from subtests', () => { 581 const output = `TAP version 13 582 # Subtest: suite 583 not ok 1 - inner test failure 584 --- 585 error: |- 586 Error: Expected true but got false 587 ... 588 1..1 589 not ok 1 - suite 590 # tests 1 591 # pass 0 592 # fail 1 593 # duration_ms 15`; 594 const result = parseTestOutput(output); 595 assert.equal(result.stats.fail, 1); 596 assert.ok(result.failures.length > 0); 597 assert.equal(result.failures[0].name, 'inner test failure'); 598 }); 599 600 test('handles assertion Expected/Received failure pattern', () => { 601 const output = `TAP version 13 602 # Subtest: suite 603 not ok 1 - my test 604 Expected: true 605 Received: false 606 1..1 607 not ok 1 - suite 608 # tests 1 609 # pass 0 610 # fail 1 611 # duration_ms 5`; 612 const result = parseTestOutput(output); 613 assert.equal(result.stats.fail, 1); 614 assert.ok(result.failures.length > 0); 615 }); 616 }); 617 618 describe('runTestsForFile - no test files found', () => { 619 test('returns error when no test files found for source', async () => { 620 const result = await runTestsForFile('src/nonexistent-module-xyz.js'); 621 assert.equal(result.success, false, 'Should fail when no test files found'); 622 assert.ok(result.error.includes('No test files found'), 'Should mention no test files'); 623 assert.equal(result.stats.tests, 0); 624 assert.equal(result.stats.pass, 0); 625 assert.equal(result.stats.fail, 0); 626 assert.ok(Array.isArray(result.failures)); 627 assert.equal(result.coverage, null); 628 }); 629 630 test('returns early descriptive message for deeply nested source path', async () => { 631 const result = await runTestsForFile('src/agents/utils/deeply/nested/no-tests.js'); 632 assert.equal(result.success, false); 633 assert.ok(result.error.includes('No test files found')); 634 }); 635 });