@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.
- 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 +55 -1
- 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/runner/__tests__/args.spec.js +32 -0
- package/dist/runner/__tests__/cleanup.spec.js +7 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +3 -0
- package/dist/runner/__tests__/orchestrator.spec.js +183 -18
- package/dist/runner/__tests__/pool-executor.spec.js +47 -0
- 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 +5 -0
- package/dist/runner/args.js +10 -0
- package/dist/runner/cleanup.js +1 -1
- package/dist/runner/orchestrator.js +98 -17
- package/dist/runner/pool-executor.d.ts +2 -0
- package/dist/runner/pool-executor.js +15 -4
- 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__/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 +1 -0
- 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 +14 -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 +83 -1
- package/dist/utils/summary.d.ts +5 -1
- package/dist/utils/summary.js +38 -3
- 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
|
+
}
|
package/dist/runner/types.d.ts
CHANGED
|
@@ -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;
|