@mutineerjs/mutineer 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +10 -10
  2. package/dist/__tests__/index.spec.d.ts +1 -0
  3. package/dist/__tests__/index.spec.js +8 -0
  4. package/dist/bin/__tests__/mutineer.spec.js +7 -7
  5. package/dist/bin/mutineer.d.ts +1 -1
  6. package/dist/bin/mutineer.js +4 -4
  7. package/dist/core/__tests__/schemata.spec.js +62 -0
  8. package/dist/core/__tests__/sfc.spec.js +41 -1
  9. package/dist/core/schemata.js +15 -21
  10. package/dist/core/sfc.js +0 -4
  11. package/dist/core/variant-utils.js +0 -4
  12. package/dist/mutators/__tests__/utils.spec.js +65 -1
  13. package/dist/mutators/operator.js +13 -27
  14. package/dist/mutators/return-value.js +3 -7
  15. package/dist/mutators/utils.d.ts +2 -2
  16. package/dist/mutators/utils.js +59 -96
  17. package/dist/runner/__tests__/args.spec.js +8 -4
  18. package/dist/runner/__tests__/cache.spec.js +24 -0
  19. package/dist/runner/__tests__/changed.spec.js +75 -0
  20. package/dist/runner/__tests__/config.spec.js +50 -1
  21. package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
  22. package/dist/runner/__tests__/discover.spec.js +179 -0
  23. package/dist/runner/__tests__/orchestrator.spec.js +301 -8
  24. package/dist/runner/__tests__/pool-executor.spec.js +77 -0
  25. package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
  26. package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
  27. package/dist/runner/__tests__/ts-checker.spec.js +89 -2
  28. package/dist/runner/args.d.ts +1 -1
  29. package/dist/runner/args.js +2 -2
  30. package/dist/runner/config.js +3 -4
  31. package/dist/runner/coverage-resolver.js +2 -1
  32. package/dist/runner/discover.js +2 -2
  33. package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
  34. package/dist/runner/jest/__tests__/pool.spec.js +223 -1
  35. package/dist/runner/jest/adapter.js +3 -46
  36. package/dist/runner/jest/pool.js +4 -10
  37. package/dist/runner/jest/worker-runtime.js +2 -1
  38. package/dist/runner/orchestrator.js +7 -7
  39. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.d.ts +1 -0
  40. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.js +104 -0
  41. package/dist/runner/shared/__tests__/worker-script.spec.d.ts +1 -0
  42. package/dist/runner/shared/__tests__/worker-script.spec.js +32 -0
  43. package/dist/runner/shared/index.d.ts +4 -0
  44. package/dist/runner/shared/index.js +2 -0
  45. package/dist/runner/shared/pending-task.d.ts +9 -0
  46. package/dist/runner/shared/pending-task.js +1 -0
  47. package/dist/runner/shared/strip-mutineer-args.d.ts +11 -0
  48. package/dist/runner/shared/strip-mutineer-args.js +47 -0
  49. package/dist/runner/shared/worker-script.d.ts +5 -0
  50. package/dist/runner/shared/worker-script.js +12 -0
  51. package/dist/runner/ts-checker-worker.d.ts +10 -1
  52. package/dist/runner/ts-checker-worker.js +27 -25
  53. package/dist/runner/ts-checker.d.ts +6 -0
  54. package/dist/runner/ts-checker.js +1 -1
  55. package/dist/runner/vitest/__tests__/adapter.spec.js +254 -0
  56. package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
  57. package/dist/runner/vitest/__tests__/pool.spec.js +674 -0
  58. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
  59. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +21 -0
  60. package/dist/runner/vitest/adapter.js +8 -46
  61. package/dist/runner/vitest/plugin.js +1 -7
  62. package/dist/runner/vitest/pool.js +5 -19
  63. package/dist/runner/vitest/redirect-loader.js +3 -1
  64. package/dist/runner/vitest/worker-runtime.js +2 -1
  65. package/dist/types/config.d.ts +2 -2
  66. package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
  67. package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
  68. package/dist/utils/__tests__/coverage.spec.js +89 -0
  69. package/dist/utils/__tests__/logger.spec.js +9 -0
  70. package/dist/utils/__tests__/progress.spec.js +38 -0
  71. package/dist/utils/__tests__/summary.spec.js +56 -0
  72. package/dist/utils/coverage.js +3 -4
  73. package/dist/utils/errors.d.ts +4 -0
  74. package/dist/utils/errors.js +6 -0
  75. package/package.json +1 -1
