@mutineerjs/mutineer 0.6.0 → 0.8.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 (69) hide show
  1. package/README.md +32 -15
  2. package/dist/bin/__tests__/mutineer.spec.js +67 -2
  3. package/dist/bin/mutineer.d.ts +6 -1
  4. package/dist/bin/mutineer.js +58 -2
  5. package/dist/core/__tests__/schemata.spec.d.ts +1 -0
  6. package/dist/core/__tests__/schemata.spec.js +165 -0
  7. package/dist/core/schemata.d.ts +22 -0
  8. package/dist/core/schemata.js +236 -0
  9. package/dist/mutators/__tests__/operator.spec.js +97 -1
  10. package/dist/mutators/__tests__/registry.spec.js +8 -0
  11. package/dist/mutators/operator.d.ts +8 -0
  12. package/dist/mutators/operator.js +58 -1
  13. package/dist/mutators/registry.js +9 -1
  14. package/dist/mutators/utils.d.ts +2 -0
  15. package/dist/mutators/utils.js +58 -1
  16. package/dist/runner/__tests__/args.spec.js +89 -1
  17. package/dist/runner/__tests__/cache.spec.js +65 -8
  18. package/dist/runner/__tests__/cleanup.spec.js +37 -0
  19. package/dist/runner/__tests__/coverage-resolver.spec.js +5 -0
  20. package/dist/runner/__tests__/discover.spec.js +128 -0
  21. package/dist/runner/__tests__/orchestrator.spec.js +332 -2
  22. package/dist/runner/__tests__/pool-executor.spec.js +107 -1
  23. package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
  24. package/dist/runner/__tests__/ts-checker.spec.js +115 -0
  25. package/dist/runner/args.d.ts +18 -0
  26. package/dist/runner/args.js +37 -0
  27. package/dist/runner/cache.d.ts +19 -3
  28. package/dist/runner/cache.js +14 -7
  29. package/dist/runner/cleanup.d.ts +3 -1
  30. package/dist/runner/cleanup.js +19 -2
  31. package/dist/runner/coverage-resolver.js +1 -1
  32. package/dist/runner/discover.d.ts +1 -1
  33. package/dist/runner/discover.js +30 -20
  34. package/dist/runner/orchestrator.d.ts +1 -0
  35. package/dist/runner/orchestrator.js +114 -19
  36. package/dist/runner/pool-executor.d.ts +7 -0
  37. package/dist/runner/pool-executor.js +29 -7
  38. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  39. package/dist/runner/shared/index.d.ts +1 -1
  40. package/dist/runner/shared/index.js +1 -1
  41. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  42. package/dist/runner/shared/mutant-paths.js +24 -0
  43. package/dist/runner/ts-checker-worker.d.ts +5 -0
  44. package/dist/runner/ts-checker-worker.js +66 -0
  45. package/dist/runner/ts-checker.d.ts +36 -0
  46. package/dist/runner/ts-checker.js +210 -0
  47. package/dist/runner/types.d.ts +2 -0
  48. package/dist/runner/vitest/__tests__/adapter.spec.js +41 -0
  49. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  50. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  51. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  52. package/dist/runner/vitest/adapter.js +14 -9
  53. package/dist/runner/vitest/plugin.d.ts +3 -0
  54. package/dist/runner/vitest/plugin.js +49 -11
  55. package/dist/runner/vitest/pool.d.ts +4 -1
  56. package/dist/runner/vitest/pool.js +25 -4
  57. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  58. package/dist/runner/vitest/worker-runtime.js +57 -18
  59. package/dist/runner/vitest/worker.mjs +10 -0
  60. package/dist/types/config.d.ts +16 -0
  61. package/dist/types/mutant.d.ts +5 -2
  62. package/dist/utils/CompileErrors.d.ts +7 -0
  63. package/dist/utils/CompileErrors.js +24 -0
  64. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  65. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  66. package/dist/utils/__tests__/summary.spec.js +126 -2
  67. package/dist/utils/summary.d.ts +23 -1
  68. package/dist/utils/summary.js +63 -3
  69. package/package.json +2 -1
@@ -160,4 +160,89 @@ describe('VitestPool', () => {
160
160
  const result = await runWithPool(mockPool, mutant, []);
161
161
  expect(result.status).toBe('error');
162
162
  });
