@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.
- package/README.md +32 -15
- package/dist/bin/__tests__/mutineer.spec.js +67 -2
- package/dist/bin/mutineer.d.ts +6 -1
- package/dist/bin/mutineer.js +58 -2
- package/dist/core/__tests__/schemata.spec.d.ts +1 -0
- package/dist/core/__tests__/schemata.spec.js +165 -0
- package/dist/core/schemata.d.ts +22 -0
- package/dist/core/schemata.js +236 -0
- package/dist/mutators/__tests__/operator.spec.js +97 -1
- package/dist/mutators/__tests__/registry.spec.js +8 -0
- package/dist/mutators/operator.d.ts +8 -0
- package/dist/mutators/operator.js +58 -1
- package/dist/mutators/registry.js +9 -1
- package/dist/mutators/utils.d.ts +2 -0
- package/dist/mutators/utils.js +58 -1
- package/dist/runner/__tests__/args.spec.js +89 -1
- package/dist/runner/__tests__/cache.spec.js +65 -8
- package/dist/runner/__tests__/cleanup.spec.js +37 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +5 -0
- package/dist/runner/__tests__/discover.spec.js +128 -0
- package/dist/runner/__tests__/orchestrator.spec.js +332 -2
- package/dist/runner/__tests__/pool-executor.spec.js +107 -1
- package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
- package/dist/runner/__tests__/ts-checker.spec.js +115 -0
- package/dist/runner/args.d.ts +18 -0
- package/dist/runner/args.js +37 -0
- package/dist/runner/cache.d.ts +19 -3
- package/dist/runner/cache.js +14 -7
- package/dist/runner/cleanup.d.ts +3 -1
- package/dist/runner/cleanup.js +19 -2
- package/dist/runner/coverage-resolver.js +1 -1
- package/dist/runner/discover.d.ts +1 -1
- package/dist/runner/discover.js +30 -20
- package/dist/runner/orchestrator.d.ts +1 -0
- package/dist/runner/orchestrator.js +114 -19
- package/dist/runner/pool-executor.d.ts +7 -0
- package/dist/runner/pool-executor.js +29 -7
- package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
- package/dist/runner/shared/index.d.ts +1 -1
- package/dist/runner/shared/index.js +1 -1
- package/dist/runner/shared/mutant-paths.d.ts +17 -0
- package/dist/runner/shared/mutant-paths.js +24 -0
- package/dist/runner/ts-checker-worker.d.ts +5 -0
- package/dist/runner/ts-checker-worker.js +66 -0
- package/dist/runner/ts-checker.d.ts +36 -0
- package/dist/runner/ts-checker.js +210 -0
- package/dist/runner/types.d.ts +2 -0
- package/dist/runner/vitest/__tests__/adapter.spec.js +41 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
- package/dist/runner/vitest/adapter.js +14 -9
- package/dist/runner/vitest/plugin.d.ts +3 -0
- package/dist/runner/vitest/plugin.js +49 -11
- package/dist/runner/vitest/pool.d.ts +4 -1
- package/dist/runner/vitest/pool.js +25 -4
- package/dist/runner/vitest/worker-runtime.d.ts +1 -0
- package/dist/runner/vitest/worker-runtime.js +57 -18
- package/dist/runner/vitest/worker.mjs +10 -0
- package/dist/types/config.d.ts +16 -0
- package/dist/types/mutant.d.ts +5 -2
- package/dist/utils/CompileErrors.d.ts +7 -0
- package/dist/utils/CompileErrors.js +24 -0
- package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
- package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
- package/dist/utils/__tests__/summary.spec.js +126 -2
- package/dist/utils/summary.d.ts +23 -1
- package/dist/utils/summary.js +63 -3
- 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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
28
|
+
config(config) {
|
|
29
|
+
const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
|
|
30
|
+
if (!activeIdFile || !path.isAbsolute(activeIdFile))
|
|
23
31
|
return null;
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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:
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
if (useSchema) {
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(activeIdFile, '', 'utf8');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
96
128
|
}
|
|
97
|
-
|
|
98
|
-
|
|
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,
|