@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
@@ -0,0 +1,47 @@
1
+ const COMMON_CONSUME_NEXT = new Set([
2
+ '--concurrency',
3
+ '--progress',
4
+ '--min-kill-percent',
5
+ '--config',
6
+ '-c',
7
+ '--coverage-file',
8
+ '--report',
9
+ ]);
10
+ const COMMON_DROP_EXACT = new Set([
11
+ '-m',
12
+ '--mutate',
13
+ '--changed',
14
+ '--changed-with-imports',
15
+ '--full',
16
+ '--only-covered-lines',
17
+ '--per-test-coverage',
18
+ '--perTestCoverage',
19
+ ]);
20
+ const COMMON_DROP_PREFIXES = ['--min-kill-percent=', '--config=', '-c='];
21
+ /**
22
+ * Strip mutineer-specific CLI args that shouldn't be passed to the underlying
23
+ * test runner. Callers may supply runner-specific extras via options.
24
+ */
25
+ export function stripMutineerArgs(args, options = {}) {
26
+ const consumeNext = options.extraConsumeNext
27
+ ? new Set([...COMMON_CONSUME_NEXT, ...options.extraConsumeNext])
28
+ : COMMON_CONSUME_NEXT;
29
+ const extraPrefixes = options.extraPrefixes ?? [];
30
+ const allPrefixes = extraPrefixes.length
31
+ ? [...COMMON_DROP_PREFIXES, ...extraPrefixes]
32
+ : COMMON_DROP_PREFIXES;
33
+ const out = [];
34
+ for (let i = 0; i < args.length; i++) {
35
+ const a = args[i];
36
+ if (COMMON_DROP_EXACT.has(a))
37
+ continue;
38
+ if (consumeNext.has(a)) {
39
+ i++;
40
+ continue;
41
+ }
42
+ if (allPrefixes.some((p) => a.startsWith(p)))
43
+ continue;
44
+ out.push(a);
45
+ }
46
+ return out;
47
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Find a worker/loader script by checking .js -> .mjs -> .ts extension fallback.
3
+ * Handles compiled (.js), bundled (.mjs), and source (.ts) environments.
4
+ */
5
+ export declare function resolveWorkerScript(dir: string, basename: string): string;
@@ -0,0 +1,12 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ /**
4
+ * Find a worker/loader script by checking .js -> .mjs -> .ts extension fallback.
5
+ * Handles compiled (.js), bundled (.mjs), and source (.ts) environments.
6
+ */
7
+ export function resolveWorkerScript(dir, basename) {
8
+ const js = path.join(dir, `${basename}.js`);
9
+ const mjs = path.join(dir, `${basename}.mjs`);
10
+ const ts = path.join(dir, `${basename}.ts`);
11
+ return fs.existsSync(js) ? js : fs.existsSync(mjs) ? mjs : ts;
12
+ }
@@ -2,4 +2,13 @@
2
2
  * Worker thread entry point for parallel TypeScript type checking.
3
3
  * Receives one file group, runs baseline + per-variant diagnose, posts results.
4
4
  */
5
- export {};
5
+ import ts from 'typescript';
6
+ /** Stable fingerprint for a diagnostic. */
7
+ export declare function diagnosticKey(d: ts.Diagnostic): string;
8
+ /** Create a compiler host that serves `code` for `targetPath`. */
9
+ export declare function makeHost(options: ts.CompilerOptions, targetPath: string, code: string): ts.CompilerHost;
10
+ /** Run semantic diagnostics for `code` in `targetPath`. */
11
+ export declare function diagnose(options: ts.CompilerOptions, targetPath: string, code: string, oldProgram: ts.Program | undefined): {
12
+ program: ts.Program;
13
+ keys: Set<string>;
14
+ };
@@ -7,11 +7,11 @@ import ts from 'typescript';
7
7
  import path from 'node:path';
8
8
  import fs from 'node:fs';
9
9
  /** Stable fingerprint for a diagnostic. */
10
- function diagnosticKey(d) {
10
+ export function diagnosticKey(d) {
11
11
  return `${d.code}:${d.start ?? -1}`;
12
12
  }
13
13
  /** Create a compiler host that serves `code` for `targetPath`. */
14
- function makeHost(options, targetPath, code) {
14
+ export function makeHost(options, targetPath, code) {
15
15
  const host = ts.createCompilerHost(options);
16
16
  const orig = host.getSourceFile.bind(host);
17
17
  host.getSourceFile = (fileName, langOrOpts) => {
@@ -23,7 +23,7 @@ function makeHost(options, targetPath, code) {
23
23
  return host;
24
24
  }
25
25
  /** Run semantic diagnostics for `code` in `targetPath`. */
26
- function diagnose(options, targetPath, code, oldProgram) {
26
+ export function diagnose(options, targetPath, code, oldProgram) {
27
27
  const host = makeHost(options, targetPath, code);
28
28
  const program = ts.createProgram({
29
29
  rootNames: [targetPath],
@@ -39,28 +39,30 @@ function diagnose(options, targetPath, code, oldProgram) {
39
39
  const keys = new Set(program.getSemanticDiagnostics(sourceFile).map(diagnosticKey));
40
40
  return { program, keys };
41
41
  }
42
- const { options, filePath, variants } = workerData;
43
- const resolvedPath = path.resolve(filePath);
44
- let originalCode = '';
45
- try {
46
- originalCode = fs.readFileSync(resolvedPath, 'utf8');
47
- }
48
- catch {
49
- // empty baseline — all mutant errors count as new
50
- }
51
- const { program: baseProgram, keys: baselineKeys } = diagnose(options, resolvedPath, originalCode, undefined);
52
- let prevProgram = baseProgram;
53
- const compileErrorIds = [];
54
- for (const variant of variants) {
55
- const { program: mutProgram, keys: mutantKeys } = diagnose(options, resolvedPath, variant.code, prevProgram);
56
- prevProgram = mutProgram;
57
- let newErrors = 0;
58
- for (const key of mutantKeys) {
59
- if (!baselineKeys.has(key))
60
- newErrors++;
42
+ if (workerData) {
43
+ const { options, filePath, variants } = workerData;
44
+ const resolvedPath = path.resolve(filePath);
45
+ let originalCode = '';
46
+ try {
47
+ originalCode = fs.readFileSync(resolvedPath, 'utf8');
48
+ }
49
+ catch {
50
+ // empty baseline — all mutant errors count as new
61
51
  }
62
- if (newErrors > 0) {
63
- compileErrorIds.push(variant.id);
52
+ const { program: baseProgram, keys: baselineKeys } = diagnose(options, resolvedPath, originalCode, undefined);
53
+ let prevProgram = baseProgram;
54
+ const compileErrorIds = [];
55
+ for (const variant of variants) {
56
+ const { program: mutProgram, keys: mutantKeys } = diagnose(options, resolvedPath, variant.code, prevProgram);
57
+ prevProgram = mutProgram;
58
+ let newErrors = 0;
59
+ for (const key of mutantKeys) {
60
+ if (!baselineKeys.has(key))
61
+ newErrors++;
62
+ }
63
+ if (newErrors > 0) {
64
+ compileErrorIds.push(variant.id);
65
+ }
64
66
  }
67
+ parentPort.postMessage({ compileErrorIds });
65
68
  }
66
- parentPort.postMessage({ compileErrorIds });
@@ -15,6 +15,12 @@
15
15
  import ts from 'typescript';
16
16
  import type { MutineerConfig } from '../types/config.js';
17
17
  import type { Variant } from '../types/mutant.js';
18
+ /**
19
+ * Compiler options used for all type checks: fully isolated, zero I/O.
20
+ * We read strict/noImplicitAny/etc from the user's tsconfig (to catch the
21
+ * same errors they care about), but always override lib/resolve settings.
22
+ */
23
+ export declare function resolveCompilerOptions(tsconfig: string | undefined, cwd: string): ts.CompilerOptions;
18
24
  /**
19
25
  * Run type checks for one file group synchronously. Used both by the sync
20
26
  * fallback (test/dev environments) and by the worker thread.
@@ -25,7 +25,7 @@ const log = createLogger('ts-checker');
25
25
  * We read strict/noImplicitAny/etc from the user's tsconfig (to catch the
26
26
  * same errors they care about), but always override lib/resolve settings.
27
27
  */
28
- function resolveCompilerOptions(tsconfig, cwd) {
28
+ export function resolveCompilerOptions(tsconfig, cwd) {
29
29
  const base = {
30
30
  noEmit: true,
31
31
  skipLibCheck: true,
@@ -56,6 +56,30 @@ describe('Vitest adapter', () => {
56
56
  const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
57
57
  expect(res).toEqual({ status: 'killed', durationMs: 10, error: undefined });
58
58
  });
59
+ it('includes passingTests in escaped result', async () => {
60
+ const adapter = makeAdapter();
61
+ await adapter.init();
62
+ poolInstance.run.mockResolvedValueOnce({
63
+ killed: false,
64
+ durationMs: 8,
65
+ passingTests: ['Suite > test one', 'Suite > test two'],
66
+ });
67
+ const res = await adapter.runMutant({ id: '2', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
68
+ expect(res.status).toBe('escaped');
69
+ expect(res.passingTests).toEqual(['Suite > test one', 'Suite > test two']);
70
+ });
71
+ it('omits passingTests for killed mutants', async () => {
72
+ const adapter = makeAdapter();
73
+ await adapter.init();
74
+ poolInstance.run.mockResolvedValueOnce({
75
+ killed: true,
76
+ durationMs: 5,
77
+ passingTests: ['should not appear'],
78
+ });
79
+ const res = await adapter.runMutant({ id: '3', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
80
+ expect(res.status).toBe('killed');
81
+ expect(res.passingTests).toBeUndefined();
82
+ });
59
83
  it('maps pool timeout errors to timeout status', async () => {
60
84
  const adapter = makeAdapter();
61
85
  await adapter.init();
@@ -137,6 +161,22 @@ describe('Vitest adapter', () => {
137
161
  const args = spawnMock.mock.calls[0][1];
138
162
  expect(args.join(' ')).not.toContain('--shard');
139
163
  });
164
+ it('strips --report flag and value from vitest args', async () => {
165
+ const adapter = makeAdapter({ cliArgs: ['--report', 'json'] });
166
+ spawnMock.mockImplementationOnce(() => ({
167
+ on: (evt, cb) => {
168
+ if (evt === 'exit')
169
+ cb(0);
170
+ },
171
+ }));
172
+ await adapter.runBaseline(['test-a'], {
173
+ collectCoverage: false,
174
+ perTestCoverage: false,
175
+ });
176
+ const args = spawnMock.mock.calls[0][1];
177
+ expect(args.join(' ')).not.toContain('--report');
178
+ expect(args.join(' ')).not.toContain('json');
179
+ });
140
180
  it('does not write captured output to stdout/stderr on success', async () => {
141
181
  const adapter = makeAdapter({ cliArgs: [] });
142
182
  const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
@@ -258,4 +298,258 @@ describe('isCoverageRequestedInArgs', () => {
258
298
  expect(isCoverageRequestedInArgs(['--coverage.enabled=false'])).toBe(false);
259
299
  expect(isCoverageRequestedInArgs(['--no-coverage'])).toBe(false);
260
300
  });
301
+ it('handles --coverage.enabled with space-separated value', () => {
302
+ expect(isCoverageRequestedInArgs(['--coverage.enabled', 'false'])).toBe(false);
303
+ expect(isCoverageRequestedInArgs(['--coverage.enabled', 'true'])).toBe(true);
304
+ expect(isCoverageRequestedInArgs(['--coverage.enabled', '0'])).toBe(false);
305
+ expect(isCoverageRequestedInArgs(['--coverage.enabled', 'off'])).toBe(false);
306
+ });
307
+ it('handles --coverage= form', () => {
308
+ expect(isCoverageRequestedInArgs(['--coverage=true'])).toBe(true);
309
+ expect(isCoverageRequestedInArgs(['--coverage=false'])).toBe(false);
310
+ expect(isCoverageRequestedInArgs(['--coverage=0'])).toBe(false);
311
+ });
312
+ it('handles --coverage.something form (other coverage flags)', () => {
313
+ expect(isCoverageRequestedInArgs(['--coverage.reporter=json'])).toBe(true);
314
+ expect(isCoverageRequestedInArgs(['--coverage.all=true'])).toBe(true);
315
+ });
316
+ });
317
+ describe('Vitest adapter additional coverage', () => {
318
+ beforeEach(() => {
319
+ vi.clearAllMocks();
320
+ });
321
+ it('strips --min-kill-percent= args from vitest args', async () => {
322
+ const adapter = makeAdapter({ cliArgs: ['--min-kill-percent=80'] });
323
+ spawnMock.mockImplementationOnce(() => ({
324
+ on: (evt, cb) => {
325
+ if (evt === 'exit')
326
+ cb(0);
327
+ },
328
+ }));
329
+ await adapter.runBaseline(['test-a'], {
330
+ collectCoverage: false,
331
+ perTestCoverage: false,
332
+ });
333
+ const args = spawnMock.mock.calls[0][1];
334
+ expect(args.join(' ')).not.toContain('--min-kill-percent');
335
+ });
336
+ it('strips --config= args from vitest args', async () => {
337
+ const adapter = makeAdapter({ cliArgs: ['--config=./custom.vite.ts'] });
338
+ spawnMock.mockImplementationOnce(() => ({
339
+ on: (evt, cb) => {
340
+ if (evt === 'exit')
341
+ cb(0);
342
+ },
343
+ }));
344
+ await adapter.runBaseline(['test-a'], {
345
+ collectCoverage: false,
346
+ perTestCoverage: false,
347
+ });
348
+ const args = spawnMock.mock.calls[0][1];
349
+ expect(args.join(' ')).not.toContain('--config=./custom.vite.ts');
350
+ });
351
+ it('passes through non-mutineer args unchanged', async () => {
352
+ const adapter = makeAdapter({ cliArgs: ['--reporter=verbose'] });
353
+ spawnMock.mockImplementationOnce(() => ({
354
+ on: (evt, cb) => {
355
+ if (evt === 'exit')
356
+ cb(0);
357
+ },
358
+ }));
359
+ await adapter.runBaseline(['test-a'], {
360
+ collectCoverage: false,
361
+ perTestCoverage: false,
362
+ });
363
+ const args = spawnMock.mock.calls[0][1];
364
+ expect(args).toContain('--reporter=verbose');
365
+ });
366
+ it('handles spawn error event in runBaseline', async () => {
367
+ const adapter = makeAdapter({ cliArgs: [] });
368
+ spawnMock.mockImplementationOnce(() => ({
369
+ stdout: { on: () => { } },
370
+ stderr: { on: () => { } },
371
+ on: (evt, cb) => {
372
+ if (evt === 'error')
373
+ cb(new Error('spawn failed'));
374
+ },
375
+ }));
376
+ const result = await adapter.runBaseline(['test-a'], {
377
+ collectCoverage: false,
378
+ perTestCoverage: false,
379
+ });
380
+ expect(result).toBe(false);
381
+ });
382
+ it('throws when runMutant called before init', async () => {
383
+ const adapter = makeAdapter();
384
+ await expect(adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t'])).rejects.toThrow('VitestAdapter not initialised');
385
+ });
386
+ it('shuts down pool on shutdown()', async () => {
387
+ const adapter = makeAdapter();
388
+ await adapter.init();
389
+ await adapter.shutdown();
390
+ expect(poolInstance?.shutdown).toHaveBeenCalledTimes(1);
391
+ });
392
+ it('does nothing on shutdown() when pool is null', async () => {
393
+ const adapter = makeAdapter();
394
+ await expect(adapter.shutdown()).resolves.toBeUndefined();
395
+ });
396
+ it('detectCoverageConfig returns false defaults when no vitestConfig', async () => {
397
+ const adapter = makeAdapter({ config: {} });
398
+ const result = await adapter.detectCoverageConfig();
399
+ expect(result).toEqual({ perTestEnabled: false, coverageEnabled: false });
400
+ });
401
+ it('detectCoverageConfig returns false defaults when config file missing', async () => {
402
+ const adapter = makeAdapter({
403
+ config: { vitestConfig: '/nonexistent/vitest.config.ts' },
404
+ });
405
+ const result = await adapter.detectCoverageConfig();
406
+ expect(result).toEqual({ perTestEnabled: false, coverageEnabled: false });
407
+ });
408
+ it('does not prepend "run" if already present in args', async () => {
409
+ const adapter = makeAdapter({ cliArgs: ['run'] });
410
+ spawnMock.mockImplementationOnce(() => ({
411
+ on: (evt, cb) => {
412
+ if (evt === 'exit')
413
+ cb(0);
414
+ },
415
+ }));
416
+ await adapter.runBaseline(['test-a'], {
417
+ collectCoverage: false,
418
+ perTestCoverage: false,
419
+ });
420
+ const args = spawnMock.mock.calls[0][1];
421
+ const runCount = args.filter((a) => a === 'run').length;
422
+ expect(runCount).toBe(1);
423
+ });
424
+ it('does not add --watch=false if --watch flag already in args', async () => {
425
+ const adapter = makeAdapter({ cliArgs: ['--watch=false'] });
426
+ spawnMock.mockImplementationOnce(() => ({
427
+ on: (evt, cb) => {
428
+ if (evt === 'exit')
429
+ cb(0);
430
+ },
431
+ }));
432
+ await adapter.runBaseline(['test-a'], {
433
+ collectCoverage: false,
434
+ perTestCoverage: false,
435
+ });
436
+ const args = spawnMock.mock.calls[0][1];
437
+ const watchCount = args.filter((a) => a.startsWith('--watch')).length;
438
+ expect(watchCount).toBe(1);
439
+ });
440
+ it('does not add --passWithNoTests if already in args', async () => {
441
+ const adapter = makeAdapter({ cliArgs: ['--passWithNoTests'] });
442
+ spawnMock.mockImplementationOnce(() => ({
443
+ on: (evt, cb) => {
444
+ if (evt === 'exit')
445
+ cb(0);
446
+ },
447
+ }));
448
+ await adapter.runBaseline(['test-a'], {
449
+ collectCoverage: false,
450
+ perTestCoverage: false,
451
+ });
452
+ const args = spawnMock.mock.calls[0][1];
453
+ const count = args.filter((a) => a === '--passWithNoTests').length;
454
+ expect(count).toBe(1);
455
+ });
456
+ it('does not add coverage flags if --coverage already in args during baseline-with-coverage', async () => {
457
+ const adapter = makeAdapter({
458
+ cliArgs: ['--coverage.reporter=html', '--coverage.perTest=true'],
459
+ });
460
+ spawnMock.mockImplementationOnce(() => ({
461
+ on: (evt, cb) => {
462
+ if (evt === 'exit')
463
+ cb(0);
464
+ },
465
+ }));
466
+ await adapter.runBaseline(['test-a'], {
467
+ collectCoverage: true,
468
+ perTestCoverage: true,
469
+ });
470
+ const args = spawnMock.mock.calls[0][1];
471
+ // --coverage.enabled=true should NOT be added since --coverage.reporter is already present
472
+ expect(args).not.toContain('--coverage.enabled=true');
473
+ // --coverage.perTest=true should NOT be added again
474
+ const perTestCount = args.filter((a) => a.startsWith('--coverage.perTest=')).length;
475
+ expect(perTestCount).toBe(1);
476
+ });
477
+ it('does not override CI env var when already set', async () => {
478
+ const originalCI = process.env.CI;
479
+ process.env.CI = 'existing-ci-value';
480
+ try {
481
+ const adapter = makeAdapter({ cliArgs: [] });
482
+ let capturedEnv;
483
+ spawnMock.mockImplementationOnce((_, __, opts) => {
484
+ capturedEnv = opts.env;
485
+ return {
486
+ on: (evt, cb) => {
487
+ if (evt === 'exit')
488
+ cb(0);
489
+ },
490
+ };
491
+ });
492
+ await adapter.runBaseline(['test-a'], {
493
+ collectCoverage: false,
494
+ perTestCoverage: false,
495
+ });
496
+ expect(capturedEnv?.CI).toBe('existing-ci-value');
497
+ }
498
+ finally {
499
+ if (originalCI === undefined)
500
+ delete process.env.CI;
501
+ else
502
+ process.env.CI = originalCI;
503
+ }
504
+ });
505
+ it('runBaseline with non-zero exit and no chunks does not write output', async () => {
506
+ const adapter = makeAdapter({ cliArgs: [] });
507
+ const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
508
+ const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
509
+ spawnMock.mockImplementationOnce(() => ({
510
+ stdout: { on: () => { } },
511
+ stderr: { on: () => { } },
512
+ on: (evt, cb) => {
513
+ if (evt === 'exit')
514
+ cb(1);
515
+ },
516
+ }));
517
+ const ok = await adapter.runBaseline(['test-a'], {
518
+ collectCoverage: false,
519
+ perTestCoverage: false,
520
+ });
521
+ expect(ok).toBe(false);
522
+ expect(stdoutWrite).not.toHaveBeenCalled();
523
+ expect(stderrWrite).not.toHaveBeenCalled();
524
+ stdoutWrite.mockRestore();
525
+ stderrWrite.mockRestore();
526
+ });
527
+ it('returns error status on pool throw with non-Error', async () => {
528
+ const adapter = makeAdapter();
529
+ await adapter.init();
530
+ poolInstance.run.mockRejectedValueOnce('string error');
531
+ const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
532
+ expect(res).toEqual({
533
+ status: 'error',
534
+ durationMs: 0,
535
+ error: 'string error',
536
+ });
537
+ });
538
+ it('detectCoverageConfig returns false when coverage is disabled in config', async () => {
539
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-vitest-'));
540
+ const cfgPath = path.join(tmp, 'vitest.config.ts');
541
+ // content with "coverage: false" matches the exclusion regex
542
+ await fs.writeFile(cfgPath, '// coverage: false');
543
+ try {
544
+ const adapter = makeAdapter({
545
+ cwd: tmp,
546
+ config: { vitestConfig: cfgPath },
547
+ });
548
+ const result = await adapter.detectCoverageConfig();
549
+ expect(result.coverageEnabled).toBe(false);
550
+ }
551
+ finally {
552
+ await fs.rm(tmp, { recursive: true, force: true });
553
+ }
554
+ });
261
555
  });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from 'vitest';
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
3
  import fssync from 'node:fs';
4
4
  import path from 'node:path';
@@ -156,6 +156,33 @@ describe('poolMutineerPlugin', () => {
156
156
  await fs.rm(tmpDir, { recursive: true, force: true });
157
157
  }
158
158
  });
159
+ it('returns null and logs error when readFileSync throws on redirect', async () => {
160
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-plugin-err-'));
161
+ const fromPath = path.join(tmpDir, 'source.ts');
162
+ const mutatedPath = path.join(tmpDir, 'mutated.ts');
163
+ globalThis.__mutineer_redirect__ = {
164
+ from: fromPath,
165
+ to: mutatedPath,
166
+ };
167
+ const readSpy = vi
168
+ .spyOn(fssync, 'readFileSync')
169
+ .mockImplementationOnce(() => {
170
+ throw new Error('disk error');
171
+ });
172
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
173
+ try {
174
+ const load = getLoadFn();
175
+ const result = load?.(fromPath);
176
+ expect(result).toBeNull();
177
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to read mutant file'));
178
+ }
179
+ finally {
180
+ readSpy.mockRestore();
181
+ consoleSpy.mockRestore();
182
+ globalThis.__mutineer_redirect__ = undefined;
183
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
184
+ }
185
+ });
159
186
  describe('config hook', () => {
160
187
  it('returns null when MUTINEER_ACTIVE_ID_FILE is not set', () => {
161
188
  const config = getConfigFn();