@@ -60,6 +60,52 @@ describe('createViteResolver Vue plugin gating', () => {
60
60
  await fs.rm(tmpDir, { recursive: true, force: true });
61
61
  }
62
62
  });
63
+ it('successfully loads @vitejs/plugin-vue when available and .vue files exist', async () => {
64
+ vi.doMock('@vitejs/plugin-vue', () => ({
65
+ default: () => ({}),
66
+ }));
67
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-vueok-'));
68
+ const srcDir = path.join(tmpDir, 'src');
69
+ await fs.mkdir(srcDir, { recursive: true });
70
+ await fs.writeFile(path.join(srcDir, 'Comp.vue'), '<template><div/></template>\n', 'utf8');
71
+ await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
72
+ const importLine = ['im', 'port { a } from "./a"'].join('');
73
+ await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
74
+ try {
75
+ await autoDiscoverTargetsAndTests(tmpDir, {
76
+ testPatterns: ['**/*.test.ts'],
77
+ extensions: ['.vue', '.ts'],
78
+ });
79
+ // Should not warn about plugin-vue
80
+ const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
81
+ expect(pluginVueWarnings).toHaveLength(0);
82
+ }
83
+ finally {
84
+ vi.doUnmock('@vitejs/plugin-vue');
85
+ await fs.rm(tmpDir, { recursive: true, force: true });
86
+ }
87
+ });
88
+ it('does not load @vitejs/plugin-vue when extensions excludes .vue', async () => {
89
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-novueext-'));
90
+ const srcDir = path.join(tmpDir, 'src');
91
+ await fs.mkdir(srcDir, { recursive: true });
92
+ await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
93
+ const importLine = ['im', 'port { a } from "./a"'].join('');
94
+ await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
95
+ try {
96
+ const result = await autoDiscoverTargetsAndTests(tmpDir, {
97
+ testPatterns: ['**/*.test.ts'],
98
+ extensions: ['.ts'],
99
+ });
100
+ // Plugin-vue code is skipped entirely; no warnings
101
+ const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
102
+ expect(pluginVueWarnings).toHaveLength(0);
103
+ expect(result.targets.length).toBeGreaterThan(0);
104
+ }
105
+ finally {
106
+ await fs.rm(tmpDir, { recursive: true, force: true });
107
+ }
108
+ });
63
109
  it('warns when .vue files exist but @vitejs/plugin-vue fails to load', async () => {
64
110
  vi.doMock('@vitejs/plugin-vue', () => {
65
111
  throw new Error('Cannot find module @vitejs/plugin-vue');
@@ -86,6 +132,20 @@ describe('createViteResolver Vue plugin gating', () => {
86
132
  });
87
133
  });
88
134
  describe('autoDiscoverTargetsAndTests', () => {
135
+ it('returns empty result when no test files found', async () => {
136
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-notests-'));
137
+ try {
138
+ const result = await autoDiscoverTargetsAndTests(tmpDir, {
139
+ testPatterns: ['**/*.test.ts'],
140
+ });
141
+ expect(result.targets).toHaveLength(0);
142
+ expect(result.testMap.size).toBe(0);
143
+ expect(result.directTestMap.size).toBe(0);
144
+ }
145
+ finally {
146
+ await fs.rm(tmpDir, { recursive: true, force: true });
147
+ }
148
+ });
89
149
  it('directTestMap only includes direct importers', async () => {
90
150
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct-'));
91
151
  const srcDir = path.join(tmpDir, 'src');
@@ -242,6 +302,125 @@ describe('autoDiscoverTargetsAndTests', () => {
242
302
  await fs.rm(tmpDir, { recursive: true, force: true });
243
303
  }
244
304
  });
305
+ it('excludes files matching excludePaths prefix', async () => {
306
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-exclude-'));
307
+ const srcDir = path.join(tmpDir, 'src');
308
+ const adminDir = path.join(srcDir, 'admin');
309
+ const adminFile = path.join(adminDir, 'restricted.ts');
310
+ const publicFile = path.join(srcDir, 'public.ts');
311
+ const testFile = path.join(srcDir, 'a.test.ts');
312
+ await fs.mkdir(adminDir, { recursive: true });
313
+ await fs.writeFile(adminFile, 'export const restricted = 1\n', 'utf8');
314
+ await fs.writeFile(publicFile, 'export const pub = 2\n', 'utf8');
315
+ const importLine = [
316
+ ['im', 'port { pub } from "./public"'].join(''),
317
+ ['im', 'port { restricted } from "./admin/restricted"'].join(''),
318
+ ].join('\n');
319
+ await fs.writeFile(testFile, `${importLine}\n`, 'utf8');
320
+ try {
321
+ const { targets } = await autoDiscoverTargetsAndTests(tmpDir, {
322
+ testPatterns: ['**/*.test.ts'],
323
+ excludePaths: ['src/admin'],
324
+ });
325
+ const targetFiles = targets.map((t) => normalizePath(typeof t === 'string' ? t : t.file));
326
+ expect(targetFiles.some((f) => f.includes('restricted.ts'))).toBe(false);
327
+ expect(targetFiles.some((f) => f.includes('public.ts'))).toBe(true);
328
+ }
329
+ finally {
330
+ await fs.rm(tmpDir, { recursive: true, force: true });
331
+ }
332
+ });
333
+ it('excludes files matching excludePaths glob pattern', async () => {
334
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-excludeglob-'));
335
+ const srcDir = path.join(tmpDir, 'src');
336
+ const adminDir = path.join(srcDir, 'admin');
337
+ const adminFile = path.join(adminDir, 'restricted.ts');
338
+ const publicFile = path.join(srcDir, 'public.ts');
339
+ const testFile = path.join(srcDir, 'a.test.ts');
340
+ await fs.mkdir(adminDir, { recursive: true });
341
+ await fs.writeFile(adminFile, 'export const restricted = 1\n', 'utf8');
342
+ await fs.writeFile(publicFile, 'export const pub = 2\n', 'utf8');
343
+ const importLine = [
344
+ ['im', 'port { pub } from "./public"'].join(''),
345
+ ['im', 'port { restricted } from "./admin/restricted"'].join(''),
346
+ ].join('\n');
347
+ await fs.writeFile(testFile, `${importLine}\n`, 'utf8');
348
+ try {
349
+ const { targets } = await autoDiscoverTargetsAndTests(tmpDir, {
350
+ testPatterns: ['**/*.test.ts'],
351
+ excludePaths: ['src/admin/**'],
352
+ });
353
+ const targetFiles = targets.map((t) => normalizePath(typeof t === 'string' ? t : t.file));
354
+ expect(targetFiles.some((f) => f.includes('restricted.ts'))).toBe(false);
355
+ expect(targetFiles.some((f) => f.includes('public.ts'))).toBe(true);
356
+ }
357
+ finally {
358
+ await fs.rm(tmpDir, { recursive: true, force: true });
359
+ }
360
+ });
361
+ it('handles unreadable dependency gracefully', async () => {
362
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-unreadable-'));
363
+ const srcDir = path.join(tmpDir, 'src');
364
+ const sourceFile = path.join(srcDir, 'source.ts');
365
+ const testFile = path.join(srcDir, 'source.test.ts');
366
+ await fs.mkdir(srcDir, { recursive: true });
367
+ // source.ts imports ./ghost which doesn't exist on disk
368
+ const ghostImport = ['im', 'port { x } from "./ghost"'].join('');
369
+ await fs.writeFile(sourceFile, `${ghostImport}\nexport const y = 1\n`, 'utf8');
370
+ const importLine = ['im', 'port { y } from "./source"'].join('');
371
+ await fs.writeFile(testFile, `${importLine}\n`, 'utf8');
372
+ try {
373
+ const { targets } = await autoDiscoverTargetsAndTests(tmpDir, {
374
+ testPatterns: ['**/*.test.ts'],
375
+ });
376
+ const targetFiles = targets.map((t) => normalizePath(typeof t === 'string' ? t : t.file));
377
+ expect(targetFiles.some((f) => f.includes('source.ts'))).toBe(true);
378
+ }
379
+ finally {
380
+ await fs.rm(tmpDir, { recursive: true, force: true });
381
+ }
382
+ });
383
+ it('uses default testPatterns when cfg.testPatterns is not set', async () => {
384
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-defaultpat-'));
385
+ const srcDir = path.join(tmpDir, 'src');
386
+ const moduleFile = path.join(srcDir, 'foo.ts');
387
+ const testFile = path.join(srcDir, 'foo.spec.ts');
388
+ await fs.mkdir(srcDir, { recursive: true });
389
+ await fs.writeFile(moduleFile, 'export const foo = 1\n', 'utf8');
390
+ const importLine = ['im', 'port { foo } from "./foo"'].join('');
391
+ await fs.writeFile(testFile, `${importLine}\n`, 'utf8');
392
+ try {
393
+ // No testPatterns → uses TEST_PATTERNS_DEFAULT which includes **/*.spec.[jt]s?(x)
394
+ const { targets } = await autoDiscoverTargetsAndTests(tmpDir, {});
395
+ const targetFiles = targets.map((t) => normalizePath(typeof t === 'string' ? t : t.file));
396
+ expect(targetFiles).toContain(normalizePath(moduleFile));
397
+ }
398
+ finally {
399
+ await fs.rm(tmpDir, { recursive: true, force: true });
400
+ }
401
+ });
402
+ it('assigns vue:script-setup kind to .vue source files', async () => {
403
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-vuekind-'));
404
+ const srcDir = path.join(tmpDir, 'src');
405
+ const vueFile = path.join(srcDir, 'Comp.vue');
406
+ const testFile = path.join(srcDir, 'Comp.test.ts');
407
+ await fs.mkdir(srcDir, { recursive: true });
408
+ await fs.writeFile(vueFile, '<script setup>\nconst x = 1\n</script>\n', 'utf8');
409
+ const importLine = ['im', `port Comp from "./Comp.vue"`].join('');
410
+ await fs.writeFile(testFile, `${importLine}\n`, 'utf8');
411
+ try {
412
+ const { targets } = await autoDiscoverTargetsAndTests(tmpDir, {
413
+ testPatterns: ['**/*.test.ts'],
414
+ extensions: ['.vue', '.ts'],
415
+ });
416
+ const vueTarget = targets.find((t) => typeof t !== 'string' && normalizePath(t.file).endsWith('Comp.vue'));
417
+ expect(vueTarget).toBeDefined();
418
+ expect(typeof vueTarget !== 'string' && vueTarget?.kind).toBe('vue:script-setup');
419
+ }
420
+ finally {
421
+ await fs.rm(tmpDir, { recursive: true, force: true });
422
+ }
423
+ });
245
424
  it('ignores test files when collecting mutate targets', async () => {
246
425
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-'));
247
426
  const srcDir = path.join(tmpDir, 'src');
@@ -70,11 +70,14 @@ vi.mock('../../core/schemata.js', () => ({
70
70
  import { runOrchestrator, parseMutantTimeoutMs } from '../orchestrator.js';
71
71
  import { loadMutineerConfig } from '../config.js';
72
72
  import { createVitestAdapter } from '../vitest/index.js';
73
+ import { createJestAdapter } from '../jest/index.js';
73
74
  import { autoDiscoverTargetsAndTests } from '../discover.js';
74
75
  import { listChangedFiles } from '../changed.js';
75
76
  import { executePool } from '../pool-executor.js';
76
77
  import { prepareTasks } from '../tasks.js';
77
- import { enumerateAllVariants } from '../variants.js';
78
+ import { enumerateAllVariants, getTargetFile } from '../variants.js';
79
+ import { resolveTypescriptEnabled, checkTypes } from '../ts-checker.js';
80
+ import { resolveCoverageConfig, loadCoverageAfterBaseline, } from '../coverage-resolver.js';
78
81
  import { generateSchema } from '../../core/schemata.js';
79
82
  import os from 'node:os';
80
83
  import fssync from 'node:fs';
@@ -183,8 +186,8 @@ describe('runOrchestrator discovery logging', () => {
183
186
  expect(consoleSpy).toHaveBeenCalledWith('Discovery complete: 3 source file(s), 2 test file(s)');
184
187
  });
185
188
  });
186
- describe('runOrchestrator --changed-with-deps diagnostic', () => {
187
- it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
189
+ describe('runOrchestrator --changed-with-imports diagnostic', () => {
190
+ it('logs uncovered targets when wantsChangedWithImports is true', async () => {
188
191
  const cfg = {};
189
192
  vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
190
193
  const depFile = '/cwd/src/dep.ts';
@@ -195,10 +198,10 @@ describe('runOrchestrator --changed-with-deps diagnostic', () => {
195
198
  directTestMap: new Map(),
196
199
  });
197
200
  const consoleSpy = vi.spyOn(console, 'log');
198
- await runOrchestrator(['--changed-with-deps'], '/cwd');
199
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 target(s) from --changed-with-deps have no covering tests and will be skipped'));
201
+ await runOrchestrator(['--changed-with-imports'], '/cwd');
202
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 target(s) from --changed-with-imports have no covering tests and will be skipped'));
200
203
  });
201
- it('does not log when all changed-with-deps targets have covering tests', async () => {
204
+ it('does not log when all changed-with-imports targets have covering tests', async () => {
202
205
  const cfg = {};
203
206
  vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
204
207
  const depFile = '/cwd/src/dep.ts';
@@ -211,11 +214,11 @@ describe('runOrchestrator --changed-with-deps diagnostic', () => {
211
214
  });
212
215
  vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
213
216
  const consoleSpy = vi.spyOn(console, 'log');
214
- await runOrchestrator(['--changed-with-deps'], '/cwd');
217
+ await runOrchestrator(['--changed-with-imports'], '/cwd');
215
218
  const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
216
219
  expect(diagnosticCalls).toHaveLength(0);
217
220
  });
218
- it('does not log diagnostic when wantsChangedWithDeps is false', async () => {
221
+ it('does not log diagnostic when wantsChangedWithImports is false', async () => {
219
222
  const cfg = {};
220
223
  vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
221
224
  const depFile = '/cwd/src/dep.ts';
@@ -501,3 +504,293 @@ describe('runOrchestrator schema generation', () => {
501
504
  expect(mockLogDebug).toHaveBeenCalledWith(expect.stringMatching(/Schema: 1 embedded, 0 fallback/));
502
505
  });
503
506
  });
507
+ describe('runOrchestrator jest runner', () => {
508
+ const targetFile = '/cwd/src/foo.ts';
509
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
510
+ beforeEach(() => {
511
+ vi.clearAllMocks();
512
+ process.exitCode = undefined;
513
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
514
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
515
+ targets: [targetFile],
516
+ testMap: new Map([[targetFile, new Set([testFile])]]),
517
+ directTestMap: new Map(),
518
+ });
519
+ vi.mocked(createJestAdapter).mockReturnValue(mockAdapter);
520
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
521
+ });
522
+ afterEach(() => {
523
+ process.exitCode = undefined;
524
+ });
525
+ it('uses jest adapter when --runner jest is passed', async () => {
526
+ await runOrchestrator(['--runner', 'jest'], '/cwd');
527
+ expect(createJestAdapter).toHaveBeenCalled();
528
+ expect(createVitestAdapter).not.toHaveBeenCalled();
529
+ });
530
+ });
531
+ describe('runOrchestrator vitestProject', () => {
532
+ const targetFile = '/cwd/src/foo.ts';
533
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
534
+ beforeEach(() => {
535
+ vi.clearAllMocks();
536
+ process.exitCode = undefined;
537
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
538
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
539
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
540
+ targets: [targetFile],
541
+ testMap: new Map([[targetFile, new Set([testFile])]]),
542
+ directTestMap: new Map(),
543
+ });
544
+ });
545
+ afterEach(() => {
546
+ process.exitCode = undefined;
547
+ });
548
+ it('passes vitestProject from config to adapter', async () => {
549
+ vi.mocked(loadMutineerConfig).mockResolvedValue({
550
+ vitestProject: 'my-project',
551
+ });
552
+ await runOrchestrator([], '/cwd');
553
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ vitestProject: 'my-project' }));
554
+ });
555
+ });
556
+ describe('runOrchestrator --changed filter', () => {
557
+ afterEach(() => {
558
+ process.exitCode = undefined;
559
+ });
560
+ it('skips target not in changedAbs and exits with no tests error', async () => {
561
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
562
+ const targetFile = '/cwd/src/foo.ts';
563
+ const unchangedFile = '/cwd/src/bar.ts';
564
+ vi.mocked(listChangedFiles).mockReturnValue([targetFile]); // only targetFile is changed
565
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
566
+ targets: [unchangedFile], // bar.ts is not changed -> filtered out
567
+ testMap: new Map([
568
+ [unchangedFile, new Set(['/cwd/src/__tests__/bar.spec.ts'])],
569
+ ]),
570
+ directTestMap: new Map(),
571
+ });
572
+ await runOrchestrator(['--changed'], '/cwd');
573
+ // With no tests because bar.ts was filtered, should hit the no-tests error
574
+ expect(process.exitCode).toBe(1);
575
+ });
576
+ });
577
+ describe('runOrchestrator baseline failure', () => {
578
+ const targetFile = '/cwd/src/foo.ts';
579
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
580
+ beforeEach(() => {
581
+ vi.clearAllMocks();
582
+ process.exitCode = undefined;
583
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
584
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
585
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
586
+ targets: [targetFile],
587
+ testMap: new Map([[targetFile, new Set([testFile])]]),
588
+ directTestMap: new Map(),
589
+ });
590
+ });
591
+ afterEach(() => {
592
+ process.exitCode = undefined;
593
+ });
594
+ it('sets exitCode=1 and returns when baseline fails', async () => {
595
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(false);
596
+ await runOrchestrator([], '/cwd');
597
+ expect(process.exitCode).toBe(1);
598
+ expect(enumerateAllVariants).not.toHaveBeenCalled();
599
+ });
600
+ });
601
+ describe('runOrchestrator TypeScript checking', () => {
602
+ let tmpDir;
603
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
604
+ function makeVariant(id, file) {
605
+ return {
606
+ id,
607
+ name: 'returnNull',
608
+ file,
609
+ code: 'return null',
610
+ line: 1,
611
+ col: 0,
612
+ tests: [testFile],
613
+ };
614
+ }
615
+ beforeEach(() => {
616
+ tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-orch-ts-'));
617
+ vi.clearAllMocks();
618
+ process.exitCode = undefined;
619
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
620
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
621
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
622
+ });
623
+ afterEach(() => {
624
+ process.exitCode = undefined;
625
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
626
+ });
627
+ it('filters compile-error variants and populates cache when TS enabled', async () => {
628
+ const sourceFile = path.join(tmpDir, 'source.ts');
629
+ fssync.writeFileSync(sourceFile, 'const x = 1', 'utf8');
630
+ const goodVariant = makeVariant('source.ts#0', sourceFile);
631
+ const badVariant = makeVariant('source.ts#1', sourceFile);
632
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
633
+ targets: [sourceFile],
634
+ testMap: new Map([[sourceFile, new Set([testFile])]]),
635
+ directTestMap: new Map(),
636
+ });
637
+ vi.mocked(enumerateAllVariants).mockResolvedValue([goodVariant, badVariant]);
638
+ vi.mocked(resolveTypescriptEnabled).mockReturnValue(true);
639
+ vi.mocked(checkTypes).mockResolvedValue(new Set(['source.ts#1']));
640
+ vi.mocked(prepareTasks)
641
+ .mockReturnValueOnce([
642
+ { key: 'source.ts#1', v: badVariant, tests: [testFile] },
643
+ ]) // compile-error tasks
644
+ .mockReturnValueOnce([
645
+ { key: 'source.ts#0', v: goodVariant, tests: [testFile] },
646
+ ]); // runnable tasks
647
+ await runOrchestrator([], tmpDir);
648
+ // executePool should only receive the good variant
649
+ const poolCall = vi.mocked(executePool).mock.calls[0][0];
650
+ expect(poolCall.tasks.map((t) => t.key)).toEqual(['source.ts#0']);
651
+ });
652
+ it('logs filtered compile-error count when TS enabled', async () => {
653
+ const sourceFile = path.join(tmpDir, 'source.ts');
654
+ fssync.writeFileSync(sourceFile, 'const x = 1', 'utf8');
655
+ const badVariant = makeVariant('source.ts#0', sourceFile);
656
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
657
+ targets: [sourceFile],
658
+ testMap: new Map([[sourceFile, new Set([testFile])]]),
659
+ directTestMap: new Map(),
660
+ });
661
+ vi.mocked(enumerateAllVariants).mockResolvedValue([badVariant]);
662
+ vi.mocked(resolveTypescriptEnabled).mockReturnValue(true);
663
+ vi.mocked(checkTypes).mockResolvedValue(new Set(['source.ts#0']));
664
+ vi.mocked(prepareTasks).mockReturnValue([
665
+ { key: 'source.ts#0', v: badVariant, tests: [testFile] },
666
+ ]);
667
+ const consoleSpy = vi.spyOn(console, 'log');
668
+ await runOrchestrator([], tmpDir);
669
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('TypeScript: filtered 1 mutant(s) with compile errors'));
670
+ });
671
+ it('calls executePool with empty tasks when all variants are compile errors', async () => {
672
+ const sourceFile = path.join(tmpDir, 'source.ts');
673
+ fssync.writeFileSync(sourceFile, 'const x = 1', 'utf8');
674
+ const badVariant = makeVariant('source.ts#0', sourceFile);
675
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
676
+ targets: [sourceFile],
677
+ testMap: new Map([[sourceFile, new Set([testFile])]]),
678
+ directTestMap: new Map(),
679
+ });
680
+ vi.mocked(enumerateAllVariants).mockResolvedValue([badVariant]);
681
+ vi.mocked(resolveTypescriptEnabled).mockReturnValue(true);
682
+ vi.mocked(checkTypes).mockResolvedValue(new Set(['source.ts#0']));
683
+ // First call: compile-error cache population; second call: runnable variants (empty)
684
+ vi.mocked(prepareTasks)
685
+ .mockReturnValueOnce([
686
+ { key: 'source.ts#0', v: badVariant, tests: [testFile] },
687
+ ])
688
+ .mockReturnValueOnce([]);
689
+ await runOrchestrator([], tmpDir);
690
+ // executePool is still called, but with empty tasks
691
+ const poolCall = vi.mocked(executePool).mock.calls[0][0];
692
+ expect(poolCall.tasks).toEqual([]);
693
+ });
694
+ it('runs TS checking but skips filtering when no compile errors found', async () => {
695
+ const sourceFile = path.join(tmpDir, 'source.ts');
696
+ fssync.writeFileSync(sourceFile, 'const x = 1', 'utf8');
697
+ const goodVariant = makeVariant('source.ts#0', sourceFile);
698
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
699
+ targets: [sourceFile],
700
+ testMap: new Map([[sourceFile, new Set([testFile])]]),
701
+ directTestMap: new Map(),
702
+ });
703
+ vi.mocked(enumerateAllVariants).mockResolvedValue([goodVariant]);
704
+ vi.mocked(resolveTypescriptEnabled).mockReturnValue(true);
705
+ vi.mocked(checkTypes).mockResolvedValue(new Set()); // no compile errors
706
+ vi.mocked(prepareTasks).mockReturnValue([
707
+ { key: 'source.ts#0', v: goodVariant, tests: [testFile] },
708
+ ]);
709
+ await runOrchestrator([], tmpDir);
710
+ // All variants are runnable; executePool receives the full task list
711
+ const poolCall = vi.mocked(executePool).mock.calls[0][0];
712
+ expect(poolCall.tasks.map((t) => t.key)).toEqual(['source.ts#0']);
713
+ });
714
+ });
715
+ describe('runOrchestrator misc branches', () => {
716
+ const targetFile = '/cwd/src/foo.ts';
717
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
718
+ beforeEach(() => {
719
+ vi.clearAllMocks();
720
+ process.exitCode = undefined;
721
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
722
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
723
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
724
+ targets: [targetFile],
725
+ testMap: new Map([[targetFile, new Set([testFile])]]),
726
+ directTestMap: new Map(),
727
+ });
728
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
729
+ // Reset mocks that may carry over from other describe blocks
730
+ vi.mocked(resolveCoverageConfig).mockResolvedValue({
731
+ enableCoverageForBaseline: false,
732
+ wantsPerTestCoverage: false,
733
+ coverageData: null,
734
+ });
735
+ vi.mocked(loadCoverageAfterBaseline).mockResolvedValue({
736
+ coverageData: null,
737
+ perTestCoverage: null,
738
+ });
739
+ vi.mocked(resolveTypescriptEnabled).mockReturnValue(false);
740
+ vi.mocked(checkTypes).mockResolvedValue(new Set());
741
+ vi.mocked(enumerateAllVariants).mockResolvedValue([]);
742
+ vi.mocked(prepareTasks).mockReturnValue([]);
743
+ vi.mocked(getTargetFile).mockImplementation((t) => typeof t === 'string' ? t : t.file);
744
+ });
745
+ afterEach(() => {
746
+ process.exitCode = undefined;
747
+ });
748
+ it('logs "only covered lines" when --only-covered-lines is passed', async () => {
749
+ const consoleSpy = vi.spyOn(console, 'log');
750
+ await runOrchestrator(['--only-covered-lines'], '/cwd');
751
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('only covered lines'));
752
+ });
753
+ it('uses autoDiscover=false to return empty targets when no cfg.targets set', async () => {
754
+ vi.mocked(loadMutineerConfig).mockResolvedValue({ autoDiscover: false });
755
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
756
+ targets: [targetFile],
757
+ testMap: new Map(),
758
+ directTestMap: new Map(),
759
+ });
760
+ await runOrchestrator([], '/cwd');
761
+ // With autoDiscover=false and no cfg.targets, targets is [], no tests found, exit=1
762
+ expect(process.exitCode).toBe(1);
763
+ });
764
+ it('logs coverage collection message when enableCoverageForBaseline is true', async () => {
765
+ vi.mocked(resolveCoverageConfig).mockResolvedValue({
766
+ enableCoverageForBaseline: true,
767
+ wantsPerTestCoverage: false,
768
+ coverageData: null,
769
+ });
770
+ const consoleSpy = vi.spyOn(console, 'log');
771
+ await runOrchestrator([], '/cwd');
772
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('collecting coverage'));
773
+ });
774
+ it('logs "no mutants" with coverage note when coverageData is non-null', async () => {
775
+ vi.mocked(loadCoverageAfterBaseline).mockResolvedValue({
776
+ coverageData: new Map(), // non-null
777
+ perTestCoverage: null,
778
+ });
779
+ const consoleSpy = vi.spyOn(console, 'log');
780
+ await runOrchestrator([], '/cwd');
781
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('uncovered lines'));
782
+ });
783
+ it('collects test files for relative target path', async () => {
784
+ const relTarget = 'src/foo.ts';
785
+ vi.mocked(getTargetFile).mockImplementation((t) => typeof t === 'string' ? relTarget : t.file);
786
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
787
+ targets: [relTarget],
788
+ testMap: new Map([[targetFile, new Set([testFile])]]),
789
+ directTestMap: new Map(),
790
+ });
791
+ // Relative path gets joined with cwd — should match the testMap key
792
+ await runOrchestrator([], '/cwd');
793
+ // runBaseline should be called since test was found via relative path join
794
+ expect(mockAdapter.runBaseline).toHaveBeenCalled();
795
+ });
796
+ });
@@ -211,6 +211,22 @@ describe('executePool', () => {
211
211
  // default file should NOT exist
212
212
  await expect(fs.access(path.join(tmpDir, '.mutineer-cache.json'))).rejects.toThrow();
213
213
  });