163
+ it('passes MUTINEER_ACTIVE_ID_FILE env var to worker process', async () => {
164
+ let capturedEnv;
165
+ vi.mocked(childProcess.spawn).mockImplementationOnce((_cmd, _args, options) => {
166
+ capturedEnv = options?.env;
167
+ const proc = new EventEmitter();
168
+ proc.stdout = new EventEmitter();
169
+ proc.stderr = new EventEmitter();
170
+ proc.stdin = { writes: [], write: vi.fn() };
171
+ proc.kill = vi.fn();
172
+ return proc;
173
+ });
174
+ const rl = new EventEmitter();
175
+ vi.mocked(readline.createInterface).mockImplementationOnce(() => rl);
176
+ const cwd = '/my/project';
177
+ const pool = new VitestPool({ cwd, concurrency: 1 });
178
+ // Start init — synchronous part (spawn + createInterface) runs immediately
179
+ const initPromise = pool.init();
180
+ // Give event loop one tick so the async suspend in worker.start() is reached
181
+ await new Promise((resolve) => setImmediate(resolve));
182
+ // Verify env var was passed to spawn before completing init
183
+ expect(capturedEnv?.MUTINEER_ACTIVE_ID_FILE).toBe('/my/project/__mutineer__/active_id_w0.txt');
184
+ // Emit ready to unblock initPromise
185
+ rl.emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
186
+ await initPromise;
187
+ // Shutdown: emit the shutdown response so worker.shutdown() resolves
188
+ const shutdownPromise = pool.shutdown();
189
+ await new Promise((resolve) => setImmediate(resolve));
190
+ rl.emit('line', JSON.stringify({ type: 'shutdown', ok: true }));
191
+ await shutdownPromise;
192
+ });
193
+ it('spawns workers with detached: true for process group isolation', async () => {
194
+ let capturedOptions;
195
+ vi.mocked(childProcess.spawn).mockImplementationOnce((_cmd, _args, options) => {
196
+ capturedOptions = options;
197
+ const proc = new EventEmitter();
198
+ proc.stdout = new EventEmitter();
199
+ proc.stderr = new EventEmitter();
200
+ proc.stdin = { writes: [], write: vi.fn() };
201
+ proc.kill = vi.fn();
202
+ return proc;
203
+ });
204
+ const rl = new EventEmitter();
205
+ vi.mocked(readline.createInterface).mockImplementationOnce(() => rl);
206
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1 });
207
+ const initPromise = pool.init();
208
+ await new Promise((r) => setImmediate(r));
209
+ expect(capturedOptions?.detached).toBe(true);
210
+ rl.emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
211
+ await initPromise;
212
+ const shutdownPromise = pool.shutdown();
213
+ await new Promise((r) => setImmediate(r));
214
+ rl.emit('line', JSON.stringify({ type: 'shutdown', ok: true }));
215
+ await shutdownPromise;
216
+ });
217
+ it('kills entire process group (negative PID) on mutant run timeout', async () => {
218
+ const mockProc = new EventEmitter();
219
+ mockProc.stdout = new EventEmitter();
220
+ mockProc.stderr = new EventEmitter();
221
+ mockProc.stdin = { writes: [], write: vi.fn() };
222
+ mockProc.kill = vi.fn();
223
+ mockProc.pid = 42000;
224
+ vi.mocked(childProcess.spawn).mockReturnValueOnce(mockProc);
225
+ const rl = new EventEmitter();
226
+ vi.mocked(readline.createInterface).mockReturnValueOnce(rl);
227
+ const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true);
228
+ // Use a very short timeoutMs so the test doesn't wait long
229
+ const pool = new VitestPool({ cwd: '/test', concurrency: 1, timeoutMs: 50 });
230
+ const initPromise = pool.init();
231
+ await new Promise((r) => setImmediate(r));
232
+ rl.emit('line', JSON.stringify({ type: 'ready', workerId: 'w0' }));
233
+ await initPromise;
234
+ const mutant = {
235
+ id: 't1',
236
+ name: 'test',
237
+ file: 'a.ts',
238
+ code: 'x',
239
+ line: 1,
240
+ col: 1,
241
+ };
242
+ // Don't emit a 'result' line — let the 50ms timeout fire and call kill()
243
+ const result = await pool.run(mutant, ['a.spec.ts']);
244
+ expect(result).toMatchObject({ error: 'timeout' });
245
+ expect(killSpy).toHaveBeenCalledWith(-42000, 'SIGKILL');
246
+ killSpy.mockRestore();
247
+ });
163
248
  });
