@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
|
@@ -7,22 +7,41 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The worker process sets globalThis.__mutineer_redirect__ before each test run,
|
|
9
9
|
* and this plugin intercepts module loading to return the mutated code.
|
|
10
|
+
*
|
|
11
|
+
* For schema-eligible variants, the plugin serves a pre-built schema file that
|
|
12
|
+
* embeds all mutations as ternary chains keyed by globalThis.__mutineer_active_id__.
|
|
10
13
|
*/
|
|
11
14
|
import * as fs from 'node:fs';
|
|
12
15
|
import * as path from 'node:path';
|
|
13
|
-
import { getRedirect } from '../shared/index.js';
|
|
16
|
+
import { getRedirect, getSchemaFilePath } from '../shared/index.js';
|
|
14
17
|
import { createLogger } from '../../utils/logger.js';
|
|
15
18
|
const log = createLogger('mutineer:swap');
|
|
16
19
|
export function poolMutineerPlugin() {
|
|
20
|
+
// Cache schema file contents keyed by normalised source path.
|
|
21
|
+
// null = checked and no schema exists; string = schema code.
|
|
22
|
+
// Schema files are written once before the pool starts and never change,
|
|
23
|
+
// so this cache is always valid for the lifetime of the plugin.
|
|
24
|
+
const schemaCache = new Map();
|
|
17
25
|
return {
|
|
18
26
|
name: 'mutineer:swap',
|
|
19
27
|
enforce: 'pre',
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
if (!
|
|
28
|
+
config(config) {
|
|
29
|
+
const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
|
|
30
|
+
if (!activeIdFile || !path.isAbsolute(activeIdFile))
|
|
23
31
|
return null;
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
const setupFile = path.join(path.dirname(activeIdFile), 'setup.mjs');
|
|
33
|
+
const testConfig = config.test;
|
|
34
|
+
const existing = testConfig?.setupFiles;
|
|
35
|
+
const existingArr = Array.isArray(existing)
|
|
36
|
+
? existing
|
|
37
|
+
: existing
|
|
38
|
+
? [existing]
|
|
39
|
+
: [];
|
|
40
|
+
return {
|
|
41
|
+
test: { setupFiles: [...existingArr, setupFile] },
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
load(id) {
|
|
26
45
|
const cleanId = id.split('?')[0];
|
|
27
46
|
let normalizedId;
|
|
28
47
|
try {
|
|
@@ -31,18 +50,37 @@ export function poolMutineerPlugin() {
|
|
|
31
50
|
catch {
|
|
32
51
|
return null;
|
|
33
52
|
}
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
// Redirect takes priority: fallback mutations use setRedirect + invalidateFile.
|
|
54
|
+
// Must check redirect first so the schema file (which exists for this source)
|
|
55
|
+
// does not shadow the mutant code during fallback runs.
|
|
56
|
+
const redirect = getRedirect();
|
|
57
|
+
if (redirect && normalizedId === path.resolve(redirect.from)) {
|
|
37
58
|
try {
|
|
38
|
-
|
|
39
|
-
return mutatedCode;
|
|
59
|
+
return fs.readFileSync(redirect.to, 'utf8');
|
|
40
60
|
}
|
|
41
61
|
catch (err) {
|
|
42
62
|
log.error(`Failed to read mutant file: ${redirect.to} ${err}`);
|
|
43
63
|
return null;
|
|
44
64
|
}
|
|
45
65
|
}
|
|
66
|
+
// Schema path: serves pre-built schema file for schema-eligible variants.
|
|
67
|
+
// Use cache to avoid existsSync + readFileSync on every module import.
|
|
68
|
+
const cached = schemaCache.get(normalizedId);
|
|
69
|
+
if (cached !== undefined) {
|
|
70
|
+
return cached;
|
|
71
|
+
}
|
|
72
|
+
const schemaPath = getSchemaFilePath(normalizedId);
|
|
73
|
+
try {
|
|
74
|
+
if (fs.existsSync(schemaPath)) {
|
|
75
|
+
const code = fs.readFileSync(schemaPath, 'utf8');
|
|
76
|
+
schemaCache.set(normalizedId, code);
|
|
77
|
+
return code;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// fall through
|
|
82
|
+
}
|
|
83
|
+
schemaCache.set(normalizedId, null);
|
|
46
84
|
return null;
|
|
47
85
|
},
|
|
48
86
|
};
|
|
@@ -16,13 +16,14 @@ import type { MutantPayload, MutantRunResult, MutantRunSummary } from '../../typ
|
|
|
16
16
|
declare class VitestWorker extends EventEmitter {
|
|
17
17
|
private readonly cwd;
|
|
18
18
|
private readonly vitestConfig?;
|
|
19
|
+
private readonly vitestProject?;
|
|
19
20
|
readonly id: string;
|
|
20
21
|
private process;
|
|
21
22
|
private rl;
|
|
22
23
|
private pendingTask;
|
|
23
24
|
private ready;
|
|
24
25
|
private shuttingDown;
|
|
25
|
-
constructor(id: string, cwd: string, vitestConfig?: string | undefined);
|
|
26
|
+
constructor(id: string, cwd: string, vitestConfig?: string | undefined, vitestProject?: string | undefined);
|
|
26
27
|
start(): Promise<void>;
|
|
27
28
|
private handleMessage;
|
|
28
29
|
private handleExit;
|
|
@@ -36,10 +37,12 @@ export interface VitestPoolOptions {
|
|
|
36
37
|
cwd: string;
|
|
37
38
|
concurrency: number;
|
|
38
39
|
vitestConfig?: string;
|
|
40
|
+
vitestProject?: string;
|
|
39
41
|
timeoutMs?: number;
|
|
40
42
|
createWorker?: (id: string, opts: {
|
|
41
43
|
cwd: string;
|
|
42
44
|
vitestConfig?: string;
|
|
45
|
+
vitestProject?: string;
|
|
43
46
|
}) => VitestWorker;
|
|
44
47
|
}
|
|
45
48
|
export declare class VitestPool {
|
|
@@ -17,15 +17,17 @@ import * as readline from 'node:readline';
|
|
|
17
17
|
import * as fs from 'node:fs';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { EventEmitter } from 'node:events';
|
|
20
|
+
import { getActiveIdFilePath } from '../shared/index.js';
|
|
20
21
|
import { createLogger, DEBUG } from '../../utils/logger.js';
|
|
21
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
const workerLog = createLogger('VitestWorker');
|
|
23
24
|
const poolLog = createLogger('VitestPool');
|
|
24
25
|
class VitestWorker extends EventEmitter {
|
|
25
|
-
constructor(id, cwd, vitestConfig) {
|
|
26
|
+
constructor(id, cwd, vitestConfig, vitestProject) {
|
|
26
27
|
super();
|
|
27
28
|
this.cwd = cwd;
|
|
28
29
|
this.vitestConfig = vitestConfig;
|
|
30
|
+
this.vitestProject = vitestProject;
|
|
29
31
|
this.process = null;
|
|
30
32
|
this.rl = null;
|
|
31
33
|
this.pendingTask = null;
|
|
@@ -54,9 +56,13 @@ class VitestWorker extends EventEmitter {
|
|
|
54
56
|
...process.env,
|
|
55
57
|
MUTINEER_WORKER_ID: this.id,
|
|
56
58
|
MUTINEER_CWD: this.cwd,
|
|
59
|
+
MUTINEER_ACTIVE_ID_FILE: getActiveIdFilePath(this.id, this.cwd),
|
|
57
60
|
...(this.vitestConfig
|
|
58
61
|
? { MUTINEER_VITEST_CONFIG: this.vitestConfig }
|
|
59
62
|
: {}),
|
|
63
|
+
...(this.vitestProject
|
|
64
|
+
? { MUTINEER_VITEST_PROJECT: this.vitestProject }
|
|
65
|
+
: {}),
|
|
60
66
|
...(DEBUG ? { MUTINEER_DEBUG: '1' } : {}),
|
|
61
67
|
};
|
|
62
68
|
workerLog.debug(`[${this.id}] Starting worker process`);
|
|
@@ -71,6 +77,10 @@ class VitestWorker extends EventEmitter {
|
|
|
71
77
|
cwd: this.cwd,
|
|
72
78
|
env,
|
|
73
79
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
80
|
+
// Create own process group so killing this process also kills its
|
|
81
|
+
// children (Vitest inner forks). Without this, SIGKILL to worker.mjs
|
|
82
|
+
// orphans the Vitest fork workers.
|
|
83
|
+
detached: true,
|
|
74
84
|
});
|
|
75
85
|
// Handle stderr (debug/error output)
|
|
76
86
|
this.process.stderr?.on('data', (data) => {
|
|
@@ -205,8 +215,16 @@ class VitestWorker extends EventEmitter {
|
|
|
205
215
|
}
|
|
206
216
|
kill() {
|
|
207
217
|
if (this.process) {
|
|
218
|
+
const pid = this.process.pid;
|
|
208
219
|
try {
|
|
209
|
-
|
|
220
|
+
if (pid !== undefined) {
|
|
221
|
+
// Kill the entire process group (negative PID) so Vitest inner fork
|
|
222
|
+
// workers die alongside worker.mjs instead of becoming orphans.
|
|
223
|
+
process.kill(-pid, 'SIGKILL');
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
this.process.kill('SIGKILL');
|
|
227
|
+
}
|
|
210
228
|
}
|
|
211
229
|
catch {
|
|
212
230
|
// Ignore
|
|
@@ -227,6 +245,7 @@ export class VitestPool {
|
|
|
227
245
|
cwd: options.cwd,
|
|
228
246
|
concurrency: options.concurrency,
|
|
229
247
|
vitestConfig: options.vitestConfig,
|
|
248
|
+
vitestProject: options.vitestProject,
|
|
230
249
|
timeoutMs: options.timeoutMs ?? 10_000,
|
|
231
250
|
createWorker: options.createWorker,
|
|
232
251
|
};
|
|
@@ -240,8 +259,9 @@ export class VitestPool {
|
|
|
240
259
|
const worker = this.options.createWorker?.(`w${i}`, {
|
|
241
260
|
cwd: this.options.cwd,
|
|
242
261
|
vitestConfig: this.options.vitestConfig,
|
|
262
|
+
vitestProject: this.options.vitestProject,
|
|
243
263
|
}) ??
|
|
244
|
-
new VitestWorker(`w${i}`, this.options.cwd, this.options.vitestConfig);
|
|
264
|
+
new VitestWorker(`w${i}`, this.options.cwd, this.options.vitestConfig, this.options.vitestProject);
|
|
245
265
|
worker.on('exit', () => {
|
|
246
266
|
if (!this.shuttingDown) {
|
|
247
267
|
this.handleWorkerExit(worker);
|
|
@@ -268,8 +288,9 @@ export class VitestPool {
|
|
|
268
288
|
const newWorker = this.options.createWorker?.(worker.id, {
|
|
269
289
|
cwd: this.options.cwd,
|
|
270
290
|
vitestConfig: this.options.vitestConfig,
|
|
291
|
+
vitestProject: this.options.vitestProject,
|
|
271
292
|
}) ??
|
|
272
|
-
new VitestWorker(worker.id, this.options.cwd, this.options.vitestConfig);
|
|
293
|
+
new VitestWorker(worker.id, this.options.cwd, this.options.vitestConfig, this.options.vitestProject);
|
|
273
294
|
const idx = this.workers.indexOf(worker);
|
|
274
295
|
if (idx >= 0) {
|
|
275
296
|
this.workers[idx] = newWorker;
|
|
@@ -5,6 +5,14 @@ import { poolMutineerPlugin } from './plugin.js';
|
|
|
5
5
|
import { getMutantFilePath, setRedirect, clearRedirect, } from '../shared/index.js';
|
|
6
6
|
import { createLogger } from '../../utils/logger.js';
|
|
7
7
|
const log = createLogger('vitest-runtime');
|
|
8
|
+
const SETUP_MJS_CONTENT = `import { beforeAll } from 'vitest'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
const _f = process.env.MUTINEER_ACTIVE_ID_FILE
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
try { globalThis.__mutineer_active_id__ = readFileSync(_f, 'utf8').trim() || null }
|
|
13
|
+
catch { globalThis.__mutineer_active_id__ = null }
|
|
14
|
+
})
|
|
15
|
+
`;
|
|
8
16
|
export class VitestWorkerRuntime {
|
|
9
17
|
constructor(options) {
|
|
10
18
|
this.options = options;
|
|
@@ -12,12 +20,24 @@ export class VitestWorkerRuntime {
|
|
|
12
20
|
}
|
|
13
21
|
async init() {
|
|
14
22
|
try {
|
|
23
|
+
// Write setup.mjs before creating Vitest so the config hook can find it
|
|
24
|
+
const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
|
|
25
|
+
if (activeIdFile && path.isAbsolute(activeIdFile)) {
|
|
26
|
+
const mutineerDir = path.dirname(activeIdFile);
|
|
27
|
+
fs.mkdirSync(mutineerDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(path.join(mutineerDir, 'setup.mjs'), SETUP_MJS_CONTENT, 'utf8');
|
|
29
|
+
}
|
|
15
30
|
this.vitest = await createVitest('test', {
|
|
16
|
-
watch:
|
|
31
|
+
watch: false,
|
|
17
32
|
reporters: ['dot'],
|
|
18
33
|
silent: true,
|
|
19
34
|
pool: 'forks',
|
|
20
35
|
bail: 1,
|
|
36
|
+
// Limit to 1 inner fork so bail:1 stops after the first failure
|
|
37
|
+
// without spawning additional fork processes. The single fork is
|
|
38
|
+
// persistent (reused across mutant runs), eliminating per-mutant
|
|
39
|
+
// fork startup overhead.
|
|
40
|
+
maxWorkers: 1,
|
|
21
41
|
...(this.options.vitestConfigPath
|
|
22
42
|
? { config: this.options.vitestConfigPath }
|
|
23
43
|
: {}),
|
|
@@ -43,20 +63,29 @@ export class VitestWorkerRuntime {
|
|
|
43
63
|
throw new Error('Vitest runtime not initialised');
|
|
44
64
|
}
|
|
45
65
|
const start = Date.now();
|
|
66
|
+
const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
|
|
67
|
+
const useSchema = !mutant.isFallback && !!activeIdFile;
|
|
46
68
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
if (useSchema) {
|
|
70
|
+
fs.writeFileSync(activeIdFile, mutant.id, 'utf8');
|
|
71
|
+
log.debug(`Schema path: wrote active ID ${mutant.id}`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const mutantPath = getMutantFilePath(mutant.file, mutant.id);
|
|
75
|
+
fs.writeFileSync(mutantPath, mutant.code, 'utf8');
|
|
76
|
+
log.debug(`Wrote mutant to ${mutantPath}`);
|
|
77
|
+
setRedirect({
|
|
78
|
+
from: path.resolve(mutant.file),
|
|
79
|
+
to: mutantPath,
|
|
80
|
+
});
|
|
81
|
+
this.vitest.invalidateFile(mutant.file);
|
|
82
|
+
log.debug(`Invalidated ${mutant.file}`);
|
|
83
|
+
}
|
|
56
84
|
const specs = [];
|
|
85
|
+
const projectName = this.options.vitestProject ?? '';
|
|
57
86
|
for (const testFile of tests) {
|
|
58
87
|
const spec = this.vitest
|
|
59
|
-
.getProjectByName(
|
|
88
|
+
.getProjectByName(projectName)
|
|
60
89
|
?.createSpecification(testFile);
|
|
61
90
|
if (spec)
|
|
62
91
|
specs.push(spec);
|
|
@@ -68,6 +97,7 @@ export class VitestWorkerRuntime {
|
|
|
68
97
|
};
|
|
69
98
|
}
|
|
70
99
|
log.debug(`Running ${specs.length} test specs`);
|
|
100
|
+
this.vitest.state?.filesMap?.clear();
|
|
71
101
|
const results = await this.vitest.runTestSpecifications(specs);
|
|
72
102
|
const requestedModules = new Set(specs.map((s) => s.moduleId));
|
|
73
103
|
const relevantModules = results.testModules.filter((mod) => requestedModules.has(mod.moduleId));
|
|
@@ -88,14 +118,23 @@ export class VitestWorkerRuntime {
|
|
|
88
118
|
};
|
|
89
119
|
}
|
|
90
120
|
finally {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
if (useSchema) {
|
|
122
|
+
try {
|
|
123
|
+
fs.writeFileSync(activeIdFile, '', 'utf8');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
96
128
|
}
|
|
97
|
-
|
|
98
|
-
|
|
129
|
+
else {
|
|
130
|
+
clearRedirect();
|
|
131
|
+
const mutantPath = getMutantFilePath(mutant.file, mutant.id);
|
|
132
|
+
try {
|
|
133
|
+
fs.rmSync(mutantPath, { force: true });
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// ignore
|
|
137
|
+
}
|
|
99
138
|
}
|
|
100
139
|
}
|
|
101
140
|
}
|
|
@@ -26,11 +26,13 @@ async function main() {
|
|
|
26
26
|
const workerId = process.env.MUTINEER_WORKER_ID ?? 'unknown';
|
|
27
27
|
const cwd = process.env.MUTINEER_CWD ?? process.cwd();
|
|
28
28
|
const vitestConfigPath = process.env.MUTINEER_VITEST_CONFIG;
|
|
29
|
+
const vitestProject = process.env.MUTINEER_VITEST_PROJECT;
|
|
29
30
|
log.debug(`Starting worker ${workerId} in ${cwd}`);
|
|
30
31
|
const runtime = createVitestWorkerRuntime({
|
|
31
32
|
workerId,
|
|
32
33
|
cwd,
|
|
33
34
|
vitestConfigPath,
|
|
35
|
+
vitestProject,
|
|
34
36
|
});
|
|
35
37
|
try {
|
|
36
38
|
await runtime.init();
|
|
@@ -41,6 +43,14 @@ async function main() {
|
|
|
41
43
|
}
|
|
42
44
|
// Signal ready
|
|
43
45
|
send({ type: 'ready', workerId });
|
|
46
|
+
// Graceful SIGTERM handler: clean up Vitest inner forks before exiting.
|
|
47
|
+
// This runs when the parent kills the process group with SIGTERM (e.g.
|
|
48
|
+
// future graceful shutdown path). Vitest forks are in the same process
|
|
49
|
+
// group so they also receive the signal, but calling close() ensures the
|
|
50
|
+
// Vitest instance is torn down cleanly.
|
|
51
|
+
process.on('SIGTERM', () => {
|
|
52
|
+
void runtime.shutdown().finally(() => process.exit(0));
|
|
53
|
+
});
|
|
44
54
|
// Process requests from stdin
|
|
45
55
|
const rl = readline.createInterface({
|
|
46
56
|
input: process.stdin,
|
package/dist/types/config.d.ts
CHANGED
|
@@ -42,4 +42,18 @@ export interface MutineerConfig {
|
|
|
42
42
|
readonly timeout?: number;
|
|
43
43
|
/** Output report format: 'text' (default) or 'json' (writes mutineer-report.json) */
|
|
44
44
|
readonly report?: 'text' | 'json';
|
|
45
|
+
/**
|
|
46
|
+
* Enable TypeScript type checking to pre-filter mutants that produce compile errors.
|
|
47
|
+
* true = enable (requires tsconfig.json), false = disable,
|
|
48
|
+
* object = enable with optional custom tsconfig path.
|
|
49
|
+
* Defaults to auto-detect (enabled if tsconfig.json found in cwd).
|
|
50
|
+
*/
|
|
51
|
+
readonly typescript?: boolean | {
|
|
52
|
+
readonly tsconfig?: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Filter mutations to a specific Vitest workspace project.
|
|
56
|
+
* Requires a vitest.config.ts with test.projects configured.
|
|
57
|
+
*/
|
|
58
|
+
readonly vitestProject?: string | readonly string[];
|
|
45
59
|
}
|
package/dist/types/mutant.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Centralises the shapes used across the runner and adapters so we
|
|
5
5
|
* don't duplicate unions or object shapes in multiple modules.
|
|
6
6
|
*/
|
|
7
|
-
export type MutantStatus = 'killed' | 'escaped' | 'skipped' | 'error' | 'timeout';
|
|
7
|
+
export type MutantStatus = 'killed' | 'escaped' | 'skipped' | 'error' | 'timeout' | 'compile-error';
|
|
8
8
|
export type MutantRunStatus = MutantStatus;
|
|
9
9
|
export interface MutantLocation {
|
|
10
10
|
readonly file: string;
|
|
@@ -17,7 +17,10 @@ export interface MutantDescriptor extends MutantLocation {
|
|
|
17
17
|
readonly code: string;
|
|
18
18
|
}
|
|
19
19
|
/** Payload passed to workers/pools for execution. */
|
|
20
|
-
export
|
|
20
|
+
export interface MutantPayload extends MutantDescriptor {
|
|
21
|
+
/** When true, this mutant must use the legacy redirect path instead of the schema path. */
|
|
22
|
+
readonly isFallback?: boolean;
|
|
23
|
+
}
|
|
21
24
|
/** Variant with attached test files. */
|
|
22
25
|
export interface Variant extends MutantDescriptor {
|
|
23
26
|
readonly tests: readonly string[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
4
|
+
import { useState, useEffect } from 'react';
|
|
5
|
+
export function CompileErrors({ entries, cwd }) {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
const [expanded, setExpanded] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (expanded)
|
|
10
|
+
exit();
|
|
11
|
+
}, [expanded, exit]);
|
|
12
|
+
useInput((input, key) => {
|
|
13
|
+
if (input === 'e') {
|
|
14
|
+
setExpanded(true);
|
|
15
|
+
}
|
|
16
|
+
else if (key.return || input === 'q') {
|
|
17
|
+
exit();
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
if (expanded) {
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Compile Error Mutants (type-filtered):" }), entries.map((entry, i) => (_jsxs(Text, { dimColor: true, children: [' \u2022 ', path.relative(cwd, entry.file), "@", entry.line, ",", entry.col, ' ', entry.mutator] }, i)))] }));
|
|
22
|
+
}
|
|
23
|
+
return (_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Compile Error Mutants (type-filtered): ", entries.length] }), _jsx(Text, { dimColor: true, children: "e expand return skip" })] }));
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
const mockExit = vi.fn();
|
|
3
|
+
const mockSetExpanded = vi.fn();
|
|
4
|
+
let inputHandler;
|
|
5
|
+
let effectCallback;
|
|
6
|
+
vi.mock('ink', () => ({
|
|
7
|
+
Box: ({ children }) => children,
|
|
8
|
+
Text: ({ children }) => children,
|
|
9
|
+
useInput: vi.fn((fn) => {
|
|
10
|
+
inputHandler = fn;
|
|
11
|
+
}),
|
|
12
|
+
useApp: () => ({ exit: mockExit }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('react', async (importOriginal) => {
|
|
15
|
+
const actual = await importOriginal();
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
useState: vi.fn((init) => [init, mockSetExpanded]),
|
|
19
|
+
useEffect: vi.fn((fn) => {
|
|
20
|
+
effectCallback = fn;
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
import { CompileErrors } from '../CompileErrors.js';
|
|
25
|
+
import { useState } from 'react';
|
|
26
|
+
const entries = [
|
|
27
|
+
{
|
|
28
|
+
status: 'compile-error',
|
|
29
|
+
file: '/cwd/src/foo.ts',
|
|
30
|
+
line: 10,
|
|
31
|
+
col: 5,
|
|
32
|
+
mutator: 'returnToNull',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
status: 'compile-error',
|
|
36
|
+
file: '/cwd/src/bar.ts',
|
|
37
|
+
line: 20,
|
|
38
|
+
col: 3,
|
|
39
|
+
mutator: 'returnFlipBool',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
describe('CompileErrors', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mockExit.mockClear();
|
|
45
|
+
mockSetExpanded.mockClear();
|
|
46
|
+
inputHandler = undefined;
|
|
47
|
+
effectCallback = undefined;
|
|
48
|
+
vi.mocked(useState).mockImplementation(((init) => [
|
|
49
|
+
init,
|
|
50
|
+
mockSetExpanded,
|
|
51
|
+
]));
|
|
52
|
+
});
|
|
53
|
+
it('registers a useInput handler on render', () => {
|
|
54
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
55
|
+
expect(inputHandler).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
it('calls setExpanded(true) when "e" is pressed', () => {
|
|
58
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
59
|
+
inputHandler('e', { return: false });
|
|
60
|
+
expect(mockSetExpanded).toHaveBeenCalledWith(true);
|
|
61
|
+
});
|
|
62
|
+
it('calls exit() when return is pressed', () => {
|
|
63
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
64
|
+
inputHandler('', { return: true });
|
|
65
|
+
expect(mockExit).toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
it('calls exit() when "q" is pressed', () => {
|
|
68
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
69
|
+
inputHandler('q', { return: false });
|
|
70
|
+
expect(mockExit).toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
it('does not call exit() or setExpanded for other keys', () => {
|
|
73
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
74
|
+
inputHandler('x', { return: false });
|
|
75
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
76
|
+
expect(mockSetExpanded).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
it('registers a useEffect handler on render', () => {
|
|
79
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
80
|
+
expect(effectCallback).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
it('useEffect calls exit() when expanded is true', () => {
|
|
83
|
+
vi.mocked(useState).mockReturnValueOnce([
|
|
84
|
+
true,
|
|
85
|
+
mockSetExpanded,
|
|
86
|
+
]);
|
|
87
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
88
|
+
effectCallback();
|
|
89
|
+
expect(mockExit).toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
it('useEffect does not call exit() when expanded is false', () => {
|
|
92
|
+
CompileErrors({ entries, cwd: '/cwd' });
|
|
93
|
+
effectCallback();
|
|
94
|
+
expect(mockExit).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -18,13 +18,16 @@ describe('summary', () => {
|
|
|
18
18
|
a: makeEntry({ status: 'killed' }),
|
|
19
19
|
b: makeEntry({ status: 'escaped' }),
|
|
20
20
|
c: makeEntry({ status: 'skipped' }),
|
|
21
|
+
d: makeEntry({ status: 'timeout' }),
|
|
21
22
|
};
|
|
22
23
|
const s = computeSummary(cache);
|
|
23
24
|
expect(s).toEqual({
|
|
24
|
-
total:
|
|
25
|
+
total: 4,
|
|
25
26
|
killed: 1,
|
|
26
27
|
escaped: 1,
|
|
27
28
|
skipped: 1,
|
|
29
|
+
timeouts: 1,
|
|
30
|
+
compileErrors: 0,
|
|
28
31
|
evaluated: 2,
|
|
29
32
|
killRate: 50,
|
|
30
33
|
});
|
|
@@ -43,6 +46,40 @@ describe('summary', () => {
|
|
|
43
46
|
expect(lines.some((l) => l.includes('Duration: 1.50s'))).toBe(true);
|
|
44
47
|
logSpy.mockRestore();
|
|
45
48
|
});
|
|
49
|
+
it('prints Timed Out Mutants section when timeouts exist', () => {
|
|
50
|
+
const cache = {
|
|
51
|
+
a: makeEntry({ status: 'timeout', file: '/tmp/a.ts', mutator: 'flip' }),
|
|
52
|
+
};
|
|
53
|
+
const summary = computeSummary(cache);
|
|
54
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
55
|
+
printSummary(summary, cache);
|
|
56
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
57
|
+
expect(lines.some((l) => l.includes('Timed Out Mutants'))).toBe(true);
|
|
58
|
+
logSpy.mockRestore();
|
|
59
|
+
});
|
|
60
|
+
it('shows Timeouts count in stat line when timeouts > 0', () => {
|
|
61
|
+
const cache = {
|
|
62
|
+
a: makeEntry({ status: 'timeout', file: '/tmp/a.ts' }),
|
|
63
|
+
b: makeEntry({ status: 'killed', file: '/tmp/b.ts' }),
|
|
64
|
+
};
|
|
65
|
+
const summary = computeSummary(cache);
|
|
66
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
67
|
+
printSummary(summary, cache);
|
|
68
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
69
|
+
expect(lines.some((l) => l.includes('Timeouts: 1'))).toBe(true);
|
|
70
|
+
logSpy.mockRestore();
|
|
71
|
+
});
|
|
72
|
+
it('shows Timeouts: 0 in stat line when timeouts is zero', () => {
|
|
73
|
+
const cache = {
|
|
74
|
+
a: makeEntry({ status: 'killed', file: '/tmp/a.ts' }),
|
|
75
|
+
};
|
|
76
|
+
const summary = computeSummary(cache);
|
|
77
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
78
|
+
printSummary(summary, cache);
|
|
79
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
80
|
+
expect(lines.some((l) => l.includes('Timeouts: 0'))).toBe(true);
|
|
81
|
+
logSpy.mockRestore();
|
|
82
|
+
});
|
|
46
83
|
it('prints diff lines for escaped mutant with snippets', () => {
|
|
47
84
|
const cache = {
|
|
48
85
|
a: makeEntry({
|
|
@@ -139,6 +176,51 @@ describe('summary', () => {
|
|
|
139
176
|
expect('originalSnippet' in report.mutants[0]).toBe(false);
|
|
140
177
|
expect('coveringTests' in report.mutants[0]).toBe(false);
|
|
141
178
|
});
|
|
179
|
+
it('prints compile error mutants section by default', () => {
|
|
180
|
+
const cache = {
|
|
181
|
+
a: makeEntry({
|
|
182
|
+
status: 'compile-error',
|
|
183
|
+
file: '/tmp/a.ts',
|
|
184
|
+
mutator: 'returnToNull',
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
const summary = computeSummary(cache);
|
|
188
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
189
|
+
printSummary(summary, cache);
|
|
190
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
191
|
+
expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(true);
|
|
192
|
+
logSpy.mockRestore();
|
|
193
|
+
});
|
|
194
|
+
it('skips compile error section when skipCompileErrors is true', () => {
|
|
195
|
+
const cache = {
|
|
196
|
+
a: makeEntry({
|
|
197
|
+
status: 'compile-error',
|
|
198
|
+
file: '/tmp/a.ts',
|
|
199
|
+
mutator: 'returnToNull',
|
|
200
|
+
}),
|
|
201
|
+
};
|
|
202
|
+
const summary = computeSummary(cache);
|
|
203
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
204
|
+
printSummary(summary, cache, undefined, { skipCompileErrors: true });
|
|
205
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
206
|
+
expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(false);
|
|
207
|
+
logSpy.mockRestore();
|
|
208
|
+
});
|
|
209
|
+
it('shows compile error section when skipCompileErrors is false', () => {
|
|
210
|
+
const cache = {
|
|
211
|
+
a: makeEntry({
|
|
212
|
+
status: 'compile-error',
|
|
213
|
+
file: '/tmp/a.ts',
|
|
214
|
+
mutator: 'returnToNull',
|
|
215
|
+
}),
|
|
216
|
+
};
|
|
217
|
+
const summary = computeSummary(cache);
|
|
218
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
219
|
+
printSummary(summary, cache, undefined, { skipCompileErrors: false });
|
|
220
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
221
|
+
expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(true);
|
|
222
|
+
logSpy.mockRestore();
|
|
223
|
+
});
|
|
142
224
|
it('summarise returns summary and prints', () => {
|
|
143
225
|
const cache = { a: makeEntry({ status: 'killed' }) };
|
|
144
226
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|