@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
@@ -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 {};
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import path from 'node:path';
3
+ import ts from 'typescript';
4
+ import { diagnosticKey, makeHost, diagnose } from '../ts-checker-worker.js';
5
+ const defaultOptions = {
6
+ strict: true,
7
+ noEmit: true,
8
+ target: ts.ScriptTarget.ES2020,
9
+ };
10
+ describe('diagnosticKey', () => {
11
+ it('returns code:start string for diagnostic with start', () => {
12
+ const d = { code: 2345, start: 10 };
13
+ expect(diagnosticKey(d)).toBe('2345:10');
14
+ });
15
+ it('returns code:-1 when start is undefined', () => {
16
+ const d = { code: 1234, start: undefined };
17
+ expect(diagnosticKey(d)).toBe('1234:-1');
18
+ });
19
+ });
20
+ describe('makeHost', () => {
21
+ it('returns custom source file for the target path', () => {
22
+ const targetPath = path.resolve('/fake/target.ts');
23
+ const code = 'const x: string = 1';
24
+ const host = makeHost(defaultOptions, targetPath, code);
25
+ const sf = host.getSourceFile(targetPath, ts.ScriptTarget.ES2020);
26
+ expect(sf).toBeDefined();
27
+ expect(sf.text).toBe(code);
28
+ });
29
+ it('falls back to orig for non-target files', () => {
30
+ const targetPath = path.resolve('/fake/target.ts');
31
+ const host = makeHost(defaultOptions, targetPath, 'const x = 1');
32
+ // A file that doesn't exist and isn't the target — should return undefined
33
+ const sf = host.getSourceFile('/some/other/file.ts', ts.ScriptTarget.ES2020);
34
+ expect(sf).toBeUndefined();
35
+ });
36
+ });
37
+ describe('diagnose', () => {
38
+ it('returns empty keys for valid TypeScript code', () => {
39
+ const targetPath = path.resolve('/fake/valid.ts');
40
+ const code = 'const x: number = 1\n';
41
+ const { keys } = diagnose(defaultOptions, targetPath, code, undefined);
42
+ expect(keys.size).toBe(0);
43
+ }, 15000);
44
+ it('detects type errors and returns diagnostic keys', () => {
45
+ const targetPath = path.resolve('/fake/invalid.ts');
46
+ const code = 'const x: number = "hello"\n';
47
+ const { keys } = diagnose(defaultOptions, targetPath, code, undefined);
48
+ expect(keys.size).toBeGreaterThan(0);
49
+ for (const key of keys) {
50
+ expect(key).toMatch(/^\d+:\d+$/);
51
+ }
52
+ }, 15000);
53
+ it('returns empty keys when sourceFile not found', () => {
54
+ // diagnose with empty code for a path that resolves to something ts can not find
55
+ const targetPath = path.resolve('/absolutely/nonexistent/path/foo.ts');
56
+ // Pass code that makes it hard for ts to find the sourceFile by alternate path
57
+ const { keys } = diagnose(defaultOptions, targetPath, '', undefined);
58
+ expect(keys instanceof Set).toBe(true);
59
+ }, 15000);
60
+ it('accepts an oldProgram for incremental compilation', () => {
61
+ const targetPath = path.resolve('/fake/incr.ts');
62
+ const { program: p1 } = diagnose(defaultOptions, targetPath, 'const x = 1\n', undefined);
63
+ const { keys } = diagnose(defaultOptions, targetPath, 'const x: string = 1\n', p1);
64
+ expect(keys instanceof Set).toBe(true);
65
+ }, 15000);
66
+ });
@@ -1,6 +1,8 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import path from 'node:path';
3
- import { resolveTypescriptEnabled, resolveTsconfigPath, checkTypes, } from '../ts-checker.js';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import { resolveTypescriptEnabled, resolveTsconfigPath, resolveCompilerOptions, checkTypes, checkFileSync, } from '../ts-checker.js';
4
6
  // Directory with a real tsconfig.json (the project root, 3 levels up from __tests__)
5
7
  const CWD_WITH_TSCONFIG = path.resolve(import.meta.dirname, '../../../');
6
8
  // A temp-like directory unlikely to have tsconfig.json
@@ -17,6 +19,45 @@ function makeVariant(overrides = {}) {
17
19
  ...overrides,
18
20
  };
19
21
  }
