@mutineerjs/mutineer 0.7.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 (52) 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 +55 -1
  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/runner/__tests__/args.spec.js +32 -0
  10. package/dist/runner/__tests__/cleanup.spec.js +7 -0
  11. package/dist/runner/__tests__/coverage-resolver.spec.js +3 -0
  12. package/dist/runner/__tests__/orchestrator.spec.js +183 -18
  13. package/dist/runner/__tests__/pool-executor.spec.js +47 -0
  14. package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
  15. package/dist/runner/__tests__/ts-checker.spec.js +115 -0
  16. package/dist/runner/args.d.ts +5 -0
  17. package/dist/runner/args.js +10 -0
  18. package/dist/runner/cleanup.js +1 -1
  19. package/dist/runner/orchestrator.js +98 -17
  20. package/dist/runner/pool-executor.d.ts +2 -0
  21. package/dist/runner/pool-executor.js +15 -4
  22. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  23. package/dist/runner/shared/index.d.ts +1 -1
  24. package/dist/runner/shared/index.js +1 -1
  25. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  26. package/dist/runner/shared/mutant-paths.js +24 -0
  27. package/dist/runner/ts-checker-worker.d.ts +5 -0
  28. package/dist/runner/ts-checker-worker.js +66 -0
  29. package/dist/runner/ts-checker.d.ts +36 -0
  30. package/dist/runner/ts-checker.js +210 -0
  31. package/dist/runner/types.d.ts +2 -0
  32. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  33. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  34. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  35. package/dist/runner/vitest/adapter.js +1 -0
  36. package/dist/runner/vitest/plugin.d.ts +3 -0
  37. package/dist/runner/vitest/plugin.js +49 -11
  38. package/dist/runner/vitest/pool.d.ts +4 -1
  39. package/dist/runner/vitest/pool.js +25 -4
  40. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  41. package/dist/runner/vitest/worker-runtime.js +57 -18
  42. package/dist/runner/vitest/worker.mjs +10 -0
  43. package/dist/types/config.d.ts +14 -0
  44. package/dist/types/mutant.d.ts +5 -2
  45. package/dist/utils/CompileErrors.d.ts +7 -0
  46. package/dist/utils/CompileErrors.js +24 -0
  47. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  48. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  49. package/dist/utils/__tests__/summary.spec.js +83 -1
  50. package/dist/utils/summary.d.ts +5 -1
  51. package/dist/utils/summary.js +38 -3
  52. package/package.json +2 -2
