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