22
+ describe('resolveCompilerOptions', () => {
23
+ let tmpDir;
24
+ let brokenTsconfigDir;
25
+ beforeAll(async () => {
26
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-tsco-'));
27
+ brokenTsconfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-tsco-broken-'));
28
+ // Write a broken tsconfig (invalid JSON)
29
+ await fs.writeFile(path.join(brokenTsconfigDir, 'tsconfig.json'), '{ invalid json', 'utf8');
30
+ });
31
+ afterAll(async () => {
32
+ await fs.rm(tmpDir, { recursive: true, force: true });
33
+ await fs.rm(brokenTsconfigDir, { recursive: true, force: true });
34
+ });
35
+ it('returns base options when no tsconfig found in cwd', () => {
36
+ const opts = resolveCompilerOptions(undefined, tmpDir);
37
+ expect(opts.noEmit).toBe(true);
38
+ expect(opts.noLib).toBe(true);
39
+ expect(opts.noResolve).toBe(true);
40
+ });
41
+ it('returns base options when tsconfig has parse errors', () => {
42
+ const opts = resolveCompilerOptions(undefined, brokenTsconfigDir);
43
+ expect(opts.noEmit).toBe(true);
44
+ expect(opts.noLib).toBe(true);
45
+ });
46
+ it('parses real tsconfig and merges options', () => {
47
+ const opts = resolveCompilerOptions(undefined, CWD_WITH_TSCONFIG);
48
+ // Should still override these core isolation settings
49
+ expect(opts.noEmit).toBe(true);
50
+ expect(opts.noLib).toBe(true);
51
+ expect(opts.noResolve).toBe(true);
52
+ expect(opts.skipLibCheck).toBe(true);
53
+ });
54
+ it('uses explicit tsconfig path when provided', () => {
55
+ const tsconfigPath = path.join(CWD_WITH_TSCONFIG, 'tsconfig.json');
56
+ const opts = resolveCompilerOptions(tsconfigPath, CWD_WITH_TSCONFIG);
57
+ expect(opts.noEmit).toBe(true);
58
+ expect(opts.noLib).toBe(true);
59
+ });
60
+ });
20
61
  describe('resolveTsconfigPath', () => {
21
62
  it('returns undefined for boolean true', () => {
22
63
  expect(resolveTsconfigPath({ typescript: true })).toBeUndefined();
@@ -97,6 +138,28 @@ describe('checkTypes', () => {
97
138
  expect(result.has('multi.ts#0')).toBe(false);
98
139
  expect(result.has('multi.ts#1')).toBe(true);
99
140
  }, 15000);
141
+ it('uses tsconfig from CWD_WITH_TSCONFIG when provided', async () => {
142
+ const variant = makeVariant({
143
+ id: 'with-tsconfig.ts#0',
144
+ file: '/nonexistent/with-tsconfig.ts',
145
+ code: 'const x: number = 1',
146
+ });
147
+ // Should not flag valid code even when cwd has tsconfig
148
+ const result = await checkTypes([variant], undefined, CWD_WITH_TSCONFIG);
149
+ expect(result.has('with-tsconfig.ts#0')).toBe(false);
150
+ }, 30000);
151
+ it('filters out mutant errors that were already in baseline', async () => {
152
+ // The original file also has a type error (same code as mutant) — no NEW errors
153
+ const variant = makeVariant({
154
+ id: 'baseline-error.ts#0',
155
+ file: '/nonexistent/baseline-error.ts',
156
+ // The mutant code is IDENTICAL to what we'd read as baseline (empty string)
157
+ // so any errors in the mutant were already in the baseline
158
+ code: 'const x: number = 1', // valid code - no new errors compared to empty baseline
159
+ });
160
+ const result = await checkTypes([variant], undefined, CWD_WITHOUT_TSCONFIG);
161
+ expect(result.has('baseline-error.ts#0')).toBe(false);
162
+ }, 15000);
100
163
  it('checks variants from different files independently', async () => {
101
164
  const validA = makeVariant({
102
165
  id: 'a.ts#0',
@@ -113,3 +176,27 @@ describe('checkTypes', () => {
113
176
  expect(result.has('b.ts#0')).toBe(true);
114
177
  }, 15000);
115
178
  });
179
+ describe('checkFileSync', () => {
180
+ let tmpDir;
181
+ beforeAll(async () => {
182
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-checkfilesync-'));
183
+ });
184
+ afterAll(async () => {
185
+ await fs.rm(tmpDir, { recursive: true, force: true });
186
+ });
187
+ it('does not flag errors that are already present in the baseline file', async () => {
188
+ // Write a baseline file with a type error
189
+ const errorFile = path.join(tmpDir, 'baseline-err.ts');
190
+ await fs.writeFile(errorFile, 'const x: number = "bad string"', 'utf8');
191
+ const options = resolveCompilerOptions(undefined, tmpDir);
192
+ const variant = makeVariant({
193
+ id: 'baseline-err.ts#0',
194
+ file: errorFile,
195
+ // Mutant has the same type error as the baseline file
196
+ code: 'const x: number = "bad string"',
197
+ });
198
+ // The error exists in both baseline and mutant — no NEW errors, so not flagged
199
+ const ids = checkFileSync(options, errorFile, [variant]);
200
+ expect(ids).not.toContain('baseline-err.ts#0');
201
+ }, 15000);
202
+ });
@@ -11,7 +11,7 @@ import type { MutineerConfig } from '../types/config.js';
11
11
  export interface ParsedCliOptions {
12
12
  readonly configPath: string | undefined;
13
13
  readonly wantsChanged: boolean;
14
- readonly wantsChangedWithDeps: boolean;
14
+ readonly wantsChangedWithImports: boolean;
15
15
  readonly wantsFull: boolean;
16
16
  readonly wantsOnlyCoveredLines: boolean;
17
17
  readonly wantsPerTestCoverage: boolean;
@@ -133,7 +133,7 @@ export function parseShardOption(args) {
133
133
  export function parseCliOptions(args, cfg) {
134
134
  const configPath = readStringFlag(args, '--config', '-c');
135
135
  const wantsChanged = args.includes('--changed');
136
- const wantsChangedWithDeps = args.includes('--changed-with-deps');
136
+ const wantsChangedWithImports = args.includes('--changed-with-imports');
137
137
  const wantsFull = args.includes('--full');
138
138
  const wantsOnlyCoveredLines = args.includes('--only-covered-lines') || cfg.onlyCoveredLines === true;
139
139
  const wantsPerTestCoverage = args.includes('--per-test-coverage') || cfg.perTestCoverage === true;
@@ -163,7 +163,7 @@ export function parseCliOptions(args, cfg) {
163
163
  return {
164
164
  configPath,
165
165
  wantsChanged,
166
- wantsChangedWithDeps,
166
+ wantsChangedWithImports,
167
167
  wantsFull,
168
168
  wantsOnlyCoveredLines,
169
169
  wantsPerTestCoverage,
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { createLogger } from '../utils/logger.js';
5
+ import { toErrorMessage } from '../utils/errors.js';
5
6
  // Constants
6
7
  const CONFIG_FILENAMES = [
7
8
  'mutineer.config.ts',
@@ -71,8 +72,7 @@ export async function loadMutineerConfig(cwd, configPath) {
71
72
  return loadedConfig;
72
73
  }
73
74
  catch (err) {
74
- const message = err instanceof Error ? err.message : String(err);
75
- throw new Error(`Failed to load config from ${configFile}: ${message}`);
75
+ throw new Error(`Failed to load config from ${configFile}: ${toErrorMessage(err)}`);
76
76
  }
77
77
  }
78
78
  /**
@@ -88,7 +88,6 @@ async function loadTypeScriptConfig(filePath) {
88
88
  return loaded?.config ?? {};
89
89
  }
90
90
  catch (err) {
91
- const message = err instanceof Error ? err.message : String(err);
92
- throw new Error(`Cannot load TypeScript config. Ensure 'vite' is installed or rename to .js/.mjs:\n${message}`);
91
+ throw new Error(`Cannot load TypeScript config. Ensure 'vite' is installed or rename to .js/.mjs:\n${toErrorMessage(err)}`);
93
92
  }
94
93
  }
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { loadCoverageData, loadPerTestCoverageData, } from '../utils/coverage.js';
3
3
  import { isCoverageRequestedInArgs } from './vitest/index.js';
4
4
  import { createLogger } from '../utils/logger.js';
5
+ import { toErrorMessage } from '../utils/errors.js';
5
6
  const log = createLogger('coverage-resolver');
6
7
  /**
7
8
  * Resolve all coverage-related configuration from CLI options, config, and adapter detection.
@@ -76,7 +77,7 @@ export async function loadCoverageAfterBaseline(resolution, cwd) {
76
77
  log.info(`Loaded coverage for ${coverageData.coveredLines.size} files`);
77
78
  }
78
79
  catch (err) {
79
- const msg = err instanceof Error ? err.message : String(err);
80
+ const msg = toErrorMessage(err);
80
81
  log.warn(`Warning: Could not load coverage data: ${msg}`);
81
82
  log.warn('Continuing without coverage filtering.');
82
83
  }
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import fg from 'fast-glob';
5
5
  import { normalizePath } from '../utils/normalizePath.js';
6
6
  import { createLogger } from '../utils/logger.js';
7
+ import { toErrorMessage } from '../utils/errors.js';
7
8
  const TEST_PATTERNS_DEFAULT = [
8
9
  '**/*.test.[jt]s?(x)',
9
10
  '**/*.spec.[jt]s?(x)',
@@ -99,8 +100,7 @@ async function createViteResolver(rootAbs, exts) {
99
100
  typeof vue === 'function' ? [vue()] : [];
100
101
  }
101
102
  catch (err) {
102
- const detail = err instanceof Error ? err.message : String(err);
103
- log.warn(`Unable to load @vitejs/plugin-vue; Vue SFC imports may fail to resolve (${detail})`);
103
+ log.warn(`Unable to load @vitejs/plugin-vue; Vue SFC imports may fail to resolve (${toErrorMessage(err)})`);
104
104
  }
105
105
  }
106
106
  }
@@ -32,6 +32,15 @@ function makeAdapter(opts = {}) {
32
32
  cliArgs: opts.cliArgs ?? ['--changed'],
33
33
  });
34
34
  }
35
+ function makeAdapterWithArgs(cliArgs) {
36
+ return createJestAdapter({
37
+ cwd: process.cwd(),
38
+ concurrency: 2,
39
+ timeoutMs: 1000,
40
+ config: { jestConfig: undefined },
41
+ cliArgs,
42
+ });
43
+ }
35
44
  describe('Jest adapter', () => {
36
45
  beforeEach(() => {
37
46
  vi.clearAllMocks();
@@ -157,3 +166,163 @@ describe('Jest adapter', () => {
157
166
  }
158
167
  });
159
168
  });
169
+ describe('Jest adapter additional coverage', () => {
170
+ beforeEach(() => {
171
+ vi.clearAllMocks();
172
+ });
173
+ it('strips --min-kill-percent= and --config= and -c= style args', () => {
174
+ const adapter = makeAdapterWithArgs([
175
+ '--min-kill-percent=50',
176
+ '--config=jest.config.ts',
177
+ '-c=x',
178
+ '--verbose',
179
+ ]);
180
+ expect(adapter).toBeDefined();
181
+ });
182
+ it('strips consumeNext args like --concurrency and --runner', () => {
183
+ const adapter = makeAdapterWithArgs([
184
+ '--concurrency',
185
+ '4',
186
+ '--runner',
187
+ 'jest',
188
+ ]);
189
+ expect(adapter).toBeDefined();
190
+ });
191
+ it('runBaseline catches runCLI Error and returns false', async () => {
192
+ const adapter = makeAdapter();
193
+ runCLIMock.mockRejectedValueOnce(new Error('jest crashed'));
194
+ const ok = await adapter.runBaseline(['test.spec.ts'], {
195
+ collectCoverage: false,
196
+ perTestCoverage: false,
197
+ });
198
+ expect(ok).toBe(false);
199
+ });
200
+ it('runBaseline catches non-Error runCLI rejection and returns false', async () => {
201
+ const adapter = makeAdapter();
202
+ runCLIMock.mockRejectedValueOnce('string rejection');
203
+ const ok = await adapter.runBaseline(['test.spec.ts'], {
204
+ collectCoverage: false,
205
+ perTestCoverage: false,
206
+ });
207
+ expect(ok).toBe(false);
208
+ });
209
+ it('makeCapture handles Buffer chunks and callback signatures', async () => {
210
+ const adapter = makeAdapter();
211
+ runCLIMock.mockImplementationOnce(async () => {
212
+ // Buffer chunk (covers Buffer.isBuffer true branch)
213
+ process.stdout.write(Buffer.from('buffered'));
214
+ // Write with encoding arg (covers typeof encodingOrCb !== 'function' → use cb)
215
+ const cb1 = vi.fn();
216
+ process.stdout.write('str-with-encoding', 'utf8', cb1);
217
+ // Write with function as second arg (covers typeof encodingOrCb === 'function')
218
+ const cb2 = vi.fn();
219
+ process.stdout.write('str-with-cb', cb2);
220
+ return { results: { success: false } };
221
+ });
222
+ const writtenStdout = [];
223
+ const spy = vi
224
+ .spyOn(process.stdout, 'write')
225
+ .mockImplementation((chunk) => {
226
+ writtenStdout.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
227
+ return true;
228
+ });
229
+ await adapter.runBaseline(['test.spec.ts'], {
230
+ collectCoverage: false,
231
+ perTestCoverage: false,
232
+ });
233
+ spy.mockRestore();
234
+ expect(writtenStdout.join('')).toContain('buffered');
235
+ });
236
+ it('runMutant throws if init() not called', async () => {
237
+ const adapter = makeAdapter();
238
+ await expect(adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t'])).rejects.toThrow('JestAdapter not initialised');
239
+ });
240
+ it('runMutant returns error on pool.run() Error throw', async () => {
241
+ const adapter = makeAdapter();
242
+ await adapter.init();
243
+ poolInstance.run.mockRejectedValueOnce(new Error('pool crashed'));
244
+ const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
245
+ expect(res).toEqual({
246
+ status: 'error',
247
+ durationMs: 0,
248
+ error: 'pool crashed',
249
+ });
250
+ });
251
+ it('runMutant returns error on pool.run() non-Error throw', async () => {
252
+ const adapter = makeAdapter();
253
+ await adapter.init();
254
+ poolInstance.run.mockRejectedValueOnce('string error');
255
+ const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
256
+ expect(res).toEqual({
257
+ status: 'error',
258
+ durationMs: 0,
259
+ error: 'string error',
260
+ });
261
+ });
262
+ it('runMutant maps escaped result', async () => {
263
+ const adapter = makeAdapter();
264
+ await adapter.init();
265
+ poolInstance.run.mockResolvedValueOnce({ killed: false, durationMs: 8 });
266
+ const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
267
+ expect(res).toEqual({ status: 'escaped', durationMs: 8 });
268
+ });
269
+ it('runMutant maps timeout result', async () => {
270
+ const adapter = makeAdapter();
271
+ await adapter.init();
272
+ poolInstance.run.mockResolvedValueOnce({
273
+ killed: false,
274
+ durationMs: 30000,
275
+ error: 'timeout',
276
+ });
277
+ const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
278
+ expect(res).toEqual({
279
+ status: 'timeout',
280
+ durationMs: 30000,
281
+ error: 'timeout',
282
+ });
283
+ });
284
+ it('shutdown with pool calls pool.shutdown and nulls pool', async () => {
285
+ const adapter = makeAdapter();
286
+ await adapter.init();
287
+ await adapter.shutdown();
288
+ expect(poolInstance.shutdown).toHaveBeenCalledTimes(1);
289
+ });
290
+ it('shutdown with no pool is a no-op', async () => {
291
+ const adapter = makeAdapter();
292
+ await expect(adapter.shutdown()).resolves.toBeUndefined();
293
+ });
294
+ it('hasCoverageProvider returns false when jest not found in cwd', () => {
295
+ const adapter = makeAdapter({ cwd: os.tmpdir() });
296
+ // Resolving jest/package.json from os.tmpdir() should fail
297
+ const result = adapter.hasCoverageProvider();
298
+ expect(result).toBe(false);
299
+ });
300
+ it('detectCoverageConfig returns defaults when no jestConfig set', async () => {
301
+ const adapter = makeAdapter({ config: { jestConfig: undefined } });
302
+ const coverage = await adapter.detectCoverageConfig();
303
+ expect(coverage).toEqual({ perTestEnabled: false, coverageEnabled: false });
304
+ });
305
+ it('detectCoverageConfig returns false on unreadable config file', async () => {
306
+ const adapter = makeAdapter({
307
+ config: { jestConfig: '/nonexistent/path/jest.config.ts' },
308
+ });
309
+ const coverage = await adapter.detectCoverageConfig();
310
+ expect(coverage).toEqual({ perTestEnabled: false, coverageEnabled: false });
311
+ });
312
+ it('detectCoverageConfig detects coverageProvider pattern', async () => {
313
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-jest-'));
314
+ const cfgPath = path.join(tmp, 'jest.config.ts');
315
+ await fs.writeFile(cfgPath, 'module.exports = { coverageProvider: "v8" }');
316
+ try {
317
+ const adapter = makeAdapter({
318
+ cwd: tmp,
319
+ config: { jestConfig: cfgPath },
320
+ });
321
+ const coverage = await adapter.detectCoverageConfig();
322
+ expect(coverage.coverageEnabled).toBe(true);
323
+ }
324
+ finally {
325
+ await fs.rm(tmp, { recursive: true, force: true });
326
+ }
327
+ });
328
+ });