@@ -0,0 +1,210 @@
1
+ /**
2
+ * TypeScript Type Checker for Mutants
3
+ *
4
+ * Pre-filters mutants that produce TypeScript compile errors before running
5
+ * them against tests. This avoids running mutations that would be trivially
6
+ * detected by the type system, saving significant test execution time.
7
+ *
8
+ * Strategy: noLib + noResolve (zero I/O) + baseline comparison
9
+ * - noLib: true → don't load lib.d.ts (huge, causes hangs)
10
+ * - noResolve: true → don't follow user imports (avoids loading project files)
11
+ * - Baseline check → type-check original file first; only flag mutants that
12
+ * introduce NEW errors not present in the original. This
13
+ * eliminates false positives from missing lib/import types.
14
+ */
15
+ import ts from 'typescript';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
19
+ import { Worker } from 'worker_threads';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { createLogger } from '../utils/logger.js';
22
+ const log = createLogger('ts-checker');
23
+ /**
24
+ * Compiler options used for all type checks: fully isolated, zero I/O.
25
+ * We read strict/noImplicitAny/etc from the user's tsconfig (to catch the
26
+ * same errors they care about), but always override lib/resolve settings.
27
+ */
28
+ function resolveCompilerOptions(tsconfig, cwd) {
29
+ const base = {
30
+ noEmit: true,
31
+ skipLibCheck: true,
32
+ strict: false,
33
+ noLib: true, // Never load lib.d.ts — huge, causes hangs
34
+ noResolve: true, // Never follow user imports — avoids loading project files
35
+ };
36
+ const searchDir = tsconfig ? path.dirname(path.resolve(cwd, tsconfig)) : cwd;
37
+ const configPath = ts.findConfigFile(searchDir, ts.sys.fileExists) ?? undefined;
38
+ if (!configPath)
39
+ return base;
40
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
41
+ if (configFile.error) {
42
+ log.debug(`Failed to read tsconfig at ${configPath}: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')}`);
43
+ return base;
44
+ }
45
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
46
+ // Take user's strictness settings (strict, noImplicitAny, exactOptionalPropertyTypes,
47
+ // etc.) so we catch the same class of errors they care about, but always override
48
+ // the I/O settings to keep checks fast and isolated.
49
+ return {
50
+ ...parsed.options,
51
+ noEmit: true,
52
+ skipLibCheck: true,
53
+ noLib: true,
54
+ noResolve: true,
55
+ };
56
+ }
57
+ /** Stable fingerprint for a diagnostic — used to diff baseline vs mutant errors. */
58
+ function diagnosticKey(d) {
59
+ return `${d.code}:${d.start ?? -1}`;
60
+ }
61
+ /** Create a compiler host that serves `code` for `targetPath`, real fs for everything else. */
62
+ function makeHost(options, targetPath, code) {
63
+ const host = ts.createCompilerHost(options);
64
+ const orig = host.getSourceFile.bind(host);
65
+ host.getSourceFile = (fileName, langOrOpts) => {
66
+ if (path.resolve(fileName) === targetPath) {
67
+ return ts.createSourceFile(fileName, code, langOrOpts);
68
+ }
69
+ return orig(fileName, langOrOpts);
70
+ };
71
+ return host;
72
+ }
73
+ /** Run semantic diagnostics for `code` in `targetPath`, reusing `oldProgram` if provided. */
74
+ function diagnose(options, targetPath, code, oldProgram) {
75
+ const host = makeHost(options, targetPath, code);
76
+ const program = ts.createProgram({
77
+ rootNames: [targetPath],
78
+ options,
79
+ host,
80
+ oldProgram,
81
+ });
82
+ const sourceFile = program.getSourceFile(targetPath) ??
83
+ program.getSourceFile(path.relative(process.cwd(), targetPath));
84
+ if (!sourceFile) {
85
+ return { program, keys: new Set() };
86
+ }
87
+ const keys = new Set(program.getSemanticDiagnostics(sourceFile).map(diagnosticKey));
88
+ return { program, keys };
89
+ }
90
+ /**
91
+ * Run type checks for one file group synchronously. Used both by the sync
92
+ * fallback (test/dev environments) and by the worker thread.
93
+ */
94
+ export function checkFileSync(options, filePath, fileVariants) {
95
+ const resolvedPath = path.resolve(filePath);
96
+ const compileErrorIds = [];
97
+ let originalCode = '';
98
+ try {
99
+ originalCode = fs.readFileSync(resolvedPath, 'utf8');
100
+ }
101
+ catch {
102
+ log.debug(`Cannot read ${filePath} for baseline — using empty baseline`);
103
+ }
104
+ const { program: baseProgram, keys: baselineKeys } = diagnose(options, resolvedPath, originalCode, undefined);
105
+ log.debug(`Baseline for ${filePath}: ${baselineKeys.size} error(s)`);
106
+ let prevProgram = baseProgram;
107
+ for (const variant of fileVariants) {
108
+ const { program: mutProgram, keys: mutantKeys } = diagnose(options, resolvedPath, variant.code, prevProgram);
109
+ prevProgram = mutProgram;
110
+ let newErrors = 0;
111
+ for (const key of mutantKeys) {
112
+ if (!baselineKeys.has(key))
113
+ newErrors++;
114
+ }
115
+ if (newErrors > 0) {
116
+ compileErrorIds.push(variant.id);
117
+ log.debug(`Compile error in ${variant.id}: ${newErrors} new error(s)`);
118
+ }
119
+ }
120
+ return compileErrorIds;
121
+ }
122
+ /** Spawn a worker for one file group and resolve with the compile-error IDs. */
123
+ function runWorker(workerPath, options, filePath, variants) {
124
+ return new Promise((resolve, reject) => {
125
+ const worker = new Worker(workerPath, {
126
+ workerData: {
127
+ options,
128
+ filePath,
129
+ variants: variants.map((v) => ({ id: v.id, code: v.code })),
130
+ },
131
+ });
132
+ worker.once('message', (msg) => {
133
+ resolve(msg.compileErrorIds);
134
+ });
135
+ worker.once('error', reject);
136
+ worker.once('exit', (code) => {
137
+ if (code !== 0)
138
+ reject(new Error(`ts-checker worker exited with code ${code}`));
139
+ });
140
+ });
141
+ }
142
+ /**
143
+ * Check TypeScript types for mutated variants.
144
+ * Returns a Set of variant IDs that introduce NEW compile errors vs the original.
145
+ */
146
+ export async function checkTypes(variants, tsconfig, cwd) {
147
+ const compileErrors = new Set();
148
+ if (variants.length === 0)
149
+ return compileErrors;
150
+ const options = resolveCompilerOptions(tsconfig, cwd);
151
+ // Group variants by source file
152
+ const byFile = new Map();
153
+ for (const v of variants) {
154
+ const list = byFile.get(v.file);
155
+ if (list)
156
+ list.push(v);
157
+ else
158
+ byFile.set(v.file, [v]);
159
+ }
160
+ // In test/dev environments import.meta.url ends in .ts — Node can't load .ts
161
+ // workers, so fall back to the synchronous path.
162
+ const isTsSource = import.meta.url.endsWith('.ts');
163
+ if (isTsSource) {
164
+ for (const [filePath, fileVariants] of byFile) {
165
+ for (const id of checkFileSync(options, filePath, fileVariants)) {
166
+ compileErrors.add(id);
167
+ }
168
+ }
169
+ return compileErrors;
170
+ }
171
+ // Parallel worker dispatch
172
+ const workerPath = fileURLToPath(new URL('./ts-checker-worker.js', import.meta.url));
173
+ const maxConcurrency = Math.max(os.cpus().length - 1, 1);
174
+ const entries = Array.from(byFile.entries());
175
+ for (let i = 0; i < entries.length; i += maxConcurrency) {
176
+ const batch = entries.slice(i, i + maxConcurrency);
177
+ const results = await Promise.all(batch.map(([filePath, fileVariants]) => runWorker(workerPath, options, filePath, fileVariants)));
178
+ for (const ids of results) {
179
+ for (const id of ids)
180
+ compileErrors.add(id);
181
+ }
182
+ }
183
+ return compileErrors;
184
+ }
185
+ /**
186
+ * Determine whether TypeScript type checking should run for this invocation.
187
+ * Precedence: CLI flag > config field > auto-detect (tsconfig.json presence).
188
+ */
189
+ export function resolveTypescriptEnabled(cliFlag, config, cwd) {
190
+ if (cliFlag === true)
191
+ return true;
192
+ if (cliFlag === false)
193
+ return false;
194
+ const cfgTs = config.typescript;
195
+ if (cfgTs === false)
196
+ return false;
197
+ if (cfgTs !== undefined)
198
+ return true;
199
+ // Auto-detect: enable if tsconfig.json exists in cwd
200
+ return ts.findConfigFile(cwd, ts.sys.fileExists) !== undefined;
201
+ }
202
+ /**
203
+ * Extract the tsconfig path from the MutineerConfig typescript option.
204
+ */
205
+ export function resolveTsconfigPath(config) {
206
+ if (typeof config.typescript === 'object' && config.typescript !== null) {
207
+ return config.typescript.tsconfig;
208
+ }
209
+ return undefined;
210
+ }
@@ -16,6 +16,8 @@ export interface TestRunnerAdapterOptions {
16
16
  readonly timeoutMs: number;
17
17
  readonly config: MutineerConfig;
18
18
  readonly cliArgs: string[];
19
+ /** Vitest workspace project name to target (from --vitest-project or config) */
20
+ readonly vitestProject?: string;
19
21
  }
