@mutineerjs/mutineer 0.4.0 → 0.5.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/dist/runner/__tests__/discover.spec.js +28 -0
- package/dist/runner/__tests__/pool-executor.spec.js +84 -0
- package/dist/runner/__tests__/tasks.spec.js +14 -0
- package/dist/runner/discover.d.ts +1 -0
- package/dist/runner/discover.js +8 -2
- package/dist/runner/orchestrator.js +2 -2
- package/dist/runner/pool-executor.js +5 -1
- package/dist/runner/tasks.d.ts +3 -1
- package/dist/runner/tasks.js +10 -2
- package/dist/types/mutant.d.ts +1 -0
- package/dist/utils/__tests__/summary.spec.js +26 -0
- package/dist/utils/summary.js +9 -0
- package/package.json +1 -1
|
@@ -35,6 +35,34 @@ vi.mock('vite', async () => {
|
|
|
35
35
|
};
|
|
36
36
|
});
|
|
37
37
|
describe('autoDiscoverTargetsAndTests', () => {
|
|
38
|
+
it('directTestMap only includes direct importers', async () => {
|
|
39
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct-'));
|
|
40
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
41
|
+
const moduleX = path.join(srcDir, 'x.ts');
|
|
42
|
+
const moduleY = path.join(srcDir, 'y.ts');
|
|
43
|
+
const testFile = path.join(srcDir, 'a.test.ts');
|
|
44
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
45
|
+
await fs.writeFile(moduleY, 'export const y = 2\n', 'utf8');
|
|
46
|
+
const importY = ['im', 'port { y } from "./y"'].join('');
|
|
47
|
+
await fs.writeFile(moduleX, `${importY}\nexport const x = 1\n`, 'utf8');
|
|
48
|
+
const importX = ['im', 'port { x } from "./x"'].join('');
|
|
49
|
+
await fs.writeFile(testFile, `${importX}\nconsole.log(x)\n`, 'utf8');
|
|
50
|
+
try {
|
|
51
|
+
const { directTestMap } = await autoDiscoverTargetsAndTests(tmpDir, {
|
|
52
|
+
testPatterns: ['**/*.test.ts'],
|
|
53
|
+
});
|
|
54
|
+
const testAbs = normalizePath(testFile);
|
|
55
|
+
const xAbs = normalizePath(moduleX);
|
|
56
|
+
const yAbs = normalizePath(moduleY);
|
|
57
|
+
// x is directly imported by the test
|
|
58
|
+
expect(directTestMap.get(xAbs)?.has(testAbs)).toBe(true);
|
|
59
|
+
// y is only transitively imported (x -> y), not directly by the test
|
|
60
|
+
expect(directTestMap.get(yAbs)?.has(testAbs)).toBeFalsy();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
38
66
|
it('ignores test files when collecting mutate targets', async () => {
|
|
39
67
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-'));
|
|
40
68
|
const srcDir = path.join(tmpDir, 'src');
|
|
@@ -286,6 +286,90 @@ describe('executePool', () => {
|
|
|
286
286
|
expect(cache['missing-file-key'].status).toBe('escaped');
|
|
287
287
|
expect(cache['missing-file-key'].originalSnippet).toBeUndefined();
|
|
288
288
|
});
|
|
289
|
+
it('escaped mutant stores coveringTests', async () => {
|
|
290
|
+
const tmpFile = path.join(tmpDir, 'covering.ts');
|
|
291
|
+
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
292
|
+
const adapter = makeAdapter({
|
|
293
|
+
runMutant: vi
|
|
294
|
+
.fn()
|
|
295
|
+
.mockResolvedValue({ status: 'escaped', durationMs: 1 }),
|
|
296
|
+
});
|
|
297
|
+
const cache = {};
|
|
298
|
+
const tests = ['/tests/foo.spec.ts', '/tests/bar.spec.ts'];
|
|
299
|
+
const task = makeTask({
|
|
300
|
+
key: 'covering-key',
|
|
301
|
+
tests,
|
|
302
|
+
v: {
|
|
303
|
+
id: 'covering.ts#0',
|
|
304
|
+
name: 'flipArith',
|
|
305
|
+
file: tmpFile,
|
|
306
|
+
code: 'const x = a - b\n',
|
|
307
|
+
line: 1,
|
|
308
|
+
col: 10,
|
|
309
|
+
tests,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
await executePool({
|
|
313
|
+
tasks: [task],
|
|
314
|
+
adapter,
|
|
315
|
+
cache,
|
|
316
|
+
concurrency: 1,
|
|
317
|
+
progressMode: 'list',
|
|
318
|
+
cwd: tmpDir,
|
|
319
|
+
});
|
|
320
|
+
expect(cache['covering-key'].coveringTests).toEqual(tests);
|
|
321
|
+
});
|
|
322
|
+
it('escaped mutant uses directTests for coveringTests when available', async () => {
|
|
323
|
+
const tmpFile = path.join(tmpDir, 'direct-covering.ts');
|
|
324
|
+
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
325
|
+
const adapter = makeAdapter({
|
|
326
|
+
runMutant: vi
|
|
327
|
+
.fn()
|
|
328
|
+
.mockResolvedValue({ status: 'escaped', durationMs: 1 }),
|
|
329
|
+
});
|
|
330
|
+
const cache = {};
|
|
331
|
+
const directTests = ['/direct.spec.ts'];
|
|
332
|
+
const allTests = ['/direct.spec.ts', '/transitive.spec.ts'];
|
|
333
|
+
const task = makeTask({
|
|
334
|
+
key: 'direct-covering-key',
|
|
335
|
+
tests: allTests,
|
|
336
|
+
directTests,
|
|
337
|
+
v: {
|
|
338
|
+
id: 'direct-covering.ts#0',
|
|
339
|
+
name: 'flipArith',
|
|
340
|
+
file: tmpFile,
|
|
341
|
+
code: 'const x = a - b\n',
|
|
342
|
+
line: 1,
|
|
343
|
+
col: 10,
|
|
344
|
+
tests: allTests,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
await executePool({
|
|
348
|
+
tasks: [task],
|
|
349
|
+
adapter,
|
|
350
|
+
cache,
|
|
351
|
+
concurrency: 1,
|
|
352
|
+
progressMode: 'list',
|
|
353
|
+
cwd: tmpDir,
|
|
354
|
+
});
|
|
355
|
+
expect(cache['direct-covering-key'].coveringTests).toEqual(directTests);
|
|
356
|
+
});
|
|
357
|
+
it('killed mutant does not store coveringTests', async () => {
|
|
358
|
+
const adapter = makeAdapter({
|
|
359
|
+
runMutant: vi.fn().mockResolvedValue({ status: 'killed', durationMs: 1 }),
|
|
360
|
+
});
|
|
361
|
+
const cache = {};
|
|
362
|
+
const task = makeTask({ key: 'killed-covering-key' });
|
|
363
|
+
await executePool({
|
|
364
|
+
tasks: [task],
|
|
365
|
+
adapter,
|
|
366
|
+
cache,
|
|
367
|
+
concurrency: 1,
|
|
368
|
+
progressMode: 'list',
|
|
369
|
+
cwd: tmpDir,
|
|
370
|
+
});
|
|
371
|
+
expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
|
|
372
|
+
});
|
|
289
373
|
it('handles adapter errors gracefully and still shuts down', async () => {
|
|
290
374
|
const adapter = makeAdapter({
|
|
291
375
|
runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
|
|
@@ -80,6 +80,20 @@ describe('prepareTasks', () => {
|
|
|
80
80
|
const tasks = prepareTasks([v], null);
|
|
81
81
|
expect(tasks[0].tests).toHaveLength(2);
|
|
82
82
|
});
|
|
83
|
+
it('populates directTests from directTestMap', () => {
|
|
84
|
+
const v = makeVariant({ file: '/src/file.ts', tests: ['/tests/a.test.ts'] });
|
|
85
|
+
const directTestMap = new Map([
|
|
86
|
+
['/src/file.ts', new Set(['/tests/direct.test.ts'])],
|
|
87
|
+
]);
|
|
88
|
+
const tasks = prepareTasks([v], null, directTestMap);
|
|
89
|
+
expect(tasks[0].directTests).toEqual(['/tests/direct.test.ts']);
|
|
90
|
+
});
|
|
91
|
+
it('directTests absent when not in directTestMap', () => {
|
|
92
|
+
const v = makeVariant({ file: '/src/file.ts', tests: ['/tests/a.test.ts'] });
|
|
93
|
+
const directTestMap = new Map();
|
|
94
|
+
const tasks = prepareTasks([v], null, directTestMap);
|
|
95
|
+
expect(tasks[0].directTests).toBeUndefined();
|
|
96
|
+
});
|
|
83
97
|
it('handles multiple variants', () => {
|
|
84
98
|
const variants = [
|
|
85
99
|
makeVariant({ id: 'file.ts#0', code: 'code1' }),
|
|
@@ -3,5 +3,6 @@ export type TestMap = Map<string, Set<string>>;
|
|
|
3
3
|
export interface DiscoveryResult {
|
|
4
4
|
readonly targets: MutateTarget[];
|
|
5
5
|
readonly testMap: TestMap;
|
|
6
|
+
readonly directTestMap: TestMap;
|
|
6
7
|
}
|
|
7
8
|
export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig): Promise<DiscoveryResult>;
|
package/dist/runner/discover.js
CHANGED
|
@@ -208,12 +208,13 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
208
208
|
ignore,
|
|
209
209
|
});
|
|
210
210
|
if (!tests.length)
|
|
211
|
-
return { targets: [], testMap: new Map() };
|
|
211
|
+
return { targets: [], testMap: new Map(), directTestMap: new Map() };
|
|
212
212
|
const testSet = new Set(tests.map((t) => normalizePath(t)));
|
|
213
213
|
// 2) Create resolver (Vite if available, otherwise Node-based fallback)
|
|
214
214
|
const { resolve, cleanup } = await createResolver(rootAbs, exts);
|
|
215
215
|
const targets = new Map();
|
|
216
216
|
const testMap = new Map();
|
|
217
|
+
const directTestMap = new Map();
|
|
217
218
|
const contentCache = new Map();
|
|
218
219
|
const resolveCache = new Map(); // key: importer\0spec -> resolved id
|
|
219
220
|
async function crawl(absFile, depth, seen, currentTestAbs) {
|
|
@@ -242,6 +243,11 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
242
243
|
if (!testMap.has(key))
|
|
243
244
|
testMap.set(key, new Set());
|
|
244
245
|
testMap.get(key).add(currentTestAbs);
|
|
246
|
+
if (depth === 0) {
|
|
247
|
+
if (!directTestMap.has(key))
|
|
248
|
+
directTestMap.set(key, new Set());
|
|
249
|
+
directTestMap.get(key).add(currentTestAbs);
|
|
250
|
+
}
|
|
245
251
|
}
|
|
246
252
|
// read file content to find further imports (works for .vue too; imports are inside <script>)
|
|
247
253
|
let code = contentCache.get(absFile);
|
|
@@ -297,7 +303,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
297
303
|
await crawl(abs, 0, seen, testAbs);
|
|
298
304
|
}
|
|
299
305
|
}
|
|
300
|
-
return { targets: Array.from(targets.values()), testMap };
|
|
306
|
+
return { targets: Array.from(targets.values()), testMap, directTestMap };
|
|
301
307
|
}
|
|
302
308
|
finally {
|
|
303
309
|
await cleanup();
|
|
@@ -68,7 +68,7 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
68
68
|
// 4. Discover targets and tests
|
|
69
69
|
const cache = await readMutantCache(cwd);
|
|
70
70
|
const discovered = await autoDiscoverTargetsAndTests(cwd, cfg);
|
|
71
|
-
const testMap = discovered
|
|
71
|
+
const { testMap, directTestMap } = discovered;
|
|
72
72
|
const targets = cfg.targets?.length
|
|
73
73
|
? [...cfg.targets]
|
|
74
74
|
: (cfg.autoDiscover ?? true)
|
|
@@ -122,7 +122,7 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
124
|
// 8. Prepare tasks and execute via worker pool
|
|
125
|
-
const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage);
|
|
125
|
+
const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage, directTestMap);
|
|
126
126
|
await executePool({
|
|
127
127
|
tasks,
|
|
128
128
|
adapter,
|
|
@@ -66,7 +66,7 @@ export async function executePool(opts) {
|
|
|
66
66
|
progress.start();
|
|
67
67
|
let nextIdx = 0;
|
|
68
68
|
async function processTask(task) {
|
|
69
|
-
const { v, tests, key } = task;
|
|
69
|
+
const { v, tests, key, directTests } = task;
|
|
70
70
|
log.debug('Cache ' + JSON.stringify(cache));
|
|
71
71
|
const cached = cache[key];
|
|
72
72
|
if (cached) {
|
|
@@ -119,6 +119,10 @@ export async function executePool(opts) {
|
|
|
119
119
|
col: v.col,
|
|
120
120
|
mutator: v.name,
|
|
121
121
|
...(originalSnippet !== undefined && { originalSnippet, mutatedSnippet }),
|
|
122
|
+
...(status === 'escaped' &&
|
|
123
|
+
(directTests ?? tests).length > 0 && {
|
|
124
|
+
coveringTests: directTests ?? tests,
|
|
125
|
+
}),
|
|
122
126
|
};
|
|
123
127
|
progress.update(status);
|
|
124
128
|
}
|
package/dist/runner/tasks.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { Variant } from '../types/mutant.js';
|
|
2
2
|
import type { PerTestCoverageMap } from '../utils/coverage.js';
|
|
3
|
+
import type { TestMap } from './discover.js';
|
|
3
4
|
export interface MutantTask {
|
|
4
5
|
v: Variant;
|
|
5
6
|
tests: string[];
|
|
6
7
|
key: string;
|
|
8
|
+
directTests?: readonly string[];
|
|
7
9
|
}
|
|
8
10
|
/**
|
|
9
11
|
* Prepare mutant tasks from variants by pruning tests via per-test coverage,
|
|
10
12
|
* sorting tests deterministically, and computing cache keys.
|
|
11
13
|
*/
|
|
12
|
-
export declare function prepareTasks(variants: readonly Variant[], perTestCoverage: PerTestCoverageMap | null): MutantTask[];
|
|
14
|
+
export declare function prepareTasks(variants: readonly Variant[], perTestCoverage: PerTestCoverageMap | null, directTestMap?: TestMap): MutantTask[];
|
package/dist/runner/tasks.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { filterTestsByCoverage } from './variants.js';
|
|
2
2
|
import { hash, keyForTests } from './cache.js';
|
|
3
3
|
import { createLogger } from '../utils/logger.js';
|
|
4
|
+
import { normalizePath } from '../utils/normalizePath.js';
|
|
4
5
|
const log = createLogger('tasks');
|
|
5
6
|
/**
|
|
6
7
|
* Prepare mutant tasks from variants by pruning tests via per-test coverage,
|
|
7
8
|
* sorting tests deterministically, and computing cache keys.
|
|
8
9
|
*/
|
|
9
|
-
export function prepareTasks(variants, perTestCoverage) {
|
|
10
|
+
export function prepareTasks(variants, perTestCoverage, directTestMap) {
|
|
10
11
|
return variants.map((v) => {
|
|
11
12
|
let tests = Array.from(v.tests);
|
|
12
13
|
if (perTestCoverage && tests.length) {
|
|
@@ -20,6 +21,13 @@ export function prepareTasks(variants, perTestCoverage) {
|
|
|
20
21
|
const testSig = hash(keyForTests(tests));
|
|
21
22
|
const codeSig = hash(v.code);
|
|
22
23
|
const key = `${testSig}:${codeSig}`;
|
|
23
|
-
|
|
24
|
+
const direct = directTestMap?.get(normalizePath(v.file));
|
|
25
|
+
return {
|
|
26
|
+
v,
|
|
27
|
+
tests,
|
|
28
|
+
key,
|
|
29
|
+
...(direct &&
|
|
30
|
+
direct.size > 0 && { directTests: Array.from(direct).sort() }),
|
|
31
|
+
};
|
|
24
32
|
});
|
|
25
33
|
}
|
package/dist/types/mutant.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface MutantCacheEntry extends MutantLocation {
|
|
|
27
27
|
readonly mutator: string;
|
|
28
28
|
readonly originalSnippet?: string;
|
|
29
29
|
readonly mutatedSnippet?: string;
|
|
30
|
+
readonly coveringTests?: readonly string[];
|
|
30
31
|
}
|
|
31
32
|
export interface MutantResult extends MutantCacheEntry {
|
|
32
33
|
readonly id: string;
|
|
@@ -71,6 +71,32 @@ describe('summary', () => {
|
|
|
71
71
|
expect(lines.some((l) => l.trimStart().startsWith('+ '))).toBe(false);
|
|
72
72
|
logSpy.mockRestore();
|
|
73
73
|
});
|
|
74
|
+
it('prints covering test path for escaped mutant', () => {
|
|
75
|
+
const cache = {
|
|
76
|
+
a: makeEntry({
|
|
77
|
+
status: 'escaped',
|
|
78
|
+
coveringTests: ['/abs/foo.spec.ts'],
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
const summary = computeSummary(cache);
|
|
82
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
83
|
+
printSummary(summary, cache);
|
|
84
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
85
|
+
expect(lines.some((l) => l.includes('↳'))).toBe(true);
|
|
86
|
+
expect(lines.some((l) => l.includes('foo.spec.ts'))).toBe(true);
|
|
87
|
+
logSpy.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
it('does not print covering tests when array is absent', () => {
|
|
90
|
+
const cache = {
|
|
91
|
+
a: makeEntry({ status: 'escaped' }),
|
|
92
|
+
};
|
|
93
|
+
const summary = computeSummary(cache);
|
|
94
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
95
|
+
printSummary(summary, cache);
|
|
96
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
97
|
+
expect(lines.some((l) => l.includes('↳'))).toBe(false);
|
|
98
|
+
logSpy.mockRestore();
|
|
99
|
+
});
|
|
74
100
|
it('summarise returns summary and prints', () => {
|
|
75
101
|
const cache = { a: makeEntry({ status: 'killed' }) };
|
|
76
102
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
package/dist/utils/summary.js
CHANGED
|
@@ -81,6 +81,15 @@ export function printSummary(summary, cache, durationMs) {
|
|
|
81
81
|
console.log(' ' + chalk.red('- ' + entry.originalSnippet));
|
|
82
82
|
console.log(' ' + chalk.green('+ ' + entry.mutatedSnippet));
|
|
83
83
|
}
|
|
84
|
+
if (entry.coveringTests?.length) {
|
|
85
|
+
const shown = entry.coveringTests.slice(0, 2);
|
|
86
|
+
for (const t of shown) {
|
|
87
|
+
console.log(' ' + chalk.dim('↳ ' + path.relative(cwd, t)));
|
|
88
|
+
}
|
|
89
|
+
if (entry.coveringTests.length > 2) {
|
|
90
|
+
console.log(' ' + chalk.dim(` +${entry.coveringTests.length - 2} more`));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
84
93
|
}
|
|
85
94
|
}
|
|
86
95
|
if (entriesByStatus.skipped.length) {
|