@mutineerjs/mutineer 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -47
- package/dist/__tests__/index.spec.js +8 -0
- package/dist/bin/__tests__/mutineer.spec.js +7 -7
- package/dist/bin/mutineer.d.ts +1 -1
- package/dist/bin/mutineer.js +7 -4
- package/dist/core/__tests__/schemata.spec.js +62 -0
- package/dist/core/__tests__/sfc.spec.js +41 -1
- package/dist/core/schemata.js +15 -21
- package/dist/core/sfc.js +0 -4
- package/dist/core/variant-utils.js +0 -4
- package/dist/mutators/__tests__/utils.spec.js +65 -1
- package/dist/mutators/operator.js +13 -27
- package/dist/mutators/return-value.js +3 -7
- package/dist/mutators/utils.d.ts +2 -2
- package/dist/mutators/utils.js +59 -96
- package/dist/runner/__tests__/args.spec.js +8 -4
- package/dist/runner/__tests__/cache.spec.js +24 -0
- package/dist/runner/__tests__/changed.spec.js +75 -0
- package/dist/runner/__tests__/config.spec.js +50 -1
- package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
- package/dist/runner/__tests__/discover.spec.js +179 -0
- package/dist/runner/__tests__/orchestrator.spec.js +336 -11
- package/dist/runner/__tests__/pool-executor.spec.js +77 -0
- package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
- package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
- package/dist/runner/__tests__/ts-checker.spec.js +89 -2
- package/dist/runner/args.d.ts +1 -1
- package/dist/runner/args.js +2 -2
- package/dist/runner/config.js +3 -4
- package/dist/runner/coverage-resolver.js +2 -1
- package/dist/runner/discover.js +2 -2
- package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
- package/dist/runner/jest/__tests__/pool.spec.js +223 -1
- package/dist/runner/jest/adapter.js +3 -45
- package/dist/runner/jest/pool.js +4 -10
- package/dist/runner/jest/worker-runtime.js +2 -1
- package/dist/runner/orchestrator.js +8 -7
- package/dist/runner/pool-executor.js +7 -12
- package/dist/runner/shared/__tests__/strip-mutineer-args.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/strip-mutineer-args.spec.js +104 -0
- package/dist/runner/shared/__tests__/worker-script.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/worker-script.spec.js +32 -0
- package/dist/runner/shared/index.d.ts +4 -0
- package/dist/runner/shared/index.js +2 -0
- package/dist/runner/shared/pending-task.d.ts +9 -0
- package/dist/runner/shared/pending-task.js +1 -0
- package/dist/runner/shared/strip-mutineer-args.d.ts +11 -0
- package/dist/runner/shared/strip-mutineer-args.js +47 -0
- package/dist/runner/shared/worker-script.d.ts +5 -0
- package/dist/runner/shared/worker-script.js +12 -0
- package/dist/runner/ts-checker-worker.d.ts +10 -1
- package/dist/runner/ts-checker-worker.js +27 -25
- package/dist/runner/ts-checker.d.ts +6 -0
- package/dist/runner/ts-checker.js +1 -1
- package/dist/runner/vitest/__tests__/adapter.spec.js +294 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
- package/dist/runner/vitest/__tests__/pool.spec.js +711 -0
- package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +81 -0
- package/dist/runner/vitest/adapter.js +14 -46
- package/dist/runner/vitest/plugin.js +1 -7
- package/dist/runner/vitest/pool.js +6 -19
- package/dist/runner/vitest/redirect-loader.js +3 -1
- package/dist/runner/vitest/worker-runtime.js +16 -1
- package/dist/runner/vitest/worker.mjs +1 -0
- package/dist/types/config.d.ts +2 -2
- package/dist/types/mutant.d.ts +3 -0
- package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
- package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
- package/dist/utils/__tests__/coverage.spec.js +89 -0
- package/dist/utils/__tests__/logger.spec.js +9 -0
- package/dist/utils/__tests__/progress.spec.js +38 -0
- package/dist/utils/__tests__/summary.spec.js +70 -31
- package/dist/utils/coverage.js +3 -4
- package/dist/utils/errors.d.ts +4 -0
- package/dist/utils/errors.js +6 -0
- package/dist/utils/summary.d.ts +2 -3
- package/dist/utils/summary.js +5 -6
- package/package.json +1 -1
- package/dist/utils/CompileErrors.d.ts +0 -7
- package/dist/utils/CompileErrors.js +0 -24
- package/dist/utils/__tests__/CompileErrors.spec.js +0 -96
- /package/dist/{utils/__tests__/CompileErrors.spec.d.ts → __tests__/index.spec.d.ts} +0 -0
|
@@ -7,7 +7,7 @@ vi.mock('node:module', async (importOriginal) => {
|
|
|
7
7
|
const actual = await importOriginal();
|
|
8
8
|
return { ...actual, register: vi.fn() };
|
|
9
9
|
});
|
|
10
|
-
import { resolve as poolResolve, initialise } from '../redirect-loader.js';
|
|
10
|
+
import { resolve as poolResolve, initialise, load } from '../redirect-loader.js';
|
|
11
11
|
describe('pool-redirect-loader resolve', () => {
|
|
12
12
|
afterEach(() => {
|
|
13
13
|
;
|
|
@@ -170,4 +170,119 @@ describe('pool-redirect-loader resolve', () => {
|
|
|
170
170
|
// Falls through to nextResolve
|
|
171
171
|
expect(nextResolve).toHaveBeenCalled();
|
|
172
172
|
});
|
|
173
|
+
it('returns null from tryResolveTsExtension when parentURL is absent', async () => {
|
|
174
|
+
const nextResolve = vi.fn().mockResolvedValue({
|
|
175
|
+
url: 'file:///fallback.js',
|
|
176
|
+
shortCircuit: false,
|
|
177
|
+
});
|
|
178
|
+
await poolResolve('./foo.js', {}, nextResolve);
|
|
179
|
+
// Falls through to nextResolve
|
|
180
|
+
expect(nextResolve).toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
it('falls through to nextResolve when neither .ts nor .tsx exists', async () => {
|
|
183
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
|
|
184
|
+
const parentFile = path.join(tmpDir, 'src', 'index.ts');
|
|
185
|
+
await fs.mkdir(path.dirname(parentFile), { recursive: true });
|
|
186
|
+
await fs.writeFile(parentFile, 'export {}', 'utf8');
|
|
187
|
+
// foo.ts and foo.tsx do NOT exist — tryResolveTsExtension returns null at end of loop
|
|
188
|
+
const nextResolve = vi.fn().mockResolvedValue({
|
|
189
|
+
url: 'file:///fallback.js',
|
|
190
|
+
shortCircuit: false,
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
await poolResolve('./foo.js', { parentURL: pathToFileURL(parentFile).href }, nextResolve);
|
|
194
|
+
expect(nextResolve).toHaveBeenCalled();
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
it('logs debug info when DEBUG is enabled', async () => {
|
|
201
|
+
initialise({ debug: true });
|
|
202
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
203
|
+
const nextResolve = vi.fn().mockResolvedValue({
|
|
204
|
+
url: 'file:///some.ts',
|
|
205
|
+
shortCircuit: false,
|
|
206
|
+
});
|
|
207
|
+
await poolResolve('./something', { parentURL: 'file:///src/index.ts' }, nextResolve);
|
|
208
|
+
expect(errSpy).toHaveBeenCalled();
|
|
209
|
+
errSpy.mockRestore();
|
|
210
|
+
});
|
|
211
|
+
it('logs active redirect and REDIRECTING when DEBUG is enabled and redirect matches after nextResolve', async () => {
|
|
212
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
|
|
213
|
+
const fromPath = path.join(tmpDir, 'target.ts');
|
|
214
|
+
const mutatedPath = path.join(tmpDir, 'mutated.ts');
|
|
215
|
+
await fs.writeFile(fromPath, 'export {}', 'utf8');
|
|
216
|
+
await fs.writeFile(mutatedPath, 'export {}', 'utf8');
|
|
217
|
+
initialise({ debug: true });
|
|
218
|
+
globalThis.__mutineer_redirect__ = {
|
|
219
|
+
from: fromPath,
|
|
220
|
+
to: mutatedPath,
|
|
221
|
+
};
|
|
222
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
223
|
+
const nextResolve = vi.fn().mockResolvedValue({
|
|
224
|
+
url: pathToFileURL(fromPath).href,
|
|
225
|
+
shortCircuit: false,
|
|
226
|
+
});
|
|
227
|
+
try {
|
|
228
|
+
const result = await poolResolve('./target', { parentURL: 'file:///src/index.ts' }, nextResolve);
|
|
229
|
+
expect(result.url).toBe(pathToFileURL(mutatedPath).href);
|
|
230
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('REDIRECTING'));
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
errSpy.mockRestore();
|
|
234
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
it('logs .js -> .ts redirect info when DEBUG is enabled and ts file exists', async () => {
|
|
238
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
|
|
239
|
+
const parentFile = path.join(tmpDir, 'src', 'index.ts');
|
|
240
|
+
const tsFile = path.join(tmpDir, 'src', 'foo.ts');
|
|
241
|
+
await fs.mkdir(path.dirname(parentFile), { recursive: true });
|
|
242
|
+
await fs.writeFile(parentFile, 'export {}', 'utf8');
|
|
243
|
+
await fs.writeFile(tsFile, 'export const foo = 1', 'utf8');
|
|
244
|
+
initialise({ debug: true });
|
|
245
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
246
|
+
const nextResolve = vi.fn();
|
|
247
|
+
try {
|
|
248
|
+
await poolResolve('./foo.js', { parentURL: pathToFileURL(parentFile).href }, nextResolve);
|
|
249
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('.js -> .ts'));
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
errSpy.mockRestore();
|
|
253
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
it('logs REDIRECTING when DEBUG is enabled and .ts resolved file matches redirect', async () => {
|
|
257
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
|
|
258
|
+
const parentFile = path.join(tmpDir, 'src', 'index.ts');
|
|
259
|
+
const fromPath = path.join(tmpDir, 'src', 'target.ts');
|
|
260
|
+
const mutatedPath = path.join(tmpDir, 'mutated.ts');
|
|
261
|
+
await fs.mkdir(path.dirname(parentFile), { recursive: true });
|
|
262
|
+
await fs.writeFile(parentFile, 'export {}', 'utf8');
|
|
263
|
+
await fs.writeFile(fromPath, 'export {}', 'utf8');
|
|
264
|
+
await fs.writeFile(mutatedPath, 'export {}', 'utf8');
|
|
265
|
+
initialise({ debug: true });
|
|
266
|
+
globalThis.__mutineer_redirect__ = {
|
|
267
|
+
from: fromPath,
|
|
268
|
+
to: mutatedPath,
|
|
269
|
+
};
|
|
270
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
271
|
+
const nextResolve = vi.fn();
|
|
272
|
+
try {
|
|
273
|
+
const result = await poolResolve('./target.js', { parentURL: pathToFileURL(parentFile).href }, nextResolve);
|
|
274
|
+
expect(result.url).toBe(pathToFileURL(mutatedPath).href);
|
|
275
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('REDIRECTING'));
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
errSpy.mockRestore();
|
|
279
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
it('load() passes through to nextLoad', async () => {
|
|
283
|
+
const nextLoad = vi.fn().mockResolvedValue({ source: '// code' });
|
|
284
|
+
const result = await load('file:///foo.ts', {}, nextLoad);
|
|
285
|
+
expect(nextLoad).toHaveBeenCalledWith('file:///foo.ts', {});
|
|
286
|
+
expect(result).toEqual({ source: '// code' });
|
|
287
|
+
});
|
|
173
288
|
});
|
|
@@ -125,6 +125,66 @@ describe('VitestWorkerRuntime', () => {
|
|
|
125
125
|
expect(result.error).toBe('string error');
|
|
126
126
|
await runtime.shutdown();
|
|
127
127
|
});
|
|
128
|
+
it('collects passingTests fullNames from modules when mutant escapes', async () => {
|
|
129
|
+
const makeModule = (moduleId, names) => ({
|
|
130
|
+
moduleId,
|
|
131
|
+
ok: () => true,
|
|
132
|
+
children: {
|
|
133
|
+
allTests: (_state) => names.map((n) => ({ fullName: n })),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
runSpecsFn.mockResolvedValue({
|
|
137
|
+
testModules: [
|
|
138
|
+
makeModule(path.join(os.tmpdir(), 'test.ts'), [
|
|
139
|
+
'Math > adds',
|
|
140
|
+
'Math > subtracts',
|
|
141
|
+
]),
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-pt-'));
|
|
145
|
+
tmpFiles.push(tmp);
|
|
146
|
+
const runtime = createVitestWorkerRuntime({ workerId: 'w-pt', cwd: tmp });
|
|
147
|
+
await runtime.init();
|
|
148
|
+
const result = await runtime.run({
|
|
149
|
+
id: 'mut#pt',
|
|
150
|
+
name: 'm',
|
|
151
|
+
file: path.join(tmp, 'src.ts'),
|
|
152
|
+
code: 'export const x=1',
|
|
153
|
+
line: 1,
|
|
154
|
+
col: 1,
|
|
155
|
+
}, [path.join(os.tmpdir(), 'test.ts')]);
|
|
156
|
+
expect(result.killed).toBe(false);
|
|
157
|
+
expect(result.passingTests).toEqual(['Math > adds', 'Math > subtracts']);
|
|
158
|
+
await runtime.shutdown();
|
|
159
|
+
});
|
|
160
|
+
it('omits passingTests when mutant is killed', async () => {
|
|
161
|
+
runSpecsFn.mockResolvedValue({
|
|
162
|
+
testModules: [
|
|
163
|
+
{
|
|
164
|
+
moduleId: path.join(os.tmpdir(), 'test.ts'),
|
|
165
|
+
ok: () => false,
|
|
166
|
+
children: {
|
|
167
|
+
allTests: (_state) => [{ fullName: 'Math > adds' }],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-kpt-'));
|
|
173
|
+
tmpFiles.push(tmp);
|
|
174
|
+
const runtime = createVitestWorkerRuntime({ workerId: 'w-kpt', cwd: tmp });
|
|
175
|
+
await runtime.init();
|
|
176
|
+
const result = await runtime.run({
|
|
177
|
+
id: 'mut#kpt',
|
|
178
|
+
name: 'm',
|
|
179
|
+
file: path.join(tmp, 'src.ts'),
|
|
180
|
+
code: 'export const x=1',
|
|
181
|
+
line: 1,
|
|
182
|
+
col: 1,
|
|
183
|
+
}, [path.join(os.tmpdir(), 'test.ts')]);
|
|
184
|
+
expect(result.killed).toBe(true);
|
|
185
|
+
expect(result.passingTests).toBeUndefined();
|
|
186
|
+
await runtime.shutdown();
|
|
187
|
+
});
|
|
128
188
|
it('falls back to all testModules when no relevant modules match', async () => {
|
|
129
189
|
runSpecsFn.mockResolvedValue({
|
|
130
190
|
testModules: [{ moduleId: 'unknown-module', ok: () => true }],
|
|
@@ -252,6 +312,27 @@ describe('VitestWorkerRuntime', () => {
|
|
|
252
312
|
}
|
|
253
313
|
}
|
|
254
314
|
});
|
|
315
|
+
it('logs error and rethrows when vitest.init() throws', async () => {
|
|
316
|
+
const { createVitest } = await import('vitest/node');
|
|
317
|
+
const initError = new Error('init failed');
|
|
318
|
+
vi.mocked(createVitest).mockResolvedValueOnce({
|
|
319
|
+
init: vi.fn().mockRejectedValue(initError),
|
|
320
|
+
close: closeFn,
|
|
321
|
+
runTestSpecifications: runSpecsFn,
|
|
322
|
+
invalidateFile: invalidateFn,
|
|
323
|
+
getProjectByName: getProjectByNameFn,
|
|
324
|
+
});
|
|
325
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
326
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-initerr-'));
|
|
327
|
+
tmpFiles.push(tmp);
|
|
328
|
+
const runtime = createVitestWorkerRuntime({
|
|
329
|
+
workerId: 'w-initerr',
|
|
330
|
+
cwd: tmp,
|
|
331
|
+
});
|
|
332
|
+
await expect(runtime.init()).rejects.toThrow('init failed');
|
|
333
|
+
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Failed to initialise Vitest'));
|
|
334
|
+
spy.mockRestore();
|
|
335
|
+
});
|
|
255
336
|
it('clears state.filesMap before each run to prevent memory accumulation', async () => {
|
|
256
337
|
const { createVitest } = await import('vitest/node');
|
|
257
338
|
const clearFn = vi.fn();
|
|
@@ -11,6 +11,8 @@ import { spawn } from 'node:child_process';
|
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
12
12
|
import { VitestPool } from './pool.js';
|
|
13
13
|
import { createLogger } from '../../utils/logger.js';
|
|
14
|
+
import { stripMutineerArgs } from '../shared/index.js';
|
|
15
|
+
import { toErrorMessage } from '../../utils/errors.js';
|
|
14
16
|
const require = createRequire(import.meta.url);
|
|
15
17
|
const log = createLogger('vitest-adapter');
|
|
16
18
|
/**
|
|
@@ -25,48 +27,6 @@ function resolveVitestPath() {
|
|
|
25
27
|
return path.join(path.dirname(pkgJson), 'vitest.mjs');
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
|
-
/**
|
|
29
|
-
* Strip mutineer-specific CLI args that shouldn't be passed to Vitest.
|
|
30
|
-
*/
|
|
31
|
-
function stripMutineerArgs(args) {
|
|
32
|
-
const out = [];
|
|
33
|
-
const consumeNext = new Set([
|
|
34
|
-
'--concurrency',
|
|
35
|
-
'--progress',
|
|
36
|
-
'--min-kill-percent',
|
|
37
|
-
'--config',
|
|
38
|
-
'-c',
|
|
39
|
-
'--coverage-file',
|
|
40
|
-
'--shard',
|
|
41
|
-
]);
|
|
42
|
-
const dropExact = new Set([
|
|
43
|
-
'-m',
|
|
44
|
-
'--mutate',
|
|
45
|
-
'--changed',
|
|
46
|
-
'--changed-with-deps',
|
|
47
|
-
'--full',
|
|
48
|
-
'--only-covered-lines',
|
|
49
|
-
'--per-test-coverage',
|
|
50
|
-
'--perTestCoverage',
|
|
51
|
-
]);
|
|
52
|
-
for (let i = 0; i < args.length; i++) {
|
|
53
|
-
const a = args[i];
|
|
54
|
-
if (dropExact.has(a))
|
|
55
|
-
continue;
|
|
56
|
-
if (consumeNext.has(a)) {
|
|
57
|
-
i++;
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
if (a.startsWith('--min-kill-percent='))
|
|
61
|
-
continue;
|
|
62
|
-
if (a.startsWith('--config=') || a.startsWith('-c='))
|
|
63
|
-
continue;
|
|
64
|
-
if (a.startsWith('--shard='))
|
|
65
|
-
continue;
|
|
66
|
-
out.push(a);
|
|
67
|
-
}
|
|
68
|
-
return out;
|
|
69
|
-
}
|
|
70
30
|
/**
|
|
71
31
|
* Ensure the Vitest config arg is included if specified.
|
|
72
32
|
*/
|
|
@@ -101,7 +61,7 @@ function buildVitestArgs(args, mode) {
|
|
|
101
61
|
result.push('--coverage.perTest=true');
|
|
102
62
|
}
|
|
103
63
|
// Disable coverage thresholds so baseline doesn't fail when a broader
|
|
104
|
-
// test set (e.g. from --changed-with-
|
|
64
|
+
// test set (e.g. from --changed-with-imports) lowers aggregate coverage
|
|
105
65
|
result.push('--coverage.thresholds.lines=0', '--coverage.thresholds.functions=0', '--coverage.thresholds.branches=0', '--coverage.thresholds.statements=0');
|
|
106
66
|
}
|
|
107
67
|
return result;
|
|
@@ -122,7 +82,10 @@ export class VitestAdapter {
|
|
|
122
82
|
throw new Error("Cannot find 'vitest'. Install it with: npm i -D vitest");
|
|
123
83
|
}
|
|
124
84
|
// Prepare base args by stripping mutineer-specific flags
|
|
125
|
-
const stripped = stripMutineerArgs(options.cliArgs
|
|
85
|
+
const stripped = stripMutineerArgs(options.cliArgs, {
|
|
86
|
+
extraConsumeNext: ['--shard'],
|
|
87
|
+
extraPrefixes: ['--shard='],
|
|
88
|
+
});
|
|
126
89
|
this.baseArgs = ensureConfigArg(stripped, options.config.vitestConfig, options.cwd);
|
|
127
90
|
}
|
|
128
91
|
async init(concurrencyOverride) {
|
|
@@ -190,17 +153,22 @@ export class VitestAdapter {
|
|
|
190
153
|
error: result.error,
|
|
191
154
|
};
|
|
192
155
|
}
|
|
156
|
+
const status = result.killed ? 'killed' : 'escaped';
|
|
193
157
|
return {
|
|
194
|
-
status
|
|
158
|
+
status,
|
|
195
159
|
durationMs: result.durationMs,
|
|
196
160
|
error: result.error,
|
|
161
|
+
...(!result.killed &&
|
|
162
|
+
result.passingTests && {
|
|
163
|
+
passingTests: result.passingTests,
|
|
164
|
+
}),
|
|
197
165
|
};
|
|
198
166
|
}
|
|
199
167
|
catch (err) {
|
|
200
168
|
return {
|
|
201
169
|
status: 'error',
|
|
202
170
|
durationMs: 0,
|
|
203
|
-
error:
|
|
171
|
+
error: toErrorMessage(err),
|
|
204
172
|
};
|
|
205
173
|
}
|
|
206
174
|
}
|
|
@@ -43,13 +43,7 @@ export function poolMutineerPlugin() {
|
|
|
43
43
|
},
|
|
44
44
|
load(id) {
|
|
45
45
|
const cleanId = id.split('?')[0];
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
normalizedId = path.resolve(cleanId);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
46
|
+
const normalizedId = path.resolve(cleanId);
|
|
53
47
|
// Redirect takes priority: fallback mutations use setRedirect + invalidateFile.
|
|
54
48
|
// Must check redirect first so the schema file (which exists for this source)
|
|
55
49
|
// does not shadow the mutant code during fallback runs.
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
import { spawn } from 'node:child_process';
|
|
15
15
|
import * as path from 'node:path';
|
|
16
16
|
import * as readline from 'node:readline';
|
|
17
|
-
import * as fs from 'node:fs';
|
|
18
17
|
import { fileURLToPath } from 'node:url';
|
|
19
18
|
import { EventEmitter } from 'node:events';
|
|
20
|
-
import { getActiveIdFilePath } from '../shared/index.js';
|
|
19
|
+
import { getActiveIdFilePath, resolveWorkerScript, } from '../shared/index.js';
|
|
21
20
|
import { createLogger, DEBUG } from '../../utils/logger.js';
|
|
21
|
+
import { toErrorMessage } from '../../utils/errors.js';
|
|
22
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
23
|
const workerLog = createLogger('VitestWorker');
|
|
24
24
|
const poolLog = createLogger('VitestPool');
|
|
@@ -36,22 +36,8 @@ class VitestWorker extends EventEmitter {
|
|
|
36
36
|
this.id = id;
|
|
37
37
|
}
|
|
38
38
|
async start() {
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const workerTs = path.join(__dirname, 'worker.mts');
|
|
42
|
-
const workerScript = fs.existsSync(workerJs)
|
|
43
|
-
? workerJs
|
|
44
|
-
: fs.existsSync(workerMts)
|
|
45
|
-
? workerMts
|
|
46
|
-
: workerTs;
|
|
47
|
-
const loaderJs = path.join(__dirname, 'redirect-loader.js');
|
|
48
|
-
const loaderMjs = path.join(__dirname, 'redirect-loader.mjs');
|
|
49
|
-
const loaderTs = path.join(__dirname, 'redirect-loader.ts');
|
|
50
|
-
const loaderScript = fs.existsSync(loaderJs)
|
|
51
|
-
? loaderJs
|
|
52
|
-
: fs.existsSync(loaderMjs)
|
|
53
|
-
? loaderMjs
|
|
54
|
-
: loaderTs;
|
|
39
|
+
const workerScript = resolveWorkerScript(__dirname, 'worker');
|
|
40
|
+
const loaderScript = resolveWorkerScript(__dirname, 'redirect-loader');
|
|
55
41
|
const env = {
|
|
56
42
|
...process.env,
|
|
57
43
|
MUTINEER_WORKER_ID: this.id,
|
|
@@ -150,6 +136,7 @@ class VitestWorker extends EventEmitter {
|
|
|
150
136
|
killed: msg.killed ?? true,
|
|
151
137
|
durationMs: msg.durationMs ?? 0,
|
|
152
138
|
error: msg.error,
|
|
139
|
+
...(msg.passingTests && { passingTests: msg.passingTests }),
|
|
153
140
|
});
|
|
154
141
|
}
|
|
155
142
|
return;
|
|
@@ -391,7 +378,7 @@ export async function runWithPool(pool, mutant, tests) {
|
|
|
391
378
|
return {
|
|
392
379
|
status: 'error',
|
|
393
380
|
durationMs: 0,
|
|
394
|
-
error:
|
|
381
|
+
error: toErrorMessage(err),
|
|
395
382
|
};
|
|
396
383
|
}
|
|
397
384
|
}
|
|
@@ -29,9 +29,11 @@ function tryResolveTsExtension(specifier, parentURL) {
|
|
|
29
29
|
if (!specifier.endsWith('.js') || !specifier.startsWith('.')) {
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
|
+
if (!parentURL)
|
|
33
|
+
return null;
|
|
32
34
|
let parentPath;
|
|
33
35
|
try {
|
|
34
|
-
parentPath = fileURLToPath(parentURL
|
|
36
|
+
parentPath = fileURLToPath(parentURL);
|
|
35
37
|
}
|
|
36
38
|
catch {
|
|
37
39
|
return null;
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { poolMutineerPlugin } from './plugin.js';
|
|
5
5
|
import { getMutantFilePath, setRedirect, clearRedirect, } from '../shared/index.js';
|
|
6
6
|
import { createLogger } from '../../utils/logger.js';
|
|
7
|
+
import { toErrorMessage } from '../../utils/errors.js';
|
|
7
8
|
const log = createLogger('vitest-runtime');
|
|
8
9
|
const SETUP_MJS_CONTENT = `import { beforeAll } from 'vitest'
|
|
9
10
|
import { readFileSync } from 'node:fs'
|
|
@@ -105,16 +106,30 @@ export class VitestWorkerRuntime {
|
|
|
105
106
|
? relevantModules
|
|
106
107
|
: results.testModules;
|
|
107
108
|
const killed = modulesForDecision.some((mod) => !mod.ok());
|
|
109
|
+
const passingTests = [];
|
|
110
|
+
if (!killed) {
|
|
111
|
+
for (const mod of modulesForDecision) {
|
|
112
|
+
try {
|
|
113
|
+
for (const tc of mod.children?.allTests('passed') ?? []) {
|
|
114
|
+
passingTests.push(tc.fullName);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// allTests API unavailable in this Vitest version
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
108
122
|
return {
|
|
109
123
|
killed,
|
|
110
124
|
durationMs: Date.now() - start,
|
|
125
|
+
...(passingTests.length > 0 && { passingTests }),
|
|
111
126
|
};
|
|
112
127
|
}
|
|
113
128
|
catch (err) {
|
|
114
129
|
return {
|
|
115
130
|
killed: true,
|
|
116
131
|
durationMs: Date.now() - start,
|
|
117
|
-
error:
|
|
132
|
+
error: toErrorMessage(err),
|
|
118
133
|
};
|
|
119
134
|
}
|
|
120
135
|
finally {
|
package/dist/types/config.d.ts
CHANGED
|
@@ -24,8 +24,8 @@ export interface MutineerConfig {
|
|
|
24
24
|
readonly runner?: 'vitest' | 'jest';
|
|
25
25
|
readonly vitestConfig?: string;
|
|
26
26
|
readonly jestConfig?: string;
|
|
27
|
-
/** Max depth for
|
|
28
|
-
readonly
|
|
27
|
+
/** Max depth for import resolution with --changed-with-imports (default: 1) */
|
|
28
|
+
readonly importDepth?: number;
|
|
29
29
|
/** Path to coverage JSON file (Istanbul format, e.g., coverage/coverage-final.json) */
|
|
30
30
|
readonly coverageFile?: string;
|
|
31
31
|
/** Only mutate lines that are covered by tests (requires coverageFile) */
|
package/dist/types/mutant.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface MutantCacheEntry extends MutantLocation {
|
|
|
31
31
|
readonly originalSnippet?: string;
|
|
32
32
|
readonly mutatedSnippet?: string;
|
|
33
33
|
readonly coveringTests?: readonly string[];
|
|
34
|
+
readonly passingTests?: readonly string[];
|
|
34
35
|
}
|
|
35
36
|
export interface MutantResult extends MutantCacheEntry {
|
|
36
37
|
readonly id: string;
|
|
@@ -41,10 +42,12 @@ export interface MutantRunSummary {
|
|
|
41
42
|
readonly killed: boolean;
|
|
42
43
|
readonly durationMs: number;
|
|
43
44
|
readonly error?: string;
|
|
45
|
+
readonly passingTests?: readonly string[];
|
|
44
46
|
}
|
|
45
47
|
/** Normalised result returned by adapters/orchestrator. */
|
|
46
48
|
export interface MutantRunResult {
|
|
47
49
|
readonly status: MutantRunStatus;
|
|
48
50
|
readonly durationMs: number;
|
|
49
51
|
readonly error?: string;
|
|
52
|
+
readonly passingTests?: readonly string[];
|
|
50
53
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
vi.mock('ink', () => ({
|
|
3
|
+
Box: ({ children }) => children,
|
|
4
|
+
Text: ({ children }) => children,
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('ink-spinner', () => ({
|
|
7
|
+
default: () => null,
|
|
8
|
+
}));
|
|
9
|
+
describe('PoolSpinner', () => {
|
|
10
|
+
it('renders without throwing', async () => {
|
|
11
|
+
const { PoolSpinner } = await import('../PoolSpinner.js');
|
|
12
|
+
const result = PoolSpinner({ message: 'initializing...' });
|
|
13
|
+
expect(result).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -188,6 +188,95 @@ describe('coverage utilities', () => {
|
|
|
188
188
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
189
189
|
}
|
|
190
190
|
});
|
|
191
|
+
it('skips non-JSON files in coverage tmp dir', async () => {
|
|
192
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
|
|
193
|
+
const covDir = path.join(tmpDir, 'coverage');
|
|
194
|
+
const tmpSubDir = path.join(covDir, 'tmp');
|
|
195
|
+
await fs.mkdir(tmpSubDir, { recursive: true });
|
|
196
|
+
await fs.writeFile(path.join(tmpSubDir, 'data.txt'), 'some text', 'utf8');
|
|
197
|
+
try {
|
|
198
|
+
const map = await loadPerTestCoverageData(covDir, tmpDir);
|
|
199
|
+
expect(map).toBeNull();
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
it('skips invalid JSON files in coverage tmp dir', async () => {
|
|
206
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
|
|
207
|
+
const covDir = path.join(tmpDir, 'coverage');
|
|
208
|
+
const tmpSubDir = path.join(covDir, 'tmp');
|
|
209
|
+
await fs.mkdir(tmpSubDir, { recursive: true });
|
|
210
|
+
await fs.writeFile(path.join(tmpSubDir, 'bad.json'), 'not valid json', 'utf8');
|
|
211
|
+
try {
|
|
212
|
+
const map = await loadPerTestCoverageData(covDir, tmpDir);
|
|
213
|
+
expect(map).toBeNull();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
it('skips format A entries with malformed detail (no lines key)', async () => {
|
|
220
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
|
|
221
|
+
const reportsDir = path.join(tmpDir, 'coverage');
|
|
222
|
+
await fs.mkdir(reportsDir, { recursive: true });
|
|
223
|
+
const data = {
|
|
224
|
+
tests: {
|
|
225
|
+
'/test/a.spec.ts': {
|
|
226
|
+
files: {
|
|
227
|
+
'/src/a.ts': { noLines: 'wrong' }, // missing 'lines' key
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
await fs.writeFile(path.join(reportsDir, 'per-test-coverage.json'), JSON.stringify(data), 'utf8');
|
|
233
|
+
try {
|
|
234
|
+
const map = await loadPerTestCoverageData(reportsDir, tmpDir);
|
|
235
|
+
// Test entry exists but no file lines were added for the malformed detail
|
|
236
|
+
expect(map).not.toBeNull();
|
|
237
|
+
const fileMap = map.get('/test/a.spec.ts');
|
|
238
|
+
expect(fileMap).toBeDefined();
|
|
239
|
+
expect(fileMap.has('/src/a.ts')).toBe(false);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
it('skips format B entries with non-array lines value', async () => {
|
|
246
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
|
|
247
|
+
const reportsDir = path.join(tmpDir, 'coverage');
|
|
248
|
+
await fs.mkdir(reportsDir, { recursive: true });
|
|
249
|
+
const data = {
|
|
250
|
+
'/test/a.spec.ts': {
|
|
251
|
+
'/src/a.ts': 'not-an-array', // non-array value
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
await fs.writeFile(path.join(reportsDir, 'per-test-coverage.json'), JSON.stringify(data), 'utf8');
|
|
255
|
+
try {
|
|
256
|
+
const map = await loadPerTestCoverageData(reportsDir, tmpDir);
|
|
257
|
+
// Test entry exists but no file lines were added for the non-array value
|
|
258
|
+
expect(map).not.toBeNull();
|
|
259
|
+
const fileMap = map.get('/test/a.spec.ts');
|
|
260
|
+
expect(fileMap).toBeDefined();
|
|
261
|
+
expect(fileMap.has('/src/a.ts')).toBe(false);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
it('gracefully handles null JSON in per-test coverage file', async () => {
|
|
268
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
|
|
269
|
+
const covDir = path.join(tmpDir, 'coverage');
|
|
270
|
+
await fs.mkdir(covDir, { recursive: true });
|
|
271
|
+
await fs.writeFile(path.join(covDir, 'per-test-coverage.json'), 'null', 'utf8');
|
|
272
|
+
try {
|
|
273
|
+
const map = await loadPerTestCoverageData(covDir, tmpDir);
|
|
274
|
+
expect(map).toBeNull();
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
191
280
|
it('uses relative reportsDir when not absolute', async () => {
|
|
192
281
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
|
|
193
282
|
const reportsDir = path.join(tmpDir, 'reports');
|
|
@@ -58,4 +58,13 @@ describe('logger', () => {
|
|
|
58
58
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[custom-tag]'));
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
|
+
it('debug calls console.error when MUTINEER_DEBUG=1', async () => {
|
|
62
|
+
process.env.MUTINEER_DEBUG = '1';
|
|
63
|
+
vi.resetModules();
|
|
64
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
65
|
+
const { createLogger } = await import('../logger.js');
|
|
66
|
+
const log = createLogger('dbg-tag');
|
|
67
|
+
log.debug('debug msg');
|
|
68
|
+
expect(spy).toHaveBeenCalledWith('[dbg-tag] debug msg');
|
|
69
|
+
});
|
|
61
70
|
});
|
|
@@ -114,6 +114,44 @@ describe('Progress', () => {
|
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
|
+
it('defaults to bar mode when no opts provided', () => {
|
|
118
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
119
|
+
const progress = new Progress(2);
|
|
120
|
+
progress.start();
|
|
121
|
+
// Non-TTY path: logs via console
|
|
122
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('running 2 mutants'));
|
|
123
|
+
progress.finish();
|
|
124
|
+
logSpy.mockRestore();
|
|
125
|
+
});
|
|
126
|
+
it('uses 80 column fallback when stream.columns is undefined', () => {
|
|
127
|
+
const writeSpy = vi.fn();
|
|
128
|
+
const fakeStream = {
|
|
129
|
+
isTTY: true,
|
|
130
|
+
write: writeSpy,
|
|
131
|
+
columns: undefined,
|
|
132
|
+
};
|
|
133
|
+
const origStderr = process.stderr;
|
|
134
|
+
Object.defineProperty(process, 'stderr', {
|
|
135
|
+
value: fakeStream,
|
|
136
|
+
writable: true,
|
|
137
|
+
configurable: true,
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
141
|
+
const progress = new Progress(3, { mode: 'bar' });
|
|
142
|
+
progress.start();
|
|
143
|
+
// Should not throw; bar rendered with 80 column fallback
|
|
144
|
+
expect(writeSpy).toHaveBeenCalled();
|
|
145
|
+
progress.finish();
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
Object.defineProperty(process, 'stderr', {
|
|
149
|
+
value: origStderr,
|
|
150
|
+
writable: true,
|
|
151
|
+
configurable: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
117
155
|
it('handles zero total in bar mode', () => {
|
|
118
156
|
const writeSpy = vi.fn();
|
|
119
157
|
const fakeStream = {
|