@mutineerjs/mutineer 0.7.0 → 0.9.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 +33 -15
- package/dist/bin/__tests__/mutineer.spec.js +75 -2
- package/dist/bin/mutineer.d.ts +6 -1
- package/dist/bin/mutineer.js +56 -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 +40 -0
- package/dist/runner/__tests__/cleanup.spec.js +7 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +4 -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 +6 -0
- package/dist/runner/args.js +12 -0
- package/dist/runner/cleanup.js +1 -1
- package/dist/runner/jest/__tests__/adapter.spec.js +49 -0
- package/dist/runner/jest/adapter.js +30 -2
- package/dist/runner/orchestrator.js +111 -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__/adapter.spec.js +70 -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 +13 -1
- package/dist/runner/vitest/plugin.d.ts +3 -0
- package/dist/runner/vitest/plugin.js +49 -11
- package/dist/runner/vitest/pool.d.ts +4 -1
- package/dist/runner/vitest/pool.js +25 -4
- package/dist/runner/vitest/worker-runtime.d.ts +1 -0
- package/dist/runner/vitest/worker-runtime.js +57 -18
- package/dist/runner/vitest/worker.mjs +10 -0
- package/dist/types/config.d.ts +14 -0
- package/dist/types/mutant.d.ts +5 -2
- package/dist/utils/CompileErrors.d.ts +7 -0
- package/dist/utils/CompileErrors.js +24 -0
- package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
- package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
- package/dist/utils/__tests__/summary.spec.js +83 -1
- package/dist/utils/summary.d.ts +5 -1
- package/dist/utils/summary.js +38 -3
- package/package.json +2 -2
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import MagicString from 'magic-string';
|
|
2
|
+
import { parse } from '@babel/parser';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
import { parserOptsTs } from '../mutators/utils.js';
|
|
5
|
+
const isWordChar = (s, i) => i >= 0 && i < s.length && /[a-zA-Z0-9_]/.test(s[i]);
|
|
6
|
+
function findSingleDiff(original, mutated) {
|
|
7
|
+
let start = 0;
|
|
8
|
+
const minLen = Math.min(original.length, mutated.length);
|
|
9
|
+
while (start < minLen && original[start] === mutated[start]) {
|
|
10
|
+
start++;
|
|
11
|
+
}
|
|
12
|
+
let origEnd = original.length;
|
|
13
|
+
let mutEnd = mutated.length;
|
|
14
|
+
while (origEnd > start &&
|
|
15
|
+
mutEnd > start &&
|
|
16
|
+
original[origEnd - 1] === mutated[mutEnd - 1]) {
|
|
17
|
+
origEnd--;
|
|
18
|
+
mutEnd--;
|
|
19
|
+
}
|
|
20
|
+
// Extend to word boundaries so we never produce partial-token ternary branches.
|
|
21
|
+
// e.g. 'true'→'false' has minimal diff 'tru'→'fals' (shared 'e' suffix), but
|
|
22
|
+
// 'tru' alone is not a valid expression — extend forward to include the 'e'.
|
|
23
|
+
while (start > 0 &&
|
|
24
|
+
isWordChar(original, start - 1) &&
|
|
25
|
+
isWordChar(original, start)) {
|
|
26
|
+
start--;
|
|
27
|
+
}
|
|
28
|
+
while (isWordChar(original, origEnd - 1) && isWordChar(original, origEnd)) {
|
|
29
|
+
origEnd++;
|
|
30
|
+
mutEnd++;
|
|
31
|
+
}
|
|
32
|
+
return { origStart: start, origEnd, mutEnd };
|
|
33
|
+
}
|
|
34
|
+
function escapeId(id) {
|
|
35
|
+
return id.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Returns true if the string is usable as a standalone JS expression fragment.
|
|
39
|
+
* Operator-only strings (e.g. '+', '!', '===') are not valid standalone expressions
|
|
40
|
+
* and cannot appear as ternary branches in generated schema code.
|
|
41
|
+
*/
|
|
42
|
+
function isExpressionFragment(s) {
|
|
43
|
+
return /[a-zA-Z0-9_]/.test(s);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse source code for AST-based span detection.
|
|
47
|
+
* Returns null if parsing fails (e.g. syntax errors in the file).
|
|
48
|
+
*/
|
|
49
|
+
function parseForSchema(code) {
|
|
50
|
+
try {
|
|
51
|
+
return parse(code, { ...parserOptsTs, tokens: false });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Find the smallest AST Expression node whose span contains `offset`.
|
|
59
|
+
* Used to expand operator-only char diffs (e.g. '+') to the full enclosing
|
|
60
|
+
* expression (e.g. 'x + y') so the ternary branch is a valid JS expression.
|
|
61
|
+
*/
|
|
62
|
+
function findSmallestEnclosingExpression(root, offset) {
|
|
63
|
+
let best = null;
|
|
64
|
+
function walk(node) {
|
|
65
|
+
if (!node ||
|
|
66
|
+
typeof node.start !== 'number' ||
|
|
67
|
+
typeof node.end !== 'number') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (offset < node.start || offset >= node.end)
|
|
71
|
+
return;
|
|
72
|
+
if (t.isExpression(node)) {
|
|
73
|
+
const span = node.end - node.start;
|
|
74
|
+
if (!best || span < best.end - best.start) {
|
|
75
|
+
best = { start: node.start, end: node.end };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const key of Object.keys(node)) {
|
|
79
|
+
if (key === 'type' ||
|
|
80
|
+
key === 'start' ||
|
|
81
|
+
key === 'end' ||
|
|
82
|
+
key === 'loc' ||
|
|
83
|
+
key === 'range' ||
|
|
84
|
+
key === 'errors' ||
|
|
85
|
+
key === 'operator') {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const child = node[key];
|
|
89
|
+
if (!child || typeof child !== 'object')
|
|
90
|
+
continue;
|
|
91
|
+
if (Array.isArray(child)) {
|
|
92
|
+
for (const item of child) {
|
|
93
|
+
if (item &&
|
|
94
|
+
typeof item === 'object' &&
|
|
95
|
+
typeof item.start === 'number') {
|
|
96
|
+
walk(item);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (typeof child.start === 'number') {
|
|
101
|
+
walk(child);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
walk(root);
|
|
106
|
+
return best;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Generate a schema file that embeds all mutation variants as a ternary chain.
|
|
110
|
+
*
|
|
111
|
+
* The schema uses `globalThis.__mutineer_active_id__` at call time to select
|
|
112
|
+
* which mutant is active, avoiding per-mutant module reloads.
|
|
113
|
+
*
|
|
114
|
+
* For value mutations (true→false, null→undefined), a character-level diff with
|
|
115
|
+
* word-boundary extension finds the span. For operator mutations (+→-, ===→!==),
|
|
116
|
+
* the Babel AST is used to find the enclosing expression (x + y, x === y) so
|
|
117
|
+
* the ternary branch is a valid JS expression.
|
|
118
|
+
*
|
|
119
|
+
* @param originalCode - The original source file contents
|
|
120
|
+
* @param variants - All variants to embed (must all be from the same source file)
|
|
121
|
+
* @returns schemaCode (the embedded schema) and fallbackIds (variants that
|
|
122
|
+
* couldn't be embedded due to overlapping diff ranges or parse errors)
|
|
123
|
+
*/
|
|
124
|
+
export function generateSchema(originalCode, variants) {
|
|
125
|
+
const fallbackIds = new Set();
|
|
126
|
+
const siteMap = new Map();
|
|
127
|
+
// Lazily parsed AST — only needed for operator mutations
|
|
128
|
+
let ast = undefined;
|
|
129
|
+
function getAst() {
|
|
130
|
+
if (ast === undefined)
|
|
131
|
+
ast = parseForSchema(originalCode);
|
|
132
|
+
return ast;
|
|
133
|
+
}
|
|
134
|
+
for (const variant of variants) {
|
|
135
|
+
const { origStart, origEnd, mutEnd } = findSingleDiff(originalCode, variant.code);
|
|
136
|
+
// Skip variants with empty diffs (identical to original)
|
|
137
|
+
if (origStart >= origEnd && origStart >= mutEnd) {
|
|
138
|
+
fallbackIds.add(variant.id);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const origSpan = originalCode.slice(origStart, origEnd);
|
|
142
|
+
const repSpan = variant.code.slice(origStart, mutEnd);
|
|
143
|
+
let siteStart;
|
|
144
|
+
let siteEnd;
|
|
145
|
+
let replacement;
|
|
146
|
+
if (isExpressionFragment(origSpan) && isExpressionFragment(repSpan)) {
|
|
147
|
+
// Value mutation (true→false, null→undefined, 0→1 etc.): char diff is usable
|
|
148
|
+
siteStart = origStart;
|
|
149
|
+
siteEnd = origEnd;
|
|
150
|
+
replacement = repSpan;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// Operator mutation (+→-, ===→!==, &&→|| etc.): char diff produces an
|
|
154
|
+
// operator-only span that isn't a valid standalone expression.
|
|
155
|
+
// Use the Babel AST to find the smallest enclosing Expression node
|
|
156
|
+
// (e.g. the BinaryExpression x + y) so the ternary branches are valid JS.
|
|
157
|
+
const parsedAst = getAst();
|
|
158
|
+
if (!parsedAst) {
|
|
159
|
+
fallbackIds.add(variant.id);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const enclosing = findSmallestEnclosingExpression(parsedAst, origStart);
|
|
163
|
+
if (!enclosing) {
|
|
164
|
+
fallbackIds.add(variant.id);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
siteStart = enclosing.start;
|
|
168
|
+
siteEnd = enclosing.end;
|
|
169
|
+
// The enclosing node end is from the original AST. For variable-length
|
|
170
|
+
// operators (e.g. '>' → '>=', '+=' → '-='), the variant code is longer or
|
|
171
|
+
// shorter by delta = mutEnd - origEnd. Adjust the slice end accordingly.
|
|
172
|
+
const delta = mutEnd - origEnd;
|
|
173
|
+
replacement = variant.code.slice(siteStart, siteEnd + delta);
|
|
174
|
+
// Sanity: if replacement equals original span, the mutation had no effect
|
|
175
|
+
if (replacement === originalCode.slice(siteStart, siteEnd)) {
|
|
176
|
+
fallbackIds.add(variant.id);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const key = `${siteStart}:${siteEnd}`;
|
|
181
|
+
if (!siteMap.has(key)) {
|
|
182
|
+
siteMap.set(key, { origStart: siteStart, origEnd: siteEnd, variants: [] });
|
|
183
|
+
}
|
|
184
|
+
siteMap.get(key).variants.push({ id: variant.id, replacement });
|
|
185
|
+
}
|
|
186
|
+
const sites = Array.from(siteMap.values()).sort((a, b) => a.origStart - b.origStart);
|
|
187
|
+
// Detect overlapping sites.
|
|
188
|
+
// When one site is fully contained within another (nested expressions), keep
|
|
189
|
+
// the inner (smaller) site and mark the outer one as fallback. For partial
|
|
190
|
+
// overlaps, mark both as fallback.
|
|
191
|
+
const overlappingSiteKeys = new Set();
|
|
192
|
+
for (let i = 0; i < sites.length; i++) {
|
|
193
|
+
for (let j = i + 1; j < sites.length; j++) {
|
|
194
|
+
const a = sites[i];
|
|
195
|
+
const b = sites[j];
|
|
196
|
+
if (b.origStart >= a.origEnd)
|
|
197
|
+
break; // sorted by start, no further overlap
|
|
198
|
+
const keyA = `${a.origStart}:${a.origEnd}`;
|
|
199
|
+
const keyB = `${b.origStart}:${b.origEnd}`;
|
|
200
|
+
if (b.origEnd <= a.origEnd) {
|
|
201
|
+
// b fully contained within a → keep b (inner), mark a (outer) as fallback
|
|
202
|
+
overlappingSiteKeys.add(keyA);
|
|
203
|
+
}
|
|
204
|
+
else if (a.origStart === b.origStart) {
|
|
205
|
+
// Same start, b is larger → b contains a → mark b (outer) as fallback
|
|
206
|
+
overlappingSiteKeys.add(keyB);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Partial overlap → mark both as fallback
|
|
210
|
+
overlappingSiteKeys.add(keyA);
|
|
211
|
+
overlappingSiteKeys.add(keyB);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const s = new MagicString(originalCode);
|
|
216
|
+
// Apply sites in descending order to preserve character positions
|
|
217
|
+
const sortedDesc = [...sites].sort((a, b) => b.origStart - a.origStart);
|
|
218
|
+
for (const site of sortedDesc) {
|
|
219
|
+
const key = `${site.origStart}:${site.origEnd}`;
|
|
220
|
+
if (overlappingSiteKeys.has(key)) {
|
|
221
|
+
for (const v of site.variants)
|
|
222
|
+
fallbackIds.add(v.id);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const originalSpan = originalCode.slice(site.origStart, site.origEnd);
|
|
226
|
+
// Build ternary chain: iterate from last variant inward, wrapping originalSpan
|
|
227
|
+
let chain = originalSpan;
|
|
228
|
+
for (let i = site.variants.length - 1; i >= 0; i--) {
|
|
229
|
+
const v = site.variants[i];
|
|
230
|
+
chain = `(globalThis.__mutineer_active_id__ === '${escapeId(v.id)}' ? (${v.replacement}) : ${chain})`;
|
|
231
|
+
}
|
|
232
|
+
s.overwrite(site.origStart, site.origEnd, chain);
|
|
233
|
+
}
|
|
234
|
+
const schemaCode = `// @ts-nocheck\n` + s.toString();
|
|
235
|
+
return { schemaCode, fallbackIds };
|
|
236
|
+
}
|
|
@@ -131,6 +131,14 @@ describe('parseCliOptions', () => {
|
|
|
131
131
|
const opts = parseCliOptions(['--changed-with-deps'], emptyCfg);
|
|
132
132
|
expect(opts.wantsChangedWithDeps).toBe(true);
|
|
133
133
|
});
|
|
134
|
+
it('parses --full flag', () => {
|
|
135
|
+
const opts = parseCliOptions(['--full'], emptyCfg);
|
|
136
|
+
expect(opts.wantsFull).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it('wantsFull defaults to false', () => {
|
|
139
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
140
|
+
expect(opts.wantsFull).toBe(false);
|
|
141
|
+
});
|
|
134
142
|
it('parses --only-covered-lines flag', () => {
|
|
135
143
|
const opts = parseCliOptions(['--only-covered-lines'], emptyCfg);
|
|
136
144
|
expect(opts.wantsOnlyCoveredLines).toBe(true);
|
|
@@ -245,6 +253,38 @@ describe('parseCliOptions', () => {
|
|
|
245
253
|
});
|
|
246
254
|
expect(opts.reportFormat).toBe('json');
|
|
247
255
|
});
|
|
256
|
+
it('parses --typescript flag', () => {
|
|
257
|
+
const opts = parseCliOptions(['--typescript'], emptyCfg);
|
|
258
|
+
expect(opts.typescriptCheck).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
it('parses --no-typescript flag', () => {
|
|
261
|
+
const opts = parseCliOptions(['--no-typescript'], emptyCfg);
|
|
262
|
+
expect(opts.typescriptCheck).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
it('defaults typescriptCheck to undefined', () => {
|
|
265
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
266
|
+
expect(opts.typescriptCheck).toBeUndefined();
|
|
267
|
+
});
|
|
268
|
+
it('--typescript takes precedence over --no-typescript (first one wins)', () => {
|
|
269
|
+
const opts = parseCliOptions(['--typescript', '--no-typescript'], emptyCfg);
|
|
270
|
+
expect(opts.typescriptCheck).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
it('parses --vitest-project flag', () => {
|
|
273
|
+
const opts = parseCliOptions(['--vitest-project', 'my-pkg'], emptyCfg);
|
|
274
|
+
expect(opts.vitestProject).toBe('my-pkg');
|
|
275
|
+
});
|
|
276
|
+
it('defaults vitestProject to undefined', () => {
|
|
277
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
278
|
+
expect(opts.vitestProject).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
it('parses --skip-baseline flag', () => {
|
|
281
|
+
const opts = parseCliOptions(['--skip-baseline'], emptyCfg);
|
|
282
|
+
expect(opts.skipBaseline).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
it('defaults skipBaseline to false', () => {
|
|
285
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
286
|
+
expect(opts.skipBaseline).toBe(false);
|
|
287
|
+
});
|
|
248
288
|
});
|
|
249
289
|
describe('validatePositiveMs', () => {
|
|
250
290
|
it('returns undefined for undefined', () => {
|
|
@@ -18,6 +18,13 @@ describe('cleanupMutineerDirs', () => {
|
|
|
18
18
|
await cleanupMutineerDirs(tmpDir);
|
|
19
19
|
await expect(fs.access(mutDir)).rejects.toThrow();
|
|
20
20
|
});
|
|
21
|
+
it('removes root-level __mutineer__ directory', async () => {
|
|
22
|
+
const rootMutDir = path.join(tmpDir, '__mutineer__');
|
|
23
|
+
await fs.mkdir(rootMutDir, { recursive: true });
|
|
24
|
+
await fs.writeFile(path.join(rootMutDir, 'setup.mjs'), 'content');
|
|
25
|
+
await cleanupMutineerDirs(tmpDir);
|
|
26
|
+
await expect(fs.access(rootMutDir)).rejects.toThrow();
|
|
27
|
+
});
|
|
21
28
|
it('removes nested __mutineer__ directories', async () => {
|
|
22
29
|
const dir1 = path.join(tmpDir, 'src', 'a', '__mutineer__');
|
|
23
30
|
const dir2 = path.join(tmpDir, 'src', 'b', '__mutineer__');
|
|
@@ -8,6 +8,7 @@ function makeOpts(overrides = {}) {
|
|
|
8
8
|
configPath: undefined,
|
|
9
9
|
wantsChanged: false,
|
|
10
10
|
wantsChangedWithDeps: false,
|
|
11
|
+
wantsFull: false,
|
|
11
12
|
wantsOnlyCoveredLines: false,
|
|
12
13
|
wantsPerTestCoverage: false,
|
|
13
14
|
coverageFilePath: undefined,
|
|
@@ -18,6 +19,9 @@ function makeOpts(overrides = {}) {
|
|
|
18
19
|
timeout: undefined,
|
|
19
20
|
reportFormat: 'text',
|
|
20
21
|
shard: undefined,
|
|
22
|
+
typescriptCheck: undefined,
|
|
23
|
+
vitestProject: undefined,
|
|
24
|
+
skipBaseline: false,
|
|
21
25
|
...overrides,
|
|
22
26
|
};
|
|
23
27
|
}
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
const { mockLogDebug } = vi.hoisted(() => ({ mockLogDebug: vi.fn() }));
|
|
3
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
4
|
+
createLogger: () => ({
|
|
5
|
+
debug: mockLogDebug,
|
|
6
|
+
info: (...args) => console.log(...args),
|
|
7
|
+
warn: (...args) => console.warn(...args),
|
|
8
|
+
error: (...args) => console.error(...args),
|
|
9
|
+
}),
|
|
10
|
+
DEBUG: true,
|
|
11
|
+
}));
|
|
2
12
|
// Mock all heavy dependencies before importing orchestrator
|
|
3
13
|
vi.mock('../config.js', () => ({
|
|
4
14
|
loadMutineerConfig: vi.fn(),
|
|
@@ -46,6 +56,19 @@ vi.mock('../variants.js', () => ({
|
|
|
46
56
|
vi.mock('../tasks.js', () => ({
|
|
47
57
|
prepareTasks: vi.fn().mockReturnValue([]),
|
|
48
58
|
}));
|
|
59
|
+
vi.mock('../ts-checker.js', () => ({
|
|
60
|
+
checkTypes: vi.fn().mockResolvedValue(new Set()),
|
|
61
|
+
resolveTypescriptEnabled: vi.fn().mockReturnValue(false),
|
|
62
|
+
resolveTsconfigPath: vi.fn().mockReturnValue(undefined),
|
|
63
|
+
}));
|
|
64
|
+
vi.mock('../../core/schemata.js', () => ({
|
|
65
|
+
generateSchema: vi
|
|
66
|
+
.fn()
|
|
67
|
+
.mockReturnValue({
|
|
68
|
+
schemaCode: '// @ts-nocheck\n',
|
|
69
|
+
fallbackIds: new Set(),
|
|
70
|
+
}),
|
|
71
|
+
}));
|
|
49
72
|
import { runOrchestrator, parseMutantTimeoutMs } from '../orchestrator.js';
|
|
50
73
|
import { loadMutineerConfig } from '../config.js';
|
|
51
74
|
import { createVitestAdapter } from '../vitest/index.js';
|
|
@@ -54,6 +77,10 @@ import { listChangedFiles } from '../changed.js';
|
|
|
54
77
|
import { executePool } from '../pool-executor.js';
|
|
55
78
|
import { prepareTasks } from '../tasks.js';
|
|
56
79
|
import { enumerateAllVariants } from '../variants.js';
|
|
80
|
+
import { generateSchema } from '../../core/schemata.js';
|
|
81
|
+
import os from 'node:os';
|
|
82
|
+
import fssync from 'node:fs';
|
|
83
|
+
import path from 'node:path';
|
|
57
84
|
const mockAdapter = {
|
|
58
85
|
name: 'vitest',
|
|
59
86
|
init: vi.fn().mockResolvedValue(undefined),
|
|
@@ -246,18 +273,21 @@ describe('runOrchestrator timeout precedence', () => {
|
|
|
246
273
|
describe('runOrchestrator shard filtering', () => {
|
|
247
274
|
const targetFile = '/cwd/src/foo.ts';
|
|
248
275
|
const testFile = '/cwd/src/__tests__/foo.spec.ts';
|
|
276
|
+
function makeVariant(id) {
|
|
277
|
+
return {
|
|
278
|
+
id,
|
|
279
|
+
name: 'flipEQ',
|
|
280
|
+
file: targetFile,
|
|
281
|
+
code: '',
|
|
282
|
+
line: 1,
|
|
283
|
+
col: 0,
|
|
284
|
+
tests: [testFile],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
249
287
|
function makeTask(key) {
|
|
250
288
|
return {
|
|
251
289
|
key,
|
|
252
|
-
v:
|
|
253
|
-
id: `${key}`,
|
|
254
|
-
name: 'flipEQ',
|
|
255
|
-
file: targetFile,
|
|
256
|
-
code: '',
|
|
257
|
-
line: 1,
|
|
258
|
-
col: 0,
|
|
259
|
-
tests: [testFile],
|
|
260
|
-
},
|
|
290
|
+
v: makeVariant(key),
|
|
261
291
|
tests: [testFile],
|
|
262
292
|
};
|
|
263
293
|
}
|
|
@@ -274,27 +304,27 @@ describe('runOrchestrator shard filtering', () => {
|
|
|
274
304
|
directTestMap: new Map(),
|
|
275
305
|
});
|
|
276
306
|
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
277
|
-
// Return
|
|
278
|
-
vi.mocked(enumerateAllVariants).mockResolvedValue([
|
|
279
|
-
|
|
280
|
-
vi.mocked(prepareTasks).
|
|
307
|
+
// Return 4 variants so shard filtering can split them across shards
|
|
308
|
+
vi.mocked(enumerateAllVariants).mockResolvedValue(['k0', 'k1', 'k2', 'k3'].map(makeVariant));
|
|
309
|
+
// Map each variant to a task by its id
|
|
310
|
+
vi.mocked(prepareTasks).mockImplementation((variants) => variants.map((v) => makeTask(v.id)));
|
|
281
311
|
});
|
|
282
312
|
afterEach(() => {
|
|
283
313
|
process.exitCode = undefined;
|
|
284
314
|
});
|
|
285
|
-
it('shard 1/2 assigns even-indexed
|
|
315
|
+
it('shard 1/2 assigns even-indexed variants', async () => {
|
|
286
316
|
await runOrchestrator(['--shard', '1/2'], '/cwd');
|
|
287
317
|
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
288
318
|
expect(call.tasks.map((t) => t.key)).toEqual(['k0', 'k2']);
|
|
289
319
|
});
|
|
290
|
-
it('shard 2/2 assigns odd-indexed
|
|
320
|
+
it('shard 2/2 assigns odd-indexed variants', async () => {
|
|
291
321
|
await runOrchestrator(['--shard', '2/2'], '/cwd');
|
|
292
322
|
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
293
323
|
expect(call.tasks.map((t) => t.key)).toEqual(['k1', 'k3']);
|
|
294
324
|
});
|
|
295
|
-
it('does not call executePool when shard has no
|
|
296
|
-
// Only 1
|
|
297
|
-
vi.mocked(
|
|
325
|
+
it('does not call executePool when shard has no variants', async () => {
|
|
326
|
+
// Only 1 variant total; shard 2/2 gets nothing
|
|
327
|
+
vi.mocked(enumerateAllVariants).mockResolvedValue([makeVariant('only')]);
|
|
298
328
|
await runOrchestrator(['--shard', '2/2'], '/cwd');
|
|
299
329
|
expect(executePool).not.toHaveBeenCalled();
|
|
300
330
|
});
|
|
@@ -304,3 +334,138 @@ describe('runOrchestrator shard filtering', () => {
|
|
|
304
334
|
expect(call.shard).toEqual({ index: 1, total: 4 });
|
|
305
335
|
});
|
|
306
336
|
});
|
|
337
|
+
describe('runOrchestrator --skip-baseline', () => {
|
|
338
|
+
const targetFile = '/cwd/src/foo.ts';
|
|
339
|
+
const testFile = '/cwd/src/__tests__/foo.spec.ts';
|
|
340
|
+
beforeEach(() => {
|
|
341
|
+
vi.clearAllMocks();
|
|
342
|
+
process.exitCode = undefined;
|
|
343
|
+
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
344
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
345
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
346
|
+
targets: [targetFile],
|
|
347
|
+
testMap: new Map([[targetFile, new Set([testFile])]]),
|
|
348
|
+
directTestMap: new Map(),
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
afterEach(() => {
|
|
352
|
+
process.exitCode = undefined;
|
|
353
|
+
});
|
|
354
|
+
it('does not call adapter.runBaseline when --skip-baseline is passed', async () => {
|
|
355
|
+
await runOrchestrator(['--skip-baseline'], '/cwd');
|
|
356
|
+
expect(mockAdapter.runBaseline).not.toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
it('logs skip message when --skip-baseline is passed', async () => {
|
|
359
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
360
|
+
await runOrchestrator(['--skip-baseline'], '/cwd');
|
|
361
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping baseline tests (--skip-baseline)'));
|
|
362
|
+
});
|
|
363
|
+
it('still calls adapter.runBaseline when --skip-baseline is absent', async () => {
|
|
364
|
+
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
365
|
+
await runOrchestrator([], '/cwd');
|
|
366
|
+
expect(mockAdapter.runBaseline).toHaveBeenCalledOnce();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
describe('runOrchestrator schema generation', () => {
|
|
370
|
+
let tmpDir;
|
|
371
|
+
const testFile = '/cwd/src/__tests__/foo.spec.ts';
|
|
372
|
+
beforeEach(() => {
|
|
373
|
+
tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-orch-schema-'));
|
|
374
|
+
vi.clearAllMocks();
|
|
375
|
+
process.exitCode = undefined;
|
|
376
|
+
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
377
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
378
|
+
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
379
|
+
});
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
process.exitCode = undefined;
|
|
382
|
+
fssync.rmSync(tmpDir, { recursive: true, force: true });
|
|
383
|
+
});
|
|
384
|
+
it('calls generateSchema and passes fallbackIds to executePool', async () => {
|
|
385
|
+
const sourceFile = path.join(tmpDir, 'source.ts');
|
|
386
|
+
fssync.writeFileSync(sourceFile, 'const x = 1 + 2', 'utf8');
|
|
387
|
+
const variant = {
|
|
388
|
+
id: 'source.ts#0',
|
|
389
|
+
name: 'flipArith',
|
|
390
|
+
file: sourceFile,
|
|
391
|
+
code: 'const x = 1 - 2',
|
|
392
|
+
line: 1,
|
|
393
|
+
col: 10,
|
|
394
|
+
tests: [testFile],
|
|
395
|
+
};
|
|
396
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
397
|
+
targets: [sourceFile],
|
|
398
|
+
testMap: new Map([[sourceFile, new Set([testFile])]]),
|
|
399
|
+
directTestMap: new Map(),
|
|
400
|
+
});
|
|
401
|
+
vi.mocked(enumerateAllVariants).mockResolvedValue([variant]);
|
|
402
|
+
vi.mocked(prepareTasks).mockReturnValue([
|
|
403
|
+
{
|
|
404
|
+
key: 'schema-test-key',
|
|
405
|
+
v: variant,
|
|
406
|
+
tests: [testFile],
|
|
407
|
+
},
|
|
408
|
+
]);
|
|
409
|
+
const mockFallbacks = new Set(['source.ts#0']);
|
|
410
|
+
vi.mocked(generateSchema).mockReturnValue({
|
|
411
|
+
schemaCode: '// @ts-nocheck\nconst x = 1',
|
|
412
|
+
fallbackIds: mockFallbacks,
|
|
413
|
+
});
|
|
414
|
+
await runOrchestrator([], tmpDir);
|
|
415
|
+
expect(generateSchema).toHaveBeenCalledWith(expect.any(String), [variant]);
|
|
416
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
417
|
+
expect(call.fallbackIds).toStrictEqual(mockFallbacks);
|
|
418
|
+
});
|
|
419
|
+
it('treats all variants as fallback when source file read fails', async () => {
|
|
420
|
+
const missingFile = path.join(tmpDir, 'nonexistent.ts');
|
|
421
|
+
const variant = {
|
|
422
|
+
id: 'nonexistent.ts#0',
|
|
423
|
+
name: 'test',
|
|
424
|
+
file: missingFile,
|
|
425
|
+
code: 'x',
|
|
426
|
+
line: 1,
|
|
427
|
+
col: 0,
|
|
428
|
+
tests: [testFile],
|
|
429
|
+
};
|
|
430
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
431
|
+
targets: [missingFile],
|
|
432
|
+
testMap: new Map([[missingFile, new Set([testFile])]]),
|
|
433
|
+
directTestMap: new Map(),
|
|
434
|
+
});
|
|
435
|
+
vi.mocked(enumerateAllVariants).mockResolvedValue([variant]);
|
|
436
|
+
vi.mocked(prepareTasks).mockReturnValue([
|
|
437
|
+
{ key: 'fallback-key', v: variant, tests: [testFile] },
|
|
438
|
+
]);
|
|
439
|
+
await runOrchestrator([], tmpDir);
|
|
440
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
441
|
+
expect(call.fallbackIds?.has('nonexistent.ts#0')).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
it('logs embedded and fallback schema counts', async () => {
|
|
444
|
+
const sourceFile = path.join(tmpDir, 'source.ts');
|
|
445
|
+
fssync.writeFileSync(sourceFile, 'const x = 1 + 2', 'utf8');
|
|
446
|
+
const variant = {
|
|
447
|
+
id: 'source.ts#0',
|
|
448
|
+
name: 'flipArith',
|
|
449
|
+
file: sourceFile,
|
|
450
|
+
code: 'const x = 1 - 2',
|
|
451
|
+
line: 1,
|
|
452
|
+
col: 10,
|
|
453
|
+
tests: [testFile],
|
|
454
|
+
};
|
|
455
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
456
|
+
targets: [sourceFile],
|
|
457
|
+
testMap: new Map([[sourceFile, new Set([testFile])]]),
|
|
458
|
+
directTestMap: new Map(),
|
|
459
|
+
});
|
|
460
|
+
vi.mocked(enumerateAllVariants).mockResolvedValue([variant]);
|
|
461
|
+
vi.mocked(prepareTasks).mockReturnValue([
|
|
462
|
+
{ key: 'log-test-key', v: variant, tests: [testFile] },
|
|
463
|
+
]);
|
|
464
|
+
vi.mocked(generateSchema).mockReturnValue({
|
|
465
|
+
schemaCode: '// @ts-nocheck\n',
|
|
466
|
+
fallbackIds: new Set(),
|
|
467
|
+
});
|
|
468
|
+
await runOrchestrator([], tmpDir);
|
|
469
|
+
expect(mockLogDebug).toHaveBeenCalledWith(expect.stringMatching(/Schema: 1 embedded, 0 fallback/));
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -407,6 +407,53 @@ describe('executePool', () => {
|
|
|
407
407
|
});
|
|
408
408
|
expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
|
|
409
409
|
});
|
|
410
|
+
it('sets isFallback=true when mutant id is in fallbackIds', async () => {
|
|
411
|
+
const adapter = makeAdapter();
|
|
412
|
+
const cache = {};
|
|
413
|
+
const task = makeTask({ key: 'fb-key' });
|
|
414
|
+
const fallbackIds = new Set(['file.ts#0']);
|
|
415
|
+
await executePool({
|
|
416
|
+
tasks: [task],
|
|
417
|
+
adapter,
|
|
418
|
+
cache,
|
|
419
|
+
concurrency: 1,
|
|
420
|
+
progressMode: 'list',
|
|
421
|
+
cwd: tmpDir,
|
|
422
|
+
fallbackIds,
|
|
423
|
+
});
|
|
424
|
+
expect(adapter.runMutant).toHaveBeenCalledWith(expect.objectContaining({ isFallback: true }), expect.any(Array));
|
|
425
|
+
});
|
|
426
|
+
it('sets isFallback=false when mutant id is not in fallbackIds', async () => {
|
|
427
|
+
const adapter = makeAdapter();
|
|
428
|
+
const cache = {};
|
|
429
|
+
const task = makeTask({ key: 'schema-key' });
|
|
430
|
+
const fallbackIds = new Set(); // empty — no fallbacks
|
|
431
|
+
await executePool({
|
|
432
|
+
tasks: [task],
|
|
433
|
+
adapter,
|
|
434
|
+
cache,
|
|
435
|
+
concurrency: 1,
|
|
436
|
+
progressMode: 'list',
|
|
437
|
+
cwd: tmpDir,
|
|
438
|
+
fallbackIds,
|
|
439
|
+
});
|
|
440
|
+
expect(adapter.runMutant).toHaveBeenCalledWith(expect.objectContaining({ isFallback: false }), expect.any(Array));
|
|
441
|
+
});
|
|
442
|
+
it('defaults isFallback=true when fallbackIds is not provided', async () => {
|
|
443
|
+
const adapter = makeAdapter();
|
|
444
|
+
const cache = {};
|
|
445
|
+
const task = makeTask({ key: 'no-fallback-ids-key' });
|
|
446
|
+
await executePool({
|
|
447
|
+
tasks: [task],
|
|
448
|
+
adapter,
|
|
449
|
+
cache,
|
|
450
|
+
concurrency: 1,
|
|
451
|
+
progressMode: 'list',
|
|
452
|
+
cwd: tmpDir,
|
|
453
|
+
// no fallbackIds
|
|
454
|
+
});
|
|
455
|
+
expect(adapter.runMutant).toHaveBeenCalledWith(expect.objectContaining({ isFallback: true }), expect.any(Array));
|
|
456
|
+
});
|
|
410
457
|
it('correctly stores snippets for multiple escaped mutants from the same file', async () => {
|
|
411
458
|
const tmpFile = path.join(tmpDir, 'shared.ts');
|
|
412
459
|
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|