@mutineerjs/mutineer 0.9.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 (83) hide show
  1. package/README.md +52 -47
  2. package/dist/__tests__/index.spec.js +8 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +7 -7
  4. package/dist/bin/mutineer.d.ts +1 -1
  5. package/dist/bin/mutineer.js +7 -4
  6. package/dist/core/__tests__/schemata.spec.js +62 -0
  7. package/dist/core/__tests__/sfc.spec.js +41 -1
  8. package/dist/core/schemata.js +15 -21
  9. package/dist/core/sfc.js +0 -4
  10. package/dist/core/variant-utils.js +0 -4
  11. package/dist/mutators/__tests__/utils.spec.js +65 -1
  12. package/dist/mutators/operator.js +13 -27
  13. package/dist/mutators/return-value.js +3 -7
  14. package/dist/mutators/utils.d.ts +2 -2
  15. package/dist/mutators/utils.js +59 -96
  16. package/dist/runner/__tests__/args.spec.js +8 -4
  17. package/dist/runner/__tests__/cache.spec.js +24 -0
  18. package/dist/runner/__tests__/changed.spec.js +75 -0
  19. package/dist/runner/__tests__/config.spec.js +50 -1
  20. package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
  21. package/dist/runner/__tests__/discover.spec.js +179 -0
  22. package/dist/runner/__tests__/orchestrator.spec.js +336 -11
  23. package/dist/runner/__tests__/pool-executor.spec.js +77 -0
  24. package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
  25. package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
  26. package/dist/runner/__tests__/ts-checker.spec.js +89 -2
  27. package/dist/runner/args.d.ts +1 -1
  28. package/dist/runner/args.js +2 -2
  29. package/dist/runner/config.js +3 -4
  30. package/dist/runner/coverage-resolver.js +2 -1
  31. package/dist/runner/discover.js +2 -2
  32. package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
  33. package/dist/runner/jest/__tests__/pool.spec.js +223 -1
  34. package/dist/runner/jest/adapter.js +3 -45
  35. package/dist/runner/jest/pool.js +4 -10
  36. package/dist/runner/jest/worker-runtime.js +2 -1
  37. package/dist/runner/orchestrator.js +8 -7
  38. package/dist/runner/pool-executor.js +7 -12
  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 +294 -0
  56. package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
  57. package/dist/runner/vitest/__tests__/pool.spec.js +711 -0
  58. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
  59. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +81 -0
  60. package/dist/runner/vitest/adapter.js +14 -46
  61. package/dist/runner/vitest/plugin.js +1 -7
  62. package/dist/runner/vitest/pool.js +6 -19
  63. package/dist/runner/vitest/redirect-loader.js +3 -1
  64. package/dist/runner/vitest/worker-runtime.js +16 -1
  65. package/dist/runner/vitest/worker.mjs +1 -0
  66. package/dist/types/config.d.ts +2 -2
  67. package/dist/types/mutant.d.ts +3 -0
  68. package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
  69. package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
  70. package/dist/utils/__tests__/coverage.spec.js +89 -0
  71. package/dist/utils/__tests__/logger.spec.js +9 -0
  72. package/dist/utils/__tests__/progress.spec.js +38 -0
  73. package/dist/utils/__tests__/summary.spec.js +70 -31
  74. package/dist/utils/coverage.js +3 -4
  75. package/dist/utils/errors.d.ts +4 -0
  76. package/dist/utils/errors.js +6 -0
  77. package/dist/utils/summary.d.ts +2 -3
  78. package/dist/utils/summary.js +5 -6
  79. package/package.json +1 -1
  80. package/dist/utils/CompileErrors.d.ts +0 -7
  81. package/dist/utils/CompileErrors.js +0 -24
  82. package/dist/utils/__tests__/CompileErrors.spec.js +0 -96
  83. /package/dist/{utils/__tests__/CompileErrors.spec.d.ts → __tests__/index.spec.d.ts} +0 -0
@@ -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');
@@ -62,9 +62,7 @@ vi.mock('../ts-checker.js', () => ({
62
62
  resolveTsconfigPath: vi.fn().mockReturnValue(undefined),
63
63
  }));
