@mutineerjs/mutineer 0.10.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 +10 -10
- package/dist/__tests__/index.spec.d.ts +1 -0
- 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 +4 -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 +301 -8
- 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 -46
- package/dist/runner/jest/pool.js +4 -10
- package/dist/runner/jest/worker-runtime.js +2 -1
- package/dist/runner/orchestrator.js +7 -7
- 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 +254 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
- package/dist/runner/vitest/__tests__/pool.spec.js +674 -0
- package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +21 -0
- package/dist/runner/vitest/adapter.js +8 -46
- package/dist/runner/vitest/plugin.js +1 -7
- package/dist/runner/vitest/pool.js +5 -19
- package/dist/runner/vitest/redirect-loader.js +3 -1
- package/dist/runner/vitest/worker-runtime.js +2 -1
- package/dist/types/config.d.ts +2 -2
- 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 +56 -0
- package/dist/utils/coverage.js +3 -4
- package/dist/utils/errors.d.ts +4 -0
- package/dist/utils/errors.js +6 -0
- package/package.json +1 -1
|
@@ -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
|
});
|
|
@@ -312,6 +312,27 @@ describe('VitestWorkerRuntime', () => {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
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
|
+
});
|
|
315
336
|
it('clears state.filesMap before each run to prevent memory accumulation', async () => {
|
|
316
337
|
const { createVitest } = await import('vitest/node');
|
|
317
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,49 +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
|
-
'--report',
|
|
42
|
-
]);
|
|
43
|
-
const dropExact = new Set([
|
|
44
|
-
'-m',
|
|
45
|
-
'--mutate',
|
|
46
|
-
'--changed',
|
|
47
|
-
'--changed-with-deps',
|
|
48
|
-
'--full',
|
|
49
|
-
'--only-covered-lines',
|
|
50
|
-
'--per-test-coverage',
|
|
51
|
-
'--perTestCoverage',
|
|
52
|
-
]);
|
|
53
|
-
for (let i = 0; i < args.length; i++) {
|
|
54
|
-
const a = args[i];
|
|
55
|
-
if (dropExact.has(a))
|
|
56
|
-
continue;
|
|
57
|
-
if (consumeNext.has(a)) {
|
|
58
|
-
i++;
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (a.startsWith('--min-kill-percent='))
|
|
62
|
-
continue;
|
|
63
|
-
if (a.startsWith('--config=') || a.startsWith('-c='))
|
|
64
|
-
continue;
|
|
65
|
-
if (a.startsWith('--shard='))
|
|
66
|
-
continue;
|
|
67
|
-
out.push(a);
|
|
68
|
-
}
|
|
69
|
-
return out;
|
|
70
|
-
}
|
|
71
30
|
/**
|
|
72
31
|
* Ensure the Vitest config arg is included if specified.
|
|
73
32
|
*/
|
|
@@ -102,7 +61,7 @@ function buildVitestArgs(args, mode) {
|
|
|
102
61
|
result.push('--coverage.perTest=true');
|
|
103
62
|
}
|
|
104
63
|
// Disable coverage thresholds so baseline doesn't fail when a broader
|
|
105
|
-
// test set (e.g. from --changed-with-
|
|
64
|
+
// test set (e.g. from --changed-with-imports) lowers aggregate coverage
|
|
106
65
|
result.push('--coverage.thresholds.lines=0', '--coverage.thresholds.functions=0', '--coverage.thresholds.branches=0', '--coverage.thresholds.statements=0');
|
|
107
66
|
}
|
|
108
67
|
return result;
|
|
@@ -123,7 +82,10 @@ export class VitestAdapter {
|
|
|
123
82
|
throw new Error("Cannot find 'vitest'. Install it with: npm i -D vitest");
|
|
124
83
|
}
|
|
125
84
|
// Prepare base args by stripping mutineer-specific flags
|
|
126
|
-
const stripped = stripMutineerArgs(options.cliArgs
|
|
85
|
+
const stripped = stripMutineerArgs(options.cliArgs, {
|
|
86
|
+
extraConsumeNext: ['--shard'],
|
|
87
|
+
extraPrefixes: ['--shard='],
|
|
88
|
+
});
|
|
127
89
|
this.baseArgs = ensureConfigArg(stripped, options.config.vitestConfig, options.cwd);
|
|
128
90
|
}
|
|
129
91
|
async init(concurrencyOverride) {
|
|
@@ -206,7 +168,7 @@ export class VitestAdapter {
|
|
|
206
168
|
return {
|
|
207
169
|
status: 'error',
|
|
208
170
|
durationMs: 0,
|
|
209
|
-
error:
|
|
171
|
+
error: toErrorMessage(err),
|
|
210
172
|
};
|
|
211
173
|
}
|
|
212
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,
|
|
@@ -392,7 +378,7 @@ export async function runWithPool(pool, mutant, tests) {
|
|
|
392
378
|
return {
|
|
393
379
|
status: 'error',
|
|
394
380
|
durationMs: 0,
|
|
395
|
-
error:
|
|
381
|
+
error: toErrorMessage(err),
|
|
396
382
|
};
|
|
397
383
|
}
|
|
398
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'
|
|
@@ -128,7 +129,7 @@ export class VitestWorkerRuntime {
|
|
|
128
129
|
return {
|
|
129
130
|
killed: true,
|
|
130
131
|
durationMs: Date.now() - start,
|
|
131
|
-
error:
|
|
132
|
+
error: toErrorMessage(err),
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
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) */
|
|
@@ -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 = {
|
|
@@ -123,6 +123,23 @@ describe('summary', () => {
|
|
|
123
123
|
expect(lines.some((l) => l.includes('foo.spec.ts'))).toBe(true);
|
|
124
124
|
logSpy.mockRestore();
|
|
125
125
|
});
|
|
126
|
+
it('handles printSummary with no cache argument when total > 0', () => {
|
|
127
|
+
const summary = {
|
|
128
|
+
total: 1,
|
|
129
|
+
killed: 1,
|
|
130
|
+
escaped: 0,
|
|
131
|
+
skipped: 0,
|
|
132
|
+
timeouts: 0,
|
|
133
|
+
compileErrors: 0,
|
|
134
|
+
evaluated: 1,
|
|
135
|
+
killRate: 100,
|
|
136
|
+
};
|
|
137
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
138
|
+
// No cache passed: allEntries=[], maxPathLen=25 (|| 25), maxMutatorLen=10 (|| 10)
|
|
139
|
+
printSummary(summary);
|
|
140
|
+
expect(logSpy).toHaveBeenCalled();
|
|
141
|
+
logSpy.mockRestore();
|
|
142
|
+
});
|
|
126
143
|
it('buildJsonReport includes passingTests when present', () => {
|
|
127
144
|
const cache = {
|
|
128
145
|
a: makeEntry({
|
|
@@ -212,4 +229,43 @@ describe('summary', () => {
|
|
|
212
229
|
expect(logSpy).toHaveBeenCalled();
|
|
213
230
|
logSpy.mockRestore();
|
|
214
231
|
});
|
|
232
|
+
it('counts compile-error status in compileErrors field', () => {
|
|
233
|
+
const cache = { a: makeEntry({ status: 'compile-error' }) };
|
|
234
|
+
const s = computeSummary(cache);
|
|
235
|
+
expect(s.compileErrors).toBe(1);
|
|
236
|
+
expect(s.killed).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
it('categorizes compile-error entries without throwing in printSummary', () => {
|
|
239
|
+
const cache = {
|
|
240
|
+
a: makeEntry({ status: 'compile-error', file: '/tmp/a.ts' }),
|
|
241
|
+
};
|
|
242
|
+
const summary = computeSummary(cache);
|
|
243
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
244
|
+
printSummary(summary, cache);
|
|
245
|
+
expect(summary.compileErrors).toBe(1);
|
|
246
|
+
logSpy.mockRestore();
|
|
247
|
+
});
|
|
248
|
+
it('formats duration in minutes when duration >= 60s', () => {
|
|
249
|
+
const cache = { a: makeEntry({ status: 'killed' }) };
|
|
250
|
+
const summary = computeSummary(cache);
|
|
251
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
252
|
+
printSummary(summary, cache, 90000); // 90 seconds = 1m 30.0s
|
|
253
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
254
|
+
expect(lines.some((l) => l.includes('1m 30.0s'))).toBe(true);
|
|
255
|
+
logSpy.mockRestore();
|
|
256
|
+
});
|
|
257
|
+
it('prints +N more when escaped mutant has more than 2 covering tests', () => {
|
|
258
|
+
const cache = {
|
|
259
|
+
a: makeEntry({
|
|
260
|
+
status: 'escaped',
|
|
261
|
+
coveringTests: ['/t1.spec.ts', '/t2.spec.ts', '/t3.spec.ts'],
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
const summary = computeSummary(cache);
|
|
265
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
266
|
+
printSummary(summary, cache);
|
|
267
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
268
|
+
expect(lines.some((l) => l.includes('+1 more'))).toBe(true);
|
|
269
|
+
logSpy.mockRestore();
|
|
270
|
+
});
|
|
215
271
|
});
|
package/dist/utils/coverage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { toErrorMessage } from './errors.js';
|
|
3
4
|
/**
|
|
4
5
|
* Load and parse Istanbul-format coverage JSON file.
|
|
5
6
|
* Supports both coverage-final.json (from Istanbul) and Vitest's coverage output.
|
|
@@ -17,16 +18,14 @@ export async function loadCoverageData(coverageFile, cwd) {
|
|
|
17
18
|
raw = await fs.readFile(absPath, 'utf8');
|
|
18
19
|
}
|
|
19
20
|
catch (err) {
|
|
20
|
-
|
|
21
|
-
throw new Error(`Failed to read coverage file "${absPath}": ${msg}`);
|
|
21
|
+
throw new Error(`Failed to read coverage file "${absPath}": ${toErrorMessage(err)}`);
|
|
22
22
|
}
|
|
23
23
|
let data;
|
|
24
24
|
try {
|
|
25
25
|
data = JSON.parse(raw);
|
|
26
26
|
}
|
|
27
27
|
catch (err) {
|
|
28
|
-
|
|
29
|
-
throw new Error(`Failed to parse coverage file "${absPath}" as JSON: ${msg}`);
|
|
28
|
+
throw new Error(`Failed to parse coverage file "${absPath}" as JSON: ${toErrorMessage(err)}`);
|
|
30
29
|
}
|
|
31
30
|
const coveredLines = new Map();
|
|
32
31
|
for (const [filePath, fileCoverage] of Object.entries(data)) {
|