@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
@@ -1,6 +1,8 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { EventEmitter } from 'node:events';
3
+ import * as childProcess from 'node:child_process';
3
4
  import { JestPool, runWithJestPool } from '../pool.js';
5
+ vi.mock('node:child_process', () => ({ fork: vi.fn() }));
4
6
  // We'll use the createWorker option to inject mock workers instead of forking processes
5
7
  function makeMockWorker(id) {
6
8
  const worker = {
@@ -30,6 +32,226 @@ const dummyMutant = {
30
32
  line: 1,
31
33
  col: 0,
32
34
  };
35
+ describe('JestWorker via JestPool (real fork mock)', () => {
36
+ const mockProcesses = [];
37
+ beforeEach(() => {
38
+ mockProcesses.length = 0;
39
+ vi.mocked(childProcess.fork).mockImplementation(() => {
40
+ const proc = new EventEmitter();
41
+ proc.stderr = new EventEmitter();
42
+ proc.send = vi.fn();
43
+ proc.kill = vi.fn();
44
+ proc.pid = 12345;
45
+ mockProcesses.push(proc);
46
+ return proc;
47
+ });
48
+ });
49
+ afterEach(() => {
50
+ vi.restoreAllMocks();
51
+ });
52
+ async function initWorkerPool(opts = {}) {
53
+ const pool = new JestPool({
54
+ cwd: opts.cwd ?? '/test',
55
+ concurrency: 1,
56
+ jestConfig: opts.jestConfig,
57
+ });
58
+ const initP = pool.init();
59
+ await new Promise((r) => setImmediate(r));
60
+ mockProcesses[0].emit('message', { type: 'ready' });
61
+ await initP;
62
+ return pool;
63
+ }
64
+ async function shutdownWorkerPool(pool) {
65
+ const p = pool.shutdown();
66
+ await new Promise((r) => setImmediate(r));
67
+ mockProcesses[mockProcesses.length - 1].emit('message', {
68
+ type: 'shutdown',
69
+ });
70
+ await p;
71
+ }
72
+ it('starts worker and emits ready via IPC', async () => {
73
+ const pool = await initWorkerPool();
74
+ expect(mockProcesses).toHaveLength(1);
75
+ expect(mockProcesses[0].send).toBeDefined();
76
+ await shutdownWorkerPool(pool);
77
+ });
78
+ it('passes jestConfig to env', async () => {
79
+ let capturedEnv;
80
+ vi.mocked(childProcess.fork).mockImplementationOnce((_script, _args, opts) => {
81
+ capturedEnv = opts?.env;
82
+ const proc = new EventEmitter();
83
+ proc.stderr = new EventEmitter();
84
+ proc.send = vi.fn();
85
+ proc.kill = vi.fn();
86
+ proc.pid = 99;
87
+ mockProcesses.push(proc);
88
+ return proc;
89
+ });
90
+ const pool = new JestPool({
91
+ cwd: '/proj',
92
+ concurrency: 1,
93
+ jestConfig: 'jest.config.ts',
94
+ });
95
+ const initP = pool.init();
96
+ await new Promise((r) => setImmediate(r));
97
+ expect(capturedEnv?.MUTINEER_JEST_CONFIG).toBe('jest.config.ts');
98
+ mockProcesses[0].emit('message', { type: 'ready' });
99
+ await initP;
100
+ await shutdownWorkerPool(pool);
101
+ });
102
+ it('handleMessage: result with pending task resolves run()', async () => {
103
+ const pool = await initWorkerPool();
104
+ const runP = pool.run(dummyMutant, ['t']);
105
+ await new Promise((r) => setImmediate(r));
106
+ mockProcesses[0].emit('message', {
107
+ type: 'result',
108
+ killed: true,
109
+ durationMs: 42,
110
+ });
111
+ const result = await runP;
112
+ expect(result.killed).toBe(true);
113
+ expect(result.durationMs).toBe(42);
114
+ await shutdownWorkerPool(pool);
115
+ });
116
+ it('handleMessage: result with nullish killed/durationMs uses defaults', async () => {
117
+ const pool = await initWorkerPool();
118
+ const runP = pool.run(dummyMutant, ['t']);
119
+ await new Promise((r) => setImmediate(r));
120
+ mockProcesses[0].emit('message', { type: 'result' });
121
+ const result = await runP;
122
+ expect(result.killed).toBe(true); // ?? true default
123
+ expect(result.durationMs).toBe(0); // ?? 0 default
124
+ await shutdownWorkerPool(pool);
125
+ });
126
+ it('handleMessage: result when no pending task is silently ignored', async () => {
127
+ const pool = await initWorkerPool();
128
+ mockProcesses[0].emit('message', {
129
+ type: 'result',
130
+ killed: true,
131
+ durationMs: 5,
132
+ });
133
+ expect(true).toBe(true);
134
+ await shutdownWorkerPool(pool);
135
+ });
136
+ it('handleMessage: shutdown type triggers shutdown resolution', async () => {
137
+ const pool = await initWorkerPool();
138
+ const shutdownP = pool.shutdown();
139
+ await new Promise((r) => setImmediate(r));
140
+ mockProcesses[0].emit('message', { type: 'shutdown' });
141
+ await shutdownP;
142
+ expect(true).toBe(true);
143
+ });
144
+ it('process error event triggers handleExit and rejects pending run', async () => {
145
+ const pool = await initWorkerPool();
146
+ const runP = pool.run(dummyMutant, ['t']);
147
+ await new Promise((r) => setImmediate(r));
148
+ mockProcesses[0].emit('error', new Error('ENOENT'));
149
+ await expect(runP).rejects.toThrow('Worker exited unexpectedly');
150
+ });
151
+ it('process exit event with code triggers handleExit', async () => {
152
+ const pool = await initWorkerPool();
153
+ const runP = pool.run(dummyMutant, ['t']);
154
+ await new Promise((r) => setImmediate(r));
155
+ mockProcesses[0].emit('exit', 1);
156
+ await expect(runP).rejects.toThrow('Worker exited unexpectedly with code 1');
157
+ });
158
+ it('process exit with null code uses ?? 1 fallback', async () => {
159
+ const pool = await initWorkerPool();
160
+ const runP = pool.run(dummyMutant, ['t']);
161
+ await new Promise((r) => setImmediate(r));
162
+ mockProcesses[0].emit('exit', null);
163
+ await expect(runP).rejects.toThrow('Worker exited unexpectedly with code 1');
164
+ });
165
+ it('handleExit during shutdown skips pending rejection', async () => {
166
+ const pool = await initWorkerPool();
167
+ const shutdownP = pool.shutdown();
168
+ await new Promise((r) => setImmediate(r));
169
+ // emit exit while shutting down — handleExit called with shuttingDown=true
170
+ mockProcesses[0].emit('exit', 0);
171
+ // emit shutdown to resolve
172
+ mockProcesses[0].emit('message', { type: 'shutdown' });
173
+ await shutdownP;
174
+ expect(true).toBe(true);
175
+ });
176
+ it('pool.run() throws when pool is shutting down', async () => {
177
+ const pool = await initWorkerPool();
178
+ pool.shutdown(); // don't await
179
+ await expect(pool.run(dummyMutant, ['t'])).rejects.toThrow('Pool is shutting down');
180
+ });
181
+ it('acquireWorker queues second run when single worker is busy', async () => {
182
+ const pool = await initWorkerPool();
183
+ const [run1, run2] = [
184
+ pool.run(dummyMutant, ['t']),
185
+ pool.run({ ...dummyMutant, id: '2' }, ['t']),
186
+ ];
187
+ await new Promise((r) => setImmediate(r));
188
+ // resolve first run
189
+ mockProcesses[0].emit('message', {
190
+ type: 'result',
191
+ killed: true,
192
+ durationMs: 5,
193
+ });
194
+ await new Promise((r) => setImmediate(r));
195
+ // resolve second run
196
+ mockProcesses[0].emit('message', {
197
+ type: 'result',
198
+ killed: false,
199
+ durationMs: 3,
200
+ });
201
+ const [r1, r2] = await Promise.all([run1, run2]);
202
+ expect(r1.killed).toBe(true);
203
+ expect(r2.killed).toBe(false);
204
+ await shutdownWorkerPool(pool);
205
+ });
206
+ it('releaseWorker skips non-ready worker', async () => {
207
+ // Use createWorker mock where worker becomes not-ready after run
208
+ let workerReady = true;
209
+ const pool = new JestPool({
210
+ cwd: '/test',
211
+ concurrency: 1,
212
+ createWorker: (id) => {
213
+ const w = new EventEmitter();
214
+ w.id = id;
215
+ w.start = vi.fn().mockResolvedValue(undefined);
216
+ w.isReady = vi.fn(() => workerReady);
217
+ w.isBusy = vi.fn().mockReturnValue(false);
218
+ w.run = vi.fn().mockImplementation(async () => {
219
+ workerReady = false;
220
+ return { killed: true, durationMs: 1 };
221
+ });
222
+ w.shutdown = vi.fn().mockResolvedValue(undefined);
223
+ w.kill = vi.fn();
224
+ return w;
225
+ },
226
+ });
227
+ await pool.init();
228
+ const result = await pool.run(dummyMutant, ['t']);
229
+ expect(result.killed).toBe(true);
230
+ await pool.shutdown();
231
+ });
232
+ it('runWithJestPool maps non-Error exception to error status', async () => {
233
+ const pool = new JestPool({
234
+ cwd: '/test',
235
+ concurrency: 1,
236
+ createWorker: (id) => {
237
+ const w = new EventEmitter();
238
+ w.id = id;
239
+ w.start = vi.fn().mockResolvedValue(undefined);
240
+ w.isReady = vi.fn().mockReturnValue(true);
241
+ w.isBusy = vi.fn().mockReturnValue(false);
242
+ w.run = vi.fn().mockRejectedValue('string error');
243
+ w.shutdown = vi.fn().mockResolvedValue(undefined);
244
+ w.kill = vi.fn();
245
+ return w;
246
+ },
247
+ });
248
+ await pool.init();
249
+ const result = await runWithJestPool(pool, dummyMutant, ['t']);
250
+ expect(result.status).toBe('error');
251
+ expect(result.error).toBe('string error');
252
+ await pool.shutdown();
253
+ });
254
+ });
33
255
  describe('JestPool', () => {
34
256
  it('throws if run is called before init', async () => {
35
257
  const pool = new JestPool({ cwd: '/tmp', concurrency: 1 });
@@ -9,51 +9,11 @@
9
9
  import fs from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import { createRequire } from 'node:module';
12
- // import os from 'node:os'
13
12
  import { JestPool } from './pool.js';
14
13
  import { createLogger } from '../../utils/logger.js';
14
+ import { toErrorMessage } from '../../utils/errors.js';
15
15
  const require = createRequire(import.meta.url);
16
16
  const log = createLogger('jest-adapter');
17
- /**
18
- * Strip mutineer-specific CLI args that shouldn't be passed to Jest.
19
- */
20
- function stripMutineerArgs(args) {
21
- const out = [];
22
- const consumeNext = new Set([
23
- '--concurrency',
24
- '--progress',
25
- '--min-kill-percent',
26
- '--config',
27
- '-c',
28
- '--coverage-file',
29
- '--runner',
30
- ]);
31
- const dropExact = new Set([
32
- '-m',
33
- '--mutate',
34
- '--changed',
35
- '--changed-with-deps',
36
- '--full',
37
- '--only-covered-lines',
38
- '--per-test-coverage',
39
- '--perTestCoverage',
40
- ]);
41
- for (let i = 0; i < args.length; i++) {
42
- const a = args[i];
43
- if (dropExact.has(a))
44
- continue;
45
- if (consumeNext.has(a)) {
46
- i++;
47
- continue;
48
- }
49
- if (a.startsWith('--min-kill-percent='))
50
- continue;
51
- if (a.startsWith('--config=') || a.startsWith('-c='))
52
- continue;
53
- out.push(a);
54
- }
55
- return out;
56
- }
57
17
  async function loadRunCLI(requireFromCwd) {
58
18
  try {
59
19
  return requireFromCwd('@jest/core');
@@ -91,7 +51,6 @@ export class JestAdapter {
91
51
  this.pool = null;
92
52
  this.options = options;
93
53
  this.jestConfigPath = options.config.jestConfig;
94
- stripMutineerArgs(options.cliArgs);
95
54
  this.requireFromCwd = createRequire(path.join(options.cwd, 'package.json'));
96
55
  }
97
56
  async init(concurrencyOverride) {
@@ -132,8 +91,7 @@ export class JestAdapter {
132
91
  success = results.success;
133
92
  }
134
93
  catch (err) {
135
- log.debug('Failed to run Jest baseline: ' +
136
- (err instanceof Error ? err.message : String(err)));
94
+ log.debug('Failed to run Jest baseline: ' + toErrorMessage(err));
137
95
  }
138
96
  finally {
139
97
  process.stdout.write = origStdoutWrite;
@@ -176,7 +134,7 @@ export class JestAdapter {
176
134
  return {
177
135
  status: 'error',
178
136
  durationMs: 0,
179
- error: err instanceof Error ? err.message : String(err),
137
+ error: toErrorMessage(err),
180
138
  };
181
139
  }
182
140
  }
@@ -1,9 +1,10 @@
1
1
  import { fork } from 'node:child_process';
2
2
  import * as path from 'node:path';
3
- import * as fs from 'node:fs';
4
3
  import { fileURLToPath } from 'node:url';
5
4
  import { EventEmitter } from 'node:events';
6
5
  import { createLogger, DEBUG } from '../../utils/logger.js';
6
+ import { resolveWorkerScript } from '../shared/index.js';
7
+ import { toErrorMessage } from '../../utils/errors.js';
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const workerLog = createLogger('JestWorker');
9
10
  const poolLog = createLogger('JestPool');
@@ -19,14 +20,7 @@ class JestWorker extends EventEmitter {
19
20
  this.id = id;
20
21
  }
21
22
  async start() {
22
- const workerJs = path.join(__dirname, 'worker.js');
23
- const workerMts = path.join(__dirname, 'worker.mjs');
24
- const workerTs = path.join(__dirname, 'worker.mts');
25
- const workerScript = fs.existsSync(workerJs)
26
- ? workerJs
27
- : fs.existsSync(workerMts)
28
- ? workerMts
29
- : workerTs;
23
+ const workerScript = resolveWorkerScript(__dirname, 'worker');
30
24
  const env = {
31
25
  ...process.env,
32
26
  MUTINEER_WORKER_ID: this.id,
@@ -301,7 +295,7 @@ export async function runWithJestPool(pool, mutant, tests) {
301
295
  return {
302
296
  status: 'error',
303
297
  durationMs: 0,
304
- error: err instanceof Error ? err.message : String(err),
298
+ error: toErrorMessage(err),
305
299
  };
306
300
  }
307
301
  }
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { getMutantFilePath, setRedirect, clearRedirect, } from '../shared/index.js';
6
6
  import { createLogger } from '../../utils/logger.js';
7
+ import { toErrorMessage } from '../../utils/errors.js';
7
8
  const log = createLogger('jest-runtime');
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
@@ -76,7 +77,7 @@ export class JestWorkerRuntime {
76
77
  return {
77
78
  killed: true,
78
79
  durationMs: Date.now() - start,
79
- error: err instanceof Error ? err.message : String(err),
80
+ error: toErrorMessage(err),
80
81
  };
81
82
  }
82
83
  finally {
@@ -61,18 +61,18 @@ export async function runOrchestrator(cliArgs, cwd) {
61
61
  const coverage = await resolveCoverageConfig(opts, cfg, adapter, cliArgs);
62
62
  if (process.exitCode)
63
63
  return; // resolveCoverageConfig sets exitCode on fatal errors
64
- log.info(`Mutineer starting in ${opts.wantsChangedWithDeps
65
- ? 'changed files with dependencies'
64
+ log.info(`Mutineer starting in ${opts.wantsChangedWithImports
65
+ ? 'changed files with imports'
66
66
  : opts.wantsChanged
67
67
  ? 'changed files only'
68
68
  : 'full'} mode${opts.wantsOnlyCoveredLines ? ' (only covered lines)' : ''}...`);
69
69
  log.info(`Using concurrency=${opts.concurrency} (cpus=${os.cpus().length})`);
70
70
  // 3. Enumerate changed files if requested
71
- const changedAbs = opts.wantsChanged || opts.wantsChangedWithDeps
71
+ const changedAbs = opts.wantsChanged || opts.wantsChangedWithImports
72
72
  ? new Set(listChangedFiles(cwd, {
73
- includeDeps: opts.wantsChangedWithDeps,
73
+ includeDeps: opts.wantsChangedWithImports,
74
74
  baseRef: cfg.baseRef,
75
- maxDepth: cfg.dependencyDepth,
75
+ maxDepth: cfg.importDepth,
76
76
  }))
77
77
  : null;
78
78
  // 4. Discover targets and tests
@@ -85,6 +85,7 @@ export async function runOrchestrator(cliArgs, cwd) {
85
85
  : (cfg.autoDiscover ?? true)
86
86
  ? discovered.targets
87
87
  : [];
88
+ targets.sort((a, b) => getTargetFile(a).localeCompare(getTargetFile(b)));
88
89
  // Collect all test files for baseline run
89
90
  const allTestFiles = new Set();
90
91
  for (const target of targets) {
@@ -99,7 +100,7 @@ export async function runOrchestrator(cliArgs, cwd) {
99
100
  }
100
101
  }
101
102
  const baselineTests = Array.from(allTestFiles);
102
- if (opts.wantsChangedWithDeps) {
103
+ if (opts.wantsChangedWithImports) {
103
104
  let uncoveredCount = 0;
104
105
  for (const target of targets) {
105
106
  const absFile = normalizePath(path.isAbsolute(getTargetFile(target))
@@ -111,7 +112,7 @@ export async function runOrchestrator(cliArgs, cwd) {
111
112
  }
112
113
  }
113
114
  if (uncoveredCount > 0) {
114
- log.info(`${uncoveredCount} target(s) from --changed-with-deps have no covering tests and will be skipped`);
115
+ log.info(`${uncoveredCount} target(s) from --changed-with-imports have no covering tests and will be skipped`);
115
116
  }
116
117
  }
117
118
  if (!baselineTests.length) {
@@ -7,7 +7,6 @@ import { computeSummary, printSummary, buildJsonReport, } from '../utils/summary
7
7
  import { saveCacheAtomic } from './cache.js';
8
8
  import { cleanupMutineerDirs } from './cleanup.js';
9
9
  import { PoolSpinner } from '../utils/PoolSpinner.js';
10
- import { CompileErrors } from '../utils/CompileErrors.js';
11
10
  import { createLogger } from '../utils/logger.js';
12
11
  const log = createLogger('pool-executor');
13
12
  /**
@@ -24,7 +23,7 @@ export async function executePool(opts) {
24
23
  const mutationStartTime = Date.now();
25
24
  // Ensure we only finish once
26
25
  let finished = false;
27
- const finishOnce = async (interactive = true) => {
26
+ const finishOnce = async () => {
28
27
  if (finished)
29
28
  return;
30
29
  finished = true;
@@ -41,15 +40,7 @@ export async function executePool(opts) {
41
40
  log.info(`JSON report written to ${path.relative(process.cwd(), outPath)}`);
42
41
  }
43
42
  else {
44
- const compileErrorEntries = Object.values(cache).filter((e) => e.status === 'compile-error');
45
- const useInteractive = interactive && process.stdout.isTTY && compileErrorEntries.length > 0;
46
- printSummary(summary, cache, durationMs, {
47
- skipCompileErrors: useInteractive,
48
- });
49
- if (useInteractive) {
50
- const { waitUntilExit } = render(createElement(CompileErrors, { entries: compileErrorEntries, cwd }));
51
- await waitUntilExit();
52
- }
43
+ printSummary(summary, cache, durationMs);
53
44
  }
54
45
  if (opts.minKillPercent !== undefined) {
55
46
  const killRateString = summary.killRate.toFixed(2);
@@ -151,6 +142,10 @@ export async function executePool(opts) {
151
142
  (directTests ?? tests).length > 0 && {
152
143
  coveringTests: directTests ?? tests,
153
144
  }),
145
+ ...(status === 'escaped' &&
146
+ result.passingTests?.length && {
147
+ passingTests: result.passingTests,
148
+ }),
154
149
  };
155
150
  progress.update(status);
156
151
  }
@@ -170,7 +165,7 @@ export async function executePool(opts) {
170
165
  return;
171
166
  signalCleanedUp = true;
172
167
  log.info(`\nReceived ${signal}, cleaning up...`);
173
- await finishOnce(false);
168
+ await finishOnce();
174
169
  await adapter.shutdown();
175
170
  await cleanupMutineerDirs(cwd);
176
171
  process.exit(1);
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { stripMutineerArgs } from '../strip-mutineer-args.js';
3
+ describe('stripMutineerArgs', () => {
4
+ describe('dropExact flags', () => {
5
+ it('drops -m', () => {
6
+ expect(stripMutineerArgs(['-m', '--verbose'])).toEqual(['--verbose']);
7
+ });
8
+ it('drops --mutate', () => {
9
+ expect(stripMutineerArgs(['--mutate', '--verbose'])).toEqual([
10
+ '--verbose',
11
+ ]);
12
+ });
13
+ it('drops --changed', () => {
14
+ expect(stripMutineerArgs(['--changed'])).toEqual([]);
15
+ });
16
+ it('drops --changed-with-imports', () => {
17
+ expect(stripMutineerArgs(['--changed-with-imports'])).toEqual([]);
18
+ });
19
+ it('drops --full', () => {
20
+ expect(stripMutineerArgs(['--full'])).toEqual([]);
21
+ });
22
+ it('drops --only-covered-lines', () => {
23
+ expect(stripMutineerArgs(['--only-covered-lines'])).toEqual([]);
24
+ });
25
+ it('drops --per-test-coverage', () => {
26
+ expect(stripMutineerArgs(['--per-test-coverage'])).toEqual([]);
27
+ });
28
+ it('drops --perTestCoverage', () => {
29
+ expect(stripMutineerArgs(['--perTestCoverage'])).toEqual([]);
30
+ });
31
+ });
32
+ describe('consumeNext flags (drops flag and its value)', () => {
33
+ it('drops --concurrency and next token', () => {
34
+ expect(stripMutineerArgs(['--concurrency', '4', '--verbose'])).toEqual([
35
+ '--verbose',
36
+ ]);
37
+ });
38
+ it('drops --progress and next token', () => {
39
+ expect(stripMutineerArgs(['--progress', 'list'])).toEqual([]);
40
+ });
41
+ it('drops --min-kill-percent and next token', () => {
42
+ expect(stripMutineerArgs(['--min-kill-percent', '80', '--verbose'])).toEqual(['--verbose']);
43
+ });
44
+ it('drops --config and next token', () => {
45
+ expect(stripMutineerArgs(['--config', 'vitest.config.ts', '--verbose'])).toEqual(['--verbose']);
46
+ });
47
+ it('drops -c and next token', () => {
48
+ expect(stripMutineerArgs(['-c', 'vitest.config.ts'])).toEqual([]);
49
+ });
50
+ it('drops --coverage-file and next token', () => {
51
+ expect(stripMutineerArgs(['--coverage-file', 'cov.json'])).toEqual([]);
52
+ });
53
+ it('drops --report and next token', () => {
54
+ expect(stripMutineerArgs(['--report', 'json', '--verbose'])).toEqual([
55
+ '--verbose',
56
+ ]);
57
+ });
58
+ });
59
+ describe('prefix-based drops', () => {
60
+ it('drops --min-kill-percent=N', () => {
61
+ expect(stripMutineerArgs(['--min-kill-percent=80', '--verbose'])).toEqual(['--verbose']);
62
+ });
63
+ it('drops --config=path', () => {
64
+ expect(stripMutineerArgs(['--config=vitest.config.ts', '--verbose'])).toEqual(['--verbose']);
65
+ });
66
+ it('drops -c=path', () => {
67
+ expect(stripMutineerArgs(['-c=vitest.config.ts'])).toEqual([]);
68
+ });
69
+ });
70
+ describe('pass-through', () => {
71
+ it('keeps unrecognised args unchanged', () => {
72
+ expect(stripMutineerArgs(['--reporter=verbose', '--bail=1'])).toEqual([
73
+ '--reporter=verbose',
74
+ '--bail=1',
75
+ ]);
76
+ });
77
+ it('returns empty array for empty input', () => {
78
+ expect(stripMutineerArgs([])).toEqual([]);
79
+ });
80
+ });
81
+ describe('extraConsumeNext option', () => {
82
+ it('drops extra consume-next flag and its value', () => {
83
+ expect(stripMutineerArgs(['--runner', 'jest', '--verbose'], {
84
+ extraConsumeNext: ['--runner'],
85
+ })).toEqual(['--verbose']);
86
+ });
87
+ it('drops extra consume-next at end of array without panicking', () => {
88
+ expect(stripMutineerArgs(['--runner'], { extraConsumeNext: ['--runner'] })).toEqual([]);
89
+ });
90
+ });
91
+ describe('extraPrefixes option', () => {
92
+ it('drops args matching an extra prefix', () => {
93
+ expect(stripMutineerArgs(['--shard=1/4', '--verbose'], {
94
+ extraPrefixes: ['--shard='],
95
+ })).toEqual(['--verbose']);
96
+ });
97
+ it('drops consume-next and prefix for same runner-specific flag', () => {
98
+ expect(stripMutineerArgs(['--shard', '1/4', '--shard=1/4', '--verbose'], {
99
+ extraConsumeNext: ['--shard'],
100
+ extraPrefixes: ['--shard='],
101
+ })).toEqual(['--verbose']);
102
+ });
103
+ });
104
+ });
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import path from 'node:path';
3
+ vi.mock('node:fs');
4
+ import fs from 'node:fs';
5
+ import { resolveWorkerScript } from '../worker-script.js';
6
+ const existsSync = vi.mocked(fs.existsSync);
7
+ beforeEach(() => {
8
+ existsSync.mockReset();
9
+ });
10
+ describe('resolveWorkerScript', () => {
11
+ const dir = '/some/dir';
12
+ it('returns .js path when .js exists', () => {
13
+ existsSync.mockImplementation((p) => p === path.join(dir, 'worker.js'));
14
+ expect(resolveWorkerScript(dir, 'worker')).toBe(path.join(dir, 'worker.js'));
15
+ });
16
+ it('returns .mjs path when .js absent but .mjs exists', () => {
17
+ existsSync.mockImplementation((p) => p === path.join(dir, 'worker.mjs'));
18
+ expect(resolveWorkerScript(dir, 'worker')).toBe(path.join(dir, 'worker.mjs'));
19
+ });
20
+ it('falls back to .ts path when neither .js nor .mjs exist', () => {
21
+ existsSync.mockReturnValue(false);
22
+ expect(resolveWorkerScript(dir, 'worker')).toBe(path.join(dir, 'worker.ts'));
23
+ });
24
+ it('checks .js before .mjs', () => {
25
+ existsSync.mockReturnValue(true);
26
+ expect(resolveWorkerScript(dir, 'worker')).toBe(path.join(dir, 'worker.js'));
27
+ });
28
+ it('uses the provided basename', () => {
29
+ existsSync.mockReturnValue(false);
30
+ expect(resolveWorkerScript(dir, 'redirect-loader')).toBe(path.join(dir, 'redirect-loader.ts'));
31
+ });
32
+ });
@@ -7,3 +7,7 @@
7
7
  export { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from './mutant-paths.js';
8
8
  export { setRedirect, getRedirect, clearRedirect, initialiseRedirectState, } from './redirect-state.js';
9
9
  export type { RedirectConfig } from './redirect-state.js';
10
+ export { stripMutineerArgs } from './strip-mutineer-args.js';
11
+ export type { StripMutineerArgsOptions } from './strip-mutineer-args.js';
12
+ export { resolveWorkerScript } from './worker-script.js';
13
+ export type { PendingTask } from './pending-task.js';
@@ -6,3 +6,5 @@
6
6
  */
7
7
  export { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from './mutant-paths.js';
8
8
  export { setRedirect, getRedirect, clearRedirect, initialiseRedirectState, } from './redirect-state.js';
9
+ export { stripMutineerArgs } from './strip-mutineer-args.js';
10
+ export { resolveWorkerScript } from './worker-script.js';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Represents a pending async task in a worker pool.
3
+ * Shared by Vitest and Jest pool implementations.
4
+ */
5
+ export interface PendingTask<TResult> {
6
+ resolve: (result: TResult) => void;
7
+ reject: (error: Error) => void;
8
+ timeoutHandle: NodeJS.Timeout | null;
9
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ export interface StripMutineerArgsOptions {
2
+ /** Runner-specific args that consume the next token and should be dropped. */
3
+ extraConsumeNext?: Iterable<string>;
4
+ /** Runner-specific prefixes whose matching args should be dropped. */
5
+ extraPrefixes?: string[];
6
+ }
7
+ /**
8
+ * Strip mutineer-specific CLI args that shouldn't be passed to the underlying
9
+ * test runner. Callers may supply runner-specific extras via options.
10
+ */
11
+ export declare function stripMutineerArgs(args: string[], options?: StripMutineerArgsOptions): string[];