64
64
  vi.mock('../../core/schemata.js', () => ({
65
- generateSchema: vi
66
- .fn()
67
- .mockReturnValue({
65
+ generateSchema: vi.fn().mockReturnValue({
68
66
  schemaCode: '// @ts-nocheck\n',
69
67
  fallbackIds: new Set(),
70
68
  }),
@@ -72,11 +70,14 @@ vi.mock('../../core/schemata.js', () => ({
72
70
  import { runOrchestrator, parseMutantTimeoutMs } from '../orchestrator.js';
73
71
  import { loadMutineerConfig } from '../config.js';
74
72
  import { createVitestAdapter } from '../vitest/index.js';
73
+ import { createJestAdapter } from '../jest/index.js';
75
74
  import { autoDiscoverTargetsAndTests } from '../discover.js';
76
75
  import { listChangedFiles } from '../changed.js';
77
76
  import { executePool } from '../pool-executor.js';
78
77
  import { prepareTasks } from '../tasks.js';
79
- 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';
80
81
  import { generateSchema } from '../../core/schemata.js';
81
82
  import os from 'node:os';
82
83
  import fssync from 'node:fs';
@@ -185,8 +186,8 @@ describe('runOrchestrator discovery logging', () => {
185
186
  expect(consoleSpy).toHaveBeenCalledWith('Discovery complete: 3 source file(s), 2 test file(s)');
186
187
  });
187
188
  });
188
- describe('runOrchestrator --changed-with-deps diagnostic', () => {
189
- 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 () => {
190
191
  const cfg = {};
191
192
  vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
192
193
  const depFile = '/cwd/src/dep.ts';
@@ -197,10 +198,10 @@ describe('runOrchestrator --changed-with-deps diagnostic', () => {
197
198
  directTestMap: new Map(),
198
199
  });
199
200
  const consoleSpy = vi.spyOn(console, 'log');
200
- await runOrchestrator(['--changed-with-deps'], '/cwd');
201
- 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'));
202
203
  });
203
- 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 () => {
204
205
  const cfg = {};
205
206
  vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
206
207
  const depFile = '/cwd/src/dep.ts';
@@ -213,11 +214,11 @@ describe('runOrchestrator --changed-with-deps diagnostic', () => {
213
214
  });
214
215
  vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
215
216
  const consoleSpy = vi.spyOn(console, 'log');
216
- await runOrchestrator(['--changed-with-deps'], '/cwd');
217
+ await runOrchestrator(['--changed-with-imports'], '/cwd');
217
218
  const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
218
219
  expect(diagnosticCalls).toHaveLength(0);
219
220
  });
220
- it('does not log diagnostic when wantsChangedWithDeps is false', async () => {
221
+ it('does not log diagnostic when wantsChangedWithImports is false', async () => {
221
222
  const cfg = {};
222
223
  vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
223
224
  const depFile = '/cwd/src/dep.ts';
@@ -334,6 +335,40 @@ describe('runOrchestrator shard filtering', () => {
334
335
  expect(call.shard).toEqual({ index: 1, total: 4 });
335
336
  });
336
337
  });
338
+ describe('runOrchestrator target ordering', () => {
339
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
340
+ beforeEach(() => {
341
+ vi.clearAllMocks();
342
+ process.exitCode = undefined;
343
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
344
+ // No explicit targets -- use auto-discovery
345
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
346
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
347
+ vi.mocked(enumerateAllVariants).mockResolvedValue([]);
348
+ vi.mocked(prepareTasks).mockReturnValue([]);
349
+ });
350
+ afterEach(() => {
351
+ process.exitCode = undefined;
352
+ });
353
+ it('sorts auto-discovered targets alphabetically before enumeration', async () => {
354
+ const fileA = '/cwd/src/aaa.ts';
355
+ const fileB = '/cwd/src/bbb.ts';
356
+ const fileC = '/cwd/src/ccc.ts';
357
+ // Return targets in reverse-alphabetical order (simulating non-deterministic fs)
358
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
359
+ targets: [fileC, fileA, fileB],
360
+ testMap: new Map([
361
+ [fileA, new Set([testFile])],
362
+ [fileB, new Set([testFile])],
363
+ [fileC, new Set([testFile])],
364
+ ]),
365
+ directTestMap: new Map(),
366
+ });
367
+ await runOrchestrator([], '/cwd');
368
+ const call = vi.mocked(enumerateAllVariants).mock.calls[0][0];
369
+ expect(call.targets).toEqual([fileA, fileB, fileC]);
370
+ });
371
+ });
337
372
  describe('runOrchestrator --skip-baseline', () => {
338
373
  const targetFile = '/cwd/src/foo.ts';
339
374
  const testFile = '/cwd/src/__tests__/foo.spec.ts';
@@ -469,3 +504,293 @@ describe('runOrchestrator schema generation', () => {
469
504
  expect(mockLogDebug).toHaveBeenCalledWith(expect.stringMatching(/Schema: 1 embedded, 0 fallback/));
470
505
  });
471
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
+ });