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