/ tests / agents / test-runner-coverage2.test.js
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  });