@@ -95,6 +95,15 @@ describe('VitestWorkerRuntime', () => {
95
95
  expect(createVitest).toHaveBeenCalledWith('test', expect.objectContaining({ config: '/custom/vitest.config.ts' }), expect.any(Object));
96
96
  await runtime.shutdown();
97
97
  });
98
+ it('passes maxWorkers: 1 and watch: false to createVitest to prevent fork resource contention and FS watcher re-runs', async () => {
99
+ const { createVitest } = await import('vitest/node');
100
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-'));
101
+ tmpFiles.push(tmp);
102
+ const runtime = createVitestWorkerRuntime({ workerId: 'w-mw', cwd: tmp });
103
+ await runtime.init();
104
+ expect(createVitest).toHaveBeenCalledWith('test', expect.objectContaining({ maxWorkers: 1, watch: false }), expect.any(Object));
105
+ await runtime.shutdown();
106
+ });
98
107
  it('handles non-Error thrown during run', async () => {
99
108
  runSpecsFn.mockRejectedValue('string error');
100
109
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-'));
@@ -156,4 +165,121 @@ describe('VitestWorkerRuntime', () => {
156
165
  expect(result.killed).toBe(false);
157
166
  await runtime.shutdown();
158
167
  });
168
+ it('writes setup.mjs when MUTINEER_ACTIVE_ID_FILE is set', async () => {
169
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-setup-'));
170
+ tmpFiles.push(tmp);
171
+ const activeIdFile = path.join(tmp, '__mutineer__', 'active_id_wx.txt');
172
+ const origEnv = process.env.MUTINEER_ACTIVE_ID_FILE;
173
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
174
+ try {
175
+ const runtime = createVitestWorkerRuntime({ workerId: 'wx', cwd: tmp });
176
+ await runtime.init();
177
+ const setupFile = path.join(tmp, '__mutineer__', 'setup.mjs');
178
+ expect(fs.existsSync(setupFile)).toBe(true);
179
+ expect(fs.readFileSync(setupFile, 'utf8')).toContain('beforeAll');
180
+ await runtime.shutdown();
181
+ }
182
+ finally {
183
+ if (origEnv === undefined) {
184
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
185
+ }
186
+ else {
187
+ process.env.MUTINEER_ACTIVE_ID_FILE = origEnv;
188
+ }
189
+ }
190
+ });
191
+ it('uses schema path when isFallback is false and MUTINEER_ACTIVE_ID_FILE is set', async () => {
192
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-schema-'));
193
+ tmpFiles.push(tmp);
194
+ const activeIdFile = path.join(tmp, '__mutineer__', 'active_id_ws.txt');
195
+ const origEnv = process.env.MUTINEER_ACTIVE_ID_FILE;
196
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
197
+ try {
198
+ const runtime = createVitestWorkerRuntime({ workerId: 'ws', cwd: tmp });
199
+ await runtime.init();
200
+ await runtime.run({
201
+ id: 'mut#schema',
202
+ name: 'm',
203
+ file: path.join(tmp, 'src.ts'),
204
+ code: 'export const x=1',
205
+ line: 1,
206
+ col: 1,
207
+ isFallback: false,
208
+ }, [path.join(tmp, 'test.ts')]);
209
+ // invalidateFile should NOT be called for schema path
210
+ expect(invalidateFn).not.toHaveBeenCalled();
211
+ // Active ID file should be cleared after run
212
+ expect(fs.readFileSync(activeIdFile, 'utf8')).toBe('');
213
+ await runtime.shutdown();
214
+ }
215
+ finally {
216
+ if (origEnv === undefined) {
217
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
218
+ }
219
+ else {
220
+ process.env.MUTINEER_ACTIVE_ID_FILE = origEnv;
221
+ }
222
+ }
223
+ });
224
+ it('uses fallback path when isFallback is true even if MUTINEER_ACTIVE_ID_FILE is set', async () => {
225
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-fb-'));
226
+ tmpFiles.push(tmp);
227
+ const activeIdFile = path.join(tmp, '__mutineer__', 'active_id_wf.txt');
228
+ const origEnv = process.env.MUTINEER_ACTIVE_ID_FILE;
229
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
230
+ try {
231
+ const runtime = createVitestWorkerRuntime({ workerId: 'wf', cwd: tmp });
232
+ await runtime.init();
233
+ await runtime.run({
234
+ id: 'mut#fb',
235
+ name: 'm',
236
+ file: path.join(tmp, 'src.ts'),
237
+ code: 'export const x=1',
238
+ line: 1,
239
+ col: 1,
240
+ isFallback: true,
241
+ }, [path.join(tmp, 'test.ts')]);
242
+ // invalidateFile SHOULD be called for fallback path
243
+ expect(invalidateFn).toHaveBeenCalledWith(path.join(tmp, 'src.ts'));
244
+ await runtime.shutdown();
245
+ }
246
+ finally {
247
+ if (origEnv === undefined) {
248
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
249
+ }
250
+ else {
251
+ process.env.MUTINEER_ACTIVE_ID_FILE = origEnv;
252
+ }
253
+ }
254
+ });
255
+ it('clears state.filesMap before each run to prevent memory accumulation', async () => {
256
+ const { createVitest } = await import('vitest/node');
257
+ const clearFn = vi.fn();
258
+ vi.mocked(createVitest).mockResolvedValueOnce({
259
+ init: initFn,
260
+ close: closeFn,
261
+ runTestSpecifications: runSpecsFn,
262
+ invalidateFile: invalidateFn,
263
+ getProjectByName: getProjectByNameFn,
264
+ state: { filesMap: { clear: clearFn } },
265
+ });
266
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-state-'));
267
+ tmpFiles.push(tmp);
268
+ const runtime = createVitestWorkerRuntime({ workerId: 'w-state', cwd: tmp });
269
+ await runtime.init();
270
+ const mutant = {
271
+ id: 'mut#state',
272
+ name: 'm',
273
+ file: path.join(tmp, 'src.ts'),
274
+ code: 'export const x=1',
275
+ line: 1,
276
+ col: 1,
277
+ };
278
+ await runtime.run(mutant, [path.join(tmp, 'test.ts')]);
279
+ await runtime.run({ ...mutant, id: 'mut#state2' }, [
280
+ path.join(tmp, 'test.ts'),
281
+ ]);
282
+ expect(clearFn).toHaveBeenCalledTimes(2);
283
+ await runtime.shutdown();
284
+ });
159
285
  });