214
+ it('writes mutineer-report.json when reportFormat=json without shard', async () => {
215
+ const adapter = makeAdapter();
216
+ const cache = {};
217
+ await executePool({
218
+ tasks: [makeTask({ key: 'json-no-shard-key' })],
219
+ adapter,
220
+ cache,
221
+ concurrency: 1,
222
+ progressMode: 'list',
223
+ cwd: tmpDir,
224
+ reportFormat: 'json',
225
+ });
226
+ const reportFile = path.join(tmpDir, 'mutineer-report.json');
227
+ const content = JSON.parse(await fs.readFile(reportFile, 'utf8'));
228
+ expect(content.schemaVersion).toBe(1);
229
+ });
214
230
  it('writes shard-suffixed JSON report when shard and reportFormat=json are set', async () => {
215
231
  const adapter = makeAdapter();
216
232
  const cache = {};
@@ -516,6 +532,67 @@ describe('executePool', () => {
516
532
  expect(cacheStringifyCalls.length).toBe(1);
517
533
  stringifySpy.mockRestore();
518
534
  });
535
+ it('uses bar mode for Progress when progressMode is bar', async () => {
536
+ const adapter = makeAdapter();
537
+ const cache = {};
538
+ // Should not throw, and bar mode writes to stderr (no isTTY in tests so uses console.log)
539
+ await executePool({
540
+ tasks: [makeTask({ key: 'bar-mode-key' })],
541
+ adapter,
542
+ cache,
543
+ concurrency: 1,
544
+ progressMode: 'bar',
545
+ cwd: tmpDir,
546
+ });
547
+ expect(cache['bar-mode-key'].status).toBe('killed');
548
+ });
549
+ it('stores passingTests when result contains passingTests', async () => {
550
+ const tmpFile = path.join(tmpDir, 'passing.ts');
551
+ await fs.writeFile(tmpFile, 'const x = a + b\n');
552
+ const adapter = makeAdapter({
553
+ runMutant: vi.fn().mockResolvedValue({
554
+ status: 'escaped',
555
+ durationMs: 1,
556
+ passingTests: ['/tests/foo.spec.ts'],
557
+ }),
558
+ });
559
+ const cache = {};
560
+ const task = makeTask({
561
+ key: 'passing-key',
562
+ v: {
563
+ id: 'passing.ts#0',
564
+ name: 'flipArith',
565
+ file: tmpFile,
566
+ code: 'const x = a - b\n',
567
+ line: 1,
568
+ col: 10,
569
+ tests: ['/tests/foo.spec.ts'],
570
+ },
571
+ });
572
+ await executePool({
573
+ tasks: [task],
574
+ adapter,
575
+ cache,
576
+ concurrency: 1,
577
+ progressMode: 'list',
578
+ cwd: tmpDir,
579
+ });
580
+ expect(cache['passing-key'].passingTests).toEqual(['/tests/foo.spec.ts']);
581
+ });
582
+ it('sets exitCode and logs no-mutants note when minKillPercent is set but no mutants evaluated', async () => {
583
+ const adapter = makeAdapter();
584
+ const cache = {};
585
+ await executePool({
586
+ tasks: [],
587
+ adapter,
588
+ cache,
589
+ concurrency: 1,
590
+ progressMode: 'list',
591
+ minKillPercent: 80,
592
+ cwd: tmpDir,
593
+ });
594
+ expect(process.exitCode).toBe(1);
595
+ });
519
596
  it('handles adapter errors gracefully and still shuts down', async () => {
520
597
  const adapter = makeAdapter({
521
598
  runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
@@ -0,0 +1 @@
1
+ export {};