20
22
  /**
21
23
  * Coverage-related configuration detected from the test runner.
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
+ import fssync from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import os from 'node:os';
5
6
  import { poolMutineerPlugin } from '../plugin.js';
@@ -13,10 +14,21 @@ function getLoadFn() {
13
14
  }
14
15
  return undefined;
15
16
  }
17
+ function getConfigFn() {
18
+ const plugin = poolMutineerPlugin();
19
+ if (Array.isArray(plugin)) {
20
+ return plugin[0]?.config;
21
+ }
22
+ if (plugin && typeof plugin === 'object' && 'config' in plugin) {
23
+ return plugin.config;
24
+ }
25
+ return undefined;
26
+ }
16
27
  describe('poolMutineerPlugin', () => {
17
28
  afterEach(() => {
18
29
  ;
19
30
  globalThis.__mutineer_redirect__ = undefined;
31
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
20
32
  });
21
33
  it('returns null when no redirect is configured', () => {
22
34
  const load = getLoadFn();
@@ -62,4 +74,143 @@ describe('poolMutineerPlugin', () => {
62
74
  await fs.rm(tmpDir, { recursive: true, force: true });
63
75
  }
64
76
  });
77
+ it('returns schema code when a schema file exists for the source path', async () => {
78
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-plugin-'));
79
+ const sourcePath = path.join(tmpDir, 'source.ts');
80
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
81
+ const schemaPath = path.join(mutineerDir, 'source_schema.ts');
82
+ await fs.mkdir(mutineerDir);
83
+ await fs.writeFile(schemaPath, '// @ts-nocheck\nconst x = 1', 'utf8');
84
+ try {
85
+ const load = getLoadFn();
86
+ const result = load?.(sourcePath);
87
+ expect(result).toBe('// @ts-nocheck\nconst x = 1');
88
+ }
89
+ finally {
90
+ await fs.rm(tmpDir, { recursive: true, force: true });
91
+ }
92
+ });
93
+ it('caches schema content so subsequent loads skip filesystem I/O', async () => {
94
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-cache-'));
95
+ const sourcePath = path.join(tmpDir, 'source.ts');
96
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
97
+ const schemaPath = path.join(mutineerDir, 'source_schema.ts');
98
+ await fs.mkdir(mutineerDir);
99
+ await fs.writeFile(schemaPath, '// cached schema', 'utf8');
100
+ // Use the same plugin instance for both calls (shared cache)
101
+ const plugin = poolMutineerPlugin();
102
+ const load = plugin?.load?.bind(plugin);
103
+ try {
104
+ const first = load?.(sourcePath);
105
+ expect(first).toBe('// cached schema');
106
+ // Remove schema file - second call must still return cached content
107
+ await fs.rm(tmpDir, { recursive: true, force: true });
108
+ const second = load?.(sourcePath);
109
+ expect(second).toBe('// cached schema');
110
+ }
111
+ finally {
112
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
113
+ }
114
+ });
115
+ it('caches null for paths with no schema to avoid repeated existsSync calls', async () => {
116
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-nocache-'));
117
+ const sourcePath = path.join(tmpDir, 'no-schema.ts');
118
+ try {
119
+ const plugin = poolMutineerPlugin();
120
+ const load = plugin?.load?.bind(plugin);
121
+ const first = load?.(sourcePath);
122
+ expect(first).toBeNull();
123
+ // Create schema file after first call - second call must return null (cached)
124
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
125
+ await fs.mkdir(mutineerDir, { recursive: true });
126
+ await fs.writeFile(path.join(mutineerDir, 'no-schema_schema.ts'), '// late schema', 'utf8');
127
+ const second = load?.(sourcePath);
128
+ expect(second).toBeNull();
129
+ }
130
+ finally {
131
+ await fs.rm(tmpDir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ it('prefers redirect over schema when both are present (fallback mutation path)', async () => {
135
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-redirect-'));
136
+ const sourcePath = path.join(tmpDir, 'source.ts');
137
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
138
+ const schemaPath = path.join(mutineerDir, 'source_schema.ts');
139
+ const mutatedPath = path.join(mutineerDir, 'source_0.ts');
140
+ await fs.mkdir(mutineerDir);
141
+ await fs.writeFile(schemaPath, '// schema', 'utf8');
142
+ await fs.writeFile(mutatedPath, '// mutated', 'utf8');
143
+ try {
144
+ ;
145
+ globalThis.__mutineer_redirect__ = {
146
+ from: sourcePath,
147
+ to: mutatedPath,
148
+ };
149
+ const load = getLoadFn();
150
+ const result = load?.(sourcePath);
151
+ // Redirect wins: fallback mutations use setRedirect + invalidateFile.
152
+ // The schema must not shadow the mutant code during fallback runs.
153
+ expect(result).toBe('// mutated');
154
+ }
155
+ finally {
156
+ await fs.rm(tmpDir, { recursive: true, force: true });
157
+ }
158
+ });
159
+ describe('config hook', () => {
160
+ it('returns null when MUTINEER_ACTIVE_ID_FILE is not set', () => {
161
+ const config = getConfigFn();
162
+ const result = config?.({ test: { setupFiles: ['/existing/setup.ts'] } });
163
+ expect(result).toBeNull();
164
+ });
165
+ it('appends setup.mjs to setupFiles when MUTINEER_ACTIVE_ID_FILE is set', () => {
166
+ const tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-config-'));
167
+ const activeIdFile = path.join(tmpDir, '__mutineer__', 'active_id_w0.txt');
168
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
169
+ try {
170
+ const config = getConfigFn();
171
+ const result = config?.({
172
+ test: { setupFiles: ['/existing/setup.ts'] },
173
+ });
174
+ const setupFiles = result?.test?.setupFiles;
175
+ expect(setupFiles).toContain('/existing/setup.ts');
176
+ expect(setupFiles.some((f) => f.endsWith('setup.mjs'))).toBe(true);
177
+ }
178
+ finally {
179
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
180
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
181
+ }
182
+ });
183
+ it('creates setupFiles array from string value', () => {
184
+ const tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-config2-'));
185
+ const activeIdFile = path.join(tmpDir, '__mutineer__', 'active_id_w0.txt');
186
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
187
+ try {
188
+ const config = getConfigFn();
189
+ const result = config?.({ test: { setupFiles: '/single/setup.ts' } });
190
+ const setupFiles = result?.test?.setupFiles;
191
+ expect(setupFiles).toContain('/single/setup.ts');
192
+ expect(setupFiles.length).toBe(2);
193
+ }
194
+ finally {
195
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
196
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
197
+ }
198
+ });
199
+ it('creates setupFiles from empty config when no existing setupFiles', () => {
200
+ const tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-config3-'));
201
+ const activeIdFile = path.join(tmpDir, '__mutineer__', 'active_id_w0.txt');
202
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
203
+ try {
204
+ const config = getConfigFn();
205
+ const result = config?.({});
206
+ const setupFiles = result?.test?.setupFiles;
207
+ expect(setupFiles.length).toBe(1);
208
+ expect(setupFiles[0]).toMatch(/setup\.mjs$/);
209
+ }
210
+ finally {
211
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
212
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
213
+ }
214
+ });
215
+ });
65
216
  });
@@ -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
  });
@@ -130,6 +130,7 @@ export class VitestAdapter {
130
130
  cwd: this.options.cwd,
131
131
  concurrency: workerCount,
132
132
  vitestConfig: this.options.config.vitestConfig,
133
+ vitestProject: this.options.vitestProject,
133
134
  timeoutMs: this.options.timeoutMs,
134
135
  });
135
136
  await this.pool.init();
@@ -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;