@@ -37,6 +37,7 @@ function stripMutineerArgs(args) {
37
37
  '--config',
38
38
  '-c',
39
39
  '--coverage-file',
40
+ '--shard',
40
41
  ]);
41
42
  const dropExact = new Set([
42
43
  '-m',
@@ -59,6 +60,8 @@ function stripMutineerArgs(args) {
59
60
  continue;
60
61
  if (a.startsWith('--config=') || a.startsWith('-c='))
61
62
  continue;
63
+ if (a.startsWith('--shard='))
64
+ continue;
62
65
  out.push(a);
63
66
  }
64
67
  return out;
@@ -127,6 +130,7 @@ export class VitestAdapter {
127
130
  cwd: this.options.cwd,
128
131
  concurrency: workerCount,
129
132
  vitestConfig: this.options.config.vitestConfig,
133
+ vitestProject: this.options.vitestProject,
130
134
  timeoutMs: this.options.timeoutMs,
131
135
  });
132
136
  await this.pool.init();
@@ -196,15 +200,16 @@ export class VitestAdapter {
196
200
  }
197
201
  }
198
202
  hasCoverageProvider() {
199
- try {
200
- require.resolve('@vitest/coverage-v8/package.json', {
201
- paths: [this.options.cwd],
202
- });
203
- return true;
204
- }
205
- catch {
206
- return false;
207
- }
203
+ const packages = ['@vitest/coverage-v8', '@vitest/coverage-istanbul'];
204
+ return packages.some((pkg) => {
205
+ try {
206
+ require.resolve(`${pkg}/package.json`, { paths: [this.options.cwd] });
207
+ return true;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ });
208
213
  }
209
214
  async detectCoverageConfig() {
210
215
  const configPath = this.options.config.vitestConfig;
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * The worker process sets globalThis.__mutineer_redirect__ before each test run,
9
9
  * and this plugin intercepts module loading to return the mutated code.
10
+ *
11
+ * For schema-eligible variants, the plugin serves a pre-built schema file that
12
+ * embeds all mutations as ternary chains keyed by globalThis.__mutineer_active_id__.
10
13
  */
11
14
  import type { PluginOption } from 'vite';
12
15
  export declare function poolMutineerPlugin(): PluginOption;
@@ -7,22 +7,41 @@
7
7
  *
8
8
  * The worker process sets globalThis.__mutineer_redirect__ before each test run,
9
9
  * and this plugin intercepts module loading to return the mutated code.
10
+ *
11
+ * For schema-eligible variants, the plugin serves a pre-built schema file that
12
+ * embeds all mutations as ternary chains keyed by globalThis.__mutineer_active_id__.
10
13
  */
11
14
  import * as fs from 'node:fs';
12
15
  import * as path from 'node:path';
13
- import { getRedirect } from '../shared/index.js';
16
+ import { getRedirect, getSchemaFilePath } from '../shared/index.js';
14
17
  import { createLogger } from '../../utils/logger.js';
15
18
  const log = createLogger('mutineer:swap');
16
19
  export function poolMutineerPlugin() {
20
+ // Cache schema file contents keyed by normalised source path.
21
+ // null = checked and no schema exists; string = schema code.
22
+ // Schema files are written once before the pool starts and never change,
23
+ // so this cache is always valid for the lifetime of the plugin.
24
+ const schemaCache = new Map();
17
25
  return {
18
26
  name: 'mutineer:swap',
19
27
  enforce: 'pre',
20
- load(id) {
21
- const redirect = getRedirect();
22
- if (!redirect) {
28
+ config(config) {
29
+ const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
30
+ if (!activeIdFile || !path.isAbsolute(activeIdFile))
23
31
  return null;
24
- }
25
- // Normalize the module ID, handling query strings
32
+ const setupFile = path.join(path.dirname(activeIdFile), 'setup.mjs');
33
+ const testConfig = config.test;
34
+ const existing = testConfig?.setupFiles;
35
+ const existingArr = Array.isArray(existing)
36
+ ? existing
37
+ : existing
38
+ ? [existing]
39
+ : [];
40
+ return {
41
+ test: { setupFiles: [...existingArr, setupFile] },
42
+ };
43
+ },
44
+ load(id) {
26
45
  const cleanId = id.split('?')[0];
27
46
  let normalizedId;
28
47
  try {
@@ -31,18 +50,37 @@ export function poolMutineerPlugin() {
31
50
  catch {
32
51
  return null;
33
52
  }
34
- // Check if this is the file we're redirecting
35
- if (normalizedId === path.resolve(redirect.from)) {
36
- // Read the mutated code from the temp file
53
+ // Redirect takes priority: fallback mutations use setRedirect + invalidateFile.
54
+ // Must check redirect first so the schema file (which exists for this source)
55
+ // does not shadow the mutant code during fallback runs.
56
+ const redirect = getRedirect();
57
+ if (redirect && normalizedId === path.resolve(redirect.from)) {
37
58
  try {
38
- const mutatedCode = fs.readFileSync(redirect.to, 'utf8');
39
- return mutatedCode;
59
+ return fs.readFileSync(redirect.to, 'utf8');
40
60
  }
41
61
  catch (err) {
42
62
  log.error(`Failed to read mutant file: ${redirect.to} ${err}`);
43
63
  return null;
44
64
  }
45
65
  }
66
+ // Schema path: serves pre-built schema file for schema-eligible variants.
67
+ // Use cache to avoid existsSync + readFileSync on every module import.
68
+ const cached = schemaCache.get(normalizedId);
69
+ if (cached !== undefined) {
70
+ return cached;
71
+ }
72
+ const schemaPath = getSchemaFilePath(normalizedId);
73
+ try {
74
+ if (fs.existsSync(schemaPath)) {
75
+ const code = fs.readFileSync(schemaPath, 'utf8');
76
+ schemaCache.set(normalizedId, code);
77
+ return code;
78
+ }
79
+ }
80
+ catch {
81
+ // fall through
82
+ }
83
+ schemaCache.set(normalizedId, null);
46
84
  return null;
47
85
  },
48
86
  };
@@ -16,13 +16,14 @@ import type { MutantPayload, MutantRunResult, MutantRunSummary } from '../../typ
16
16
  declare class VitestWorker extends EventEmitter {
17
17
  private readonly cwd;
18
18
  private readonly vitestConfig?;
19
+ private readonly vitestProject?;
19
20
  readonly id: string;
20
21
  private process;
21
22
  private rl;
22
23
  private pendingTask;
23
24
  private ready;
24
25
  private shuttingDown;
25
- constructor(id: string, cwd: string, vitestConfig?: string | undefined);
26
+ constructor(id: string, cwd: string, vitestConfig?: string | undefined, vitestProject?: string | undefined);
26
27
  start(): Promise<void>;
27
28
  private handleMessage;
28
29
  private handleExit;
@@ -36,10 +37,12 @@ export interface VitestPoolOptions {
36
37
  cwd: string;
37
38
  concurrency: number;
38
39
  vitestConfig?: string;
40
+ vitestProject?: string;
39
41
  timeoutMs?: number;
40
42
  createWorker?: (id: string, opts: {
41
43
  cwd: string;
42
44
  vitestConfig?: string;
45
+ vitestProject?: string;
43
46
  }) => VitestWorker;
44
47
  }
45
48
  export declare class VitestPool {
@@ -17,15 +17,17 @@ import * as readline from 'node:readline';
17
17
  import * as fs from 'node:fs';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { EventEmitter } from 'node:events';
20
+ import { getActiveIdFilePath } from '../shared/index.js';
20
21
  import { createLogger, DEBUG } from '../../utils/logger.js';
21
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
23
  const workerLog = createLogger('VitestWorker');
23
24
  const poolLog = createLogger('VitestPool');
24
25
  class VitestWorker extends EventEmitter {
25
- constructor(id, cwd, vitestConfig) {
26
+ constructor(id, cwd, vitestConfig, vitestProject) {
26
27
  super();
27
28
  this.cwd = cwd;
28
29
  this.vitestConfig = vitestConfig;
30
+ this.vitestProject = vitestProject;
29
31
  this.process = null;
30
32
  this.rl = null;
31
33
  this.pendingTask = null;
@@ -54,9 +56,13 @@ class VitestWorker extends EventEmitter {
54
56
  ...process.env,
55
57
  MUTINEER_WORKER_ID: this.id,
56
58
  MUTINEER_CWD: this.cwd,
59
+ MUTINEER_ACTIVE_ID_FILE: getActiveIdFilePath(this.id, this.cwd),
57
60
  ...(this.vitestConfig
58
61
  ? { MUTINEER_VITEST_CONFIG: this.vitestConfig }
59
62
  : {}),
63
+ ...(this.vitestProject
64
+ ? { MUTINEER_VITEST_PROJECT: this.vitestProject }
65
+ : {}),
60
66
  ...(DEBUG ? { MUTINEER_DEBUG: '1' } : {}),
61
67
  };
62
68
  workerLog.debug(`[${this.id}] Starting worker process`);
@@ -71,6 +77,10 @@ class VitestWorker extends EventEmitter {
71
77
  cwd: this.cwd,
72
78
  env,
73
79
  stdio: ['pipe', 'pipe', 'pipe'],
80
+ // Create own process group so killing this process also kills its
81
+ // children (Vitest inner forks). Without this, SIGKILL to worker.mjs
82
+ // orphans the Vitest fork workers.
83
+ detached: true,
74
84
  });
75
85
  // Handle stderr (debug/error output)
76
86
  this.process.stderr?.on('data', (data) => {
@@ -205,8 +215,16 @@ class VitestWorker extends EventEmitter {
205
215
  }
206
216
  kill() {
207
217
  if (this.process) {
218
+ const pid = this.process.pid;
208
219
  try {
209
- this.process.kill('SIGKILL');
220
+ if (pid !== undefined) {
221
+ // Kill the entire process group (negative PID) so Vitest inner fork
222
+ // workers die alongside worker.mjs instead of becoming orphans.
223
+ process.kill(-pid, 'SIGKILL');
224
+ }
225
+ else {
226
+ this.process.kill('SIGKILL');
227
+ }
210
228
  }
211
229
  catch {
212
230
  // Ignore
@@ -227,6 +245,7 @@ export class VitestPool {
227
245
  cwd: options.cwd,
228
246
  concurrency: options.concurrency,
229
247
  vitestConfig: options.vitestConfig,
248
+ vitestProject: options.vitestProject,
230
249
  timeoutMs: options.timeoutMs ?? 10_000,
231
250
  createWorker: options.createWorker,
232
251
  };
@@ -240,8 +259,9 @@ export class VitestPool {
240
259
  const worker = this.options.createWorker?.(`w${i}`, {
241
260
  cwd: this.options.cwd,
242
261
  vitestConfig: this.options.vitestConfig,
262
+ vitestProject: this.options.vitestProject,
243
263
  }) ??
244
- new VitestWorker(`w${i}`, this.options.cwd, this.options.vitestConfig);
264
+ new VitestWorker(`w${i}`, this.options.cwd, this.options.vitestConfig, this.options.vitestProject);
245
265
  worker.on('exit', () => {
246
266
  if (!this.shuttingDown) {
247
267
  this.handleWorkerExit(worker);
@@ -268,8 +288,9 @@ export class VitestPool {
268
288
  const newWorker = this.options.createWorker?.(worker.id, {
269
289
  cwd: this.options.cwd,
270
290
  vitestConfig: this.options.vitestConfig,
291
+ vitestProject: this.options.vitestProject,
271
292
  }) ??
272
- new VitestWorker(worker.id, this.options.cwd, this.options.vitestConfig);
293
+ new VitestWorker(worker.id, this.options.cwd, this.options.vitestConfig, this.options.vitestProject);
273
294
  const idx = this.workers.indexOf(worker);
274
295
  if (idx >= 0) {
275
296
  this.workers[idx] = newWorker;
@@ -4,6 +4,7 @@ export interface VitestWorkerRuntimeOptions {
4
4
  workerId: string;
5
5
  cwd: string;
6
6
  vitestConfigPath?: string;
7
+ vitestProject?: string;
7
8
  }
8
9
  export declare class VitestWorkerRuntime {
9
10
  private readonly options;
@@ -5,6 +5,14 @@ import { poolMutineerPlugin } from './plugin.js';
5
5
  import { getMutantFilePath, setRedirect, clearRedirect, } from '../shared/index.js';
6
6
  import { createLogger } from '../../utils/logger.js';
7
7
  const log = createLogger('vitest-runtime');
8
+ const SETUP_MJS_CONTENT = `import { beforeAll } from 'vitest'
9
+ import { readFileSync } from 'node:fs'
10
+ const _f = process.env.MUTINEER_ACTIVE_ID_FILE
11
+ beforeAll(() => {
12
+ try { globalThis.__mutineer_active_id__ = readFileSync(_f, 'utf8').trim() || null }
13
+ catch { globalThis.__mutineer_active_id__ = null }
14
+ })
15
+ `;
8
16
  export class VitestWorkerRuntime {
9
17
  constructor(options) {
10
18
  this.options = options;
@@ -12,12 +20,24 @@ export class VitestWorkerRuntime {
12
20
  }
13
21
  async init() {
14
22
  try {
23
+ // Write setup.mjs before creating Vitest so the config hook can find it
24
+ const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
25
+ if (activeIdFile && path.isAbsolute(activeIdFile)) {
26
+ const mutineerDir = path.dirname(activeIdFile);
27
+ fs.mkdirSync(mutineerDir, { recursive: true });
28
+ fs.writeFileSync(path.join(mutineerDir, 'setup.mjs'), SETUP_MJS_CONTENT, 'utf8');
29
+ }
15
30
  this.vitest = await createVitest('test', {
16
- watch: true,
31
+ watch: false,
17
32
  reporters: ['dot'],
18
33
  silent: true,
19
34
  pool: 'forks',
20
35
  bail: 1,
36
+ // Limit to 1 inner fork so bail:1 stops after the first failure
37
+ // without spawning additional fork processes. The single fork is
38
+ // persistent (reused across mutant runs), eliminating per-mutant
39
+ // fork startup overhead.
40
+ maxWorkers: 1,
21
41
  ...(this.options.vitestConfigPath
22
42
  ? { config: this.options.vitestConfigPath }
23
43
  : {}),
@@ -43,20 +63,29 @@ export class VitestWorkerRuntime {
43
63
  throw new Error('Vitest runtime not initialised');
44
64
  }
45
65
  const start = Date.now();
66
+ const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
67
+ const useSchema = !mutant.isFallback && !!activeIdFile;
46
68
  try {
47
- const mutantPath = getMutantFilePath(mutant.file, mutant.id);
48
- fs.writeFileSync(mutantPath, mutant.code, 'utf8');
49
- log.debug(`Wrote mutant to ${mutantPath}`);
50
- setRedirect({
51
- from: path.resolve(mutant.file),
52
- to: mutantPath,
53
- });
54
- this.vitest.invalidateFile(mutant.file);
55
- log.debug(`Invalidated ${mutant.file}`);
69
+ if (useSchema) {
70
+ fs.writeFileSync(activeIdFile, mutant.id, 'utf8');
71
+ log.debug(`Schema path: wrote active ID ${mutant.id}`);
72
+ }
73
+ else {
74
+ const mutantPath = getMutantFilePath(mutant.file, mutant.id);
75
+ fs.writeFileSync(mutantPath, mutant.code, 'utf8');
76
+ log.debug(`Wrote mutant to ${mutantPath}`);
77
+ setRedirect({
78
+ from: path.resolve(mutant.file),
79
+ to: mutantPath,
80
+ });
81
+ this.vitest.invalidateFile(mutant.file);
82
+ log.debug(`Invalidated ${mutant.file}`);
83
+ }
56
84
  const specs = [];
85
+ const projectName = this.options.vitestProject ?? '';
57
86
  for (const testFile of tests) {
58
87
  const spec = this.vitest
59
- .getProjectByName('')
88
+ .getProjectByName(projectName)
60
89
  ?.createSpecification(testFile);
61
90
  if (spec)
62
91
  specs.push(spec);
@@ -68,6 +97,7 @@ export class VitestWorkerRuntime {
68
97
  };
69
98
  }
70
99
  log.debug(`Running ${specs.length} test specs`);
100
+ this.vitest.state?.filesMap?.clear();
71
101
  const results = await this.vitest.runTestSpecifications(specs);
72
102
  const requestedModules = new Set(specs.map((s) => s.moduleId));
73
103
  const relevantModules = results.testModules.filter((mod) => requestedModules.has(mod.moduleId));
@@ -88,14 +118,23 @@ export class VitestWorkerRuntime {
88
118
  };
89
119
  }
90
120
  finally {
91
- // Clear redirect and clean up temp file
92
- const mutantPath = getMutantFilePath(mutant.file, mutant.id);
93
- clearRedirect();
94
- try {
95
- fs.rmSync(mutantPath, { force: true });
121
+ if (useSchema) {
122
+ try {
123
+ fs.writeFileSync(activeIdFile, '', 'utf8');
124
+ }
125
+ catch {
126
+ // ignore
127
+ }
96
128
  }
97
- catch {
98
- // ignore
129
+ else {
130
+ clearRedirect();
131
+ const mutantPath = getMutantFilePath(mutant.file, mutant.id);
132
+ try {
133
+ fs.rmSync(mutantPath, { force: true });
134
+ }
135
+ catch {
136
+ // ignore
137
+ }
99
138
  }
100
139
  }
101
140
  }
@@ -26,11 +26,13 @@ async function main() {
26
26
  const workerId = process.env.MUTINEER_WORKER_ID ?? 'unknown';
27
27
  const cwd = process.env.MUTINEER_CWD ?? process.cwd();
28
28
  const vitestConfigPath = process.env.MUTINEER_VITEST_CONFIG;
29
+ const vitestProject = process.env.MUTINEER_VITEST_PROJECT;
29
30
  log.debug(`Starting worker ${workerId} in ${cwd}`);
30
31
  const runtime = createVitestWorkerRuntime({
31
32
  workerId,
32
33
  cwd,
33
34
  vitestConfigPath,
35
+ vitestProject,
34
36
  });
35
37
  try {
36
38
  await runtime.init();
@@ -41,6 +43,14 @@ async function main() {
41
43
  }
42
44
  // Signal ready
43
45
  send({ type: 'ready', workerId });
46
+ // Graceful SIGTERM handler: clean up Vitest inner forks before exiting.
47
+ // This runs when the parent kills the process group with SIGTERM (e.g.
48
+ // future graceful shutdown path). Vitest forks are in the same process
49
+ // group so they also receive the signal, but calling close() ensures the
50
+ // Vitest instance is torn down cleanly.
51
+ process.on('SIGTERM', () => {
52
+ void runtime.shutdown().finally(() => process.exit(0));
53
+ });
44
54
  // Process requests from stdin
45
55
  const rl = readline.createInterface({
46
56
  input: process.stdin,