@mutineerjs/mutineer 0.6.0 → 0.7.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/bin/mutineer.d.ts +1 -1
- package/dist/bin/mutineer.js +3 -1
- package/dist/mutators/__tests__/operator.spec.js +97 -1
- package/dist/mutators/__tests__/registry.spec.js +8 -0
- package/dist/mutators/operator.d.ts +8 -0
- package/dist/mutators/operator.js +58 -1
- package/dist/mutators/registry.js +9 -1
- package/dist/mutators/utils.d.ts +2 -0
- package/dist/mutators/utils.js +58 -1
- package/dist/runner/__tests__/args.spec.js +57 -1
- package/dist/runner/__tests__/cache.spec.js +65 -8
- package/dist/runner/__tests__/cleanup.spec.js +30 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +2 -0
- package/dist/runner/__tests__/discover.spec.js +128 -0
- package/dist/runner/__tests__/orchestrator.spec.js +167 -2
- package/dist/runner/__tests__/pool-executor.spec.js +60 -1
- package/dist/runner/args.d.ts +13 -0
- package/dist/runner/args.js +27 -0
- package/dist/runner/cache.d.ts +19 -3
- package/dist/runner/cache.js +14 -7
- package/dist/runner/cleanup.d.ts +3 -1
- package/dist/runner/cleanup.js +18 -1
- package/dist/runner/coverage-resolver.js +1 -1
- package/dist/runner/discover.d.ts +1 -1
- package/dist/runner/discover.js +30 -20
- package/dist/runner/orchestrator.d.ts +1 -0
- package/dist/runner/orchestrator.js +22 -8
- package/dist/runner/pool-executor.d.ts +5 -0
- package/dist/runner/pool-executor.js +15 -4
- package/dist/runner/vitest/__tests__/adapter.spec.js +41 -0
- package/dist/runner/vitest/adapter.js +13 -9
- package/dist/types/config.d.ts +2 -0
- package/dist/utils/__tests__/summary.spec.js +43 -1
- package/dist/utils/summary.d.ts +18 -0
- package/dist/utils/summary.js +25 -0
- package/package.json +2 -1
|
@@ -114,6 +114,134 @@ describe('autoDiscoverTargetsAndTests', () => {
|
|
|
114
114
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
|
+
it('calls onProgress at least twice with informational messages', async () => {
|
|
118
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-progress-'));
|
|
119
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
120
|
+
const moduleFile = path.join(srcDir, 'foo.ts');
|
|
121
|
+
const testFile = path.join(srcDir, 'foo.test.ts');
|
|
122
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
123
|
+
await fs.writeFile(moduleFile, 'export const foo = 1\n', 'utf8');
|
|
124
|
+
const importLine = ['im', 'port { foo } from "./foo"'].join('');
|
|
125
|
+
await fs.writeFile(testFile, `${importLine}\nconsole.log(foo)\n`, 'utf8');
|
|
126
|
+
const messages = [];
|
|
127
|
+
try {
|
|
128
|
+
await autoDiscoverTargetsAndTests(tmpDir, { testPatterns: ['**/*.test.ts'] }, (msg) => messages.push(msg));
|
|
129
|
+
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
130
|
+
expect(messages.some((m) => m.includes('test file'))).toBe(true);
|
|
131
|
+
expect(messages.some((m) => m.includes('Discovery complete'))).toBe(true);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
it('shared dep imported by 2 tests appears in testMap for both', async () => {
|
|
138
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-shared-'));
|
|
139
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
140
|
+
const sharedDep = path.join(srcDir, 'shared.ts');
|
|
141
|
+
const test1 = path.join(srcDir, 'a.test.ts');
|
|
142
|
+
const test2 = path.join(srcDir, 'b.test.ts');
|
|
143
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
144
|
+
await fs.writeFile(sharedDep, 'export const shared = 1\n', 'utf8');
|
|
145
|
+
const importShared = ['im', 'port { shared } from "./shared"'].join('');
|
|
146
|
+
await fs.writeFile(test1, `${importShared}\n`, 'utf8');
|
|
147
|
+
await fs.writeFile(test2, `${importShared}\n`, 'utf8');
|
|
148
|
+
try {
|
|
149
|
+
const { testMap } = await autoDiscoverTargetsAndTests(tmpDir, {
|
|
150
|
+
testPatterns: ['**/*.test.ts'],
|
|
151
|
+
});
|
|
152
|
+
const sharedAbs = normalizePath(sharedDep);
|
|
153
|
+
const test1Abs = normalizePath(test1);
|
|
154
|
+
const test2Abs = normalizePath(test2);
|
|
155
|
+
expect(testMap.get(sharedAbs)?.has(test1Abs)).toBe(true);
|
|
156
|
+
expect(testMap.get(sharedAbs)?.has(test2Abs)).toBe(true);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
it('diamond graph: shared grandchild discovered with no duplicates', async () => {
|
|
163
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-diamond-'));
|
|
164
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
165
|
+
const fileA = path.join(srcDir, 'A.ts');
|
|
166
|
+
const fileB = path.join(srcDir, 'B.ts');
|
|
167
|
+
const fileC = path.join(srcDir, 'C.ts');
|
|
168
|
+
const fileD = path.join(srcDir, 'D.ts');
|
|
169
|
+
const testFile = path.join(srcDir, 'test.test.ts');
|
|
170
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
171
|
+
await fs.writeFile(fileD, 'export const d = 4\n', 'utf8');
|
|
172
|
+
const importD = ['im', 'port { d } from "./D"'].join('');
|
|
173
|
+
await fs.writeFile(fileB, `${importD}\nexport const b = 2\n`, 'utf8');
|
|
174
|
+
await fs.writeFile(fileC, `${importD}\nexport const c = 3\n`, 'utf8');
|
|
175
|
+
const importB = ['im', 'port { b } from "./B"'].join('');
|
|
176
|
+
const importC = ['im', 'port { c } from "./C"'].join('');
|
|
177
|
+
await fs.writeFile(fileA, `${importB}\n${importC}\nexport const a = 1\n`, 'utf8');
|
|
178
|
+
const importA = ['im', 'port { a } from "./A"'].join('');
|
|
179
|
+
await fs.writeFile(testFile, `${importA}\n`, 'utf8');
|
|
180
|
+
try {
|
|
181
|
+
const { testMap } = await autoDiscoverTargetsAndTests(tmpDir, {
|
|
182
|
+
testPatterns: ['**/*.test.ts'],
|
|
183
|
+
});
|
|
184
|
+
const dAbs = normalizePath(fileD);
|
|
185
|
+
const testAbs = normalizePath(testFile);
|
|
186
|
+
expect(testMap.get(dAbs)?.has(testAbs)).toBe(true);
|
|
187
|
+
// D should only be in testMap once (Set ensures no duplicates)
|
|
188
|
+
expect(testMap.get(dAbs)?.size).toBe(1);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
it('deep chain: deepest file is discovered correctly', async () => {
|
|
195
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-deep-'));
|
|
196
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
197
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
198
|
+
// chain: test -> f1 -> f2 -> f3 -> f4 -> f5
|
|
199
|
+
const files = Array.from({ length: 5 }, (_, i) => path.join(srcDir, `f${i + 1}.ts`));
|
|
200
|
+
const testFile = path.join(srcDir, 'chain.test.ts');
|
|
201
|
+
await fs.writeFile(files[4], 'export const f5 = 5\n', 'utf8');
|
|
202
|
+
for (let i = 3; i >= 0; i--) {
|
|
203
|
+
const importNext = ['im', `port { f${i + 2} } from "./f${i + 2}"`].join('');
|
|
204
|
+
await fs.writeFile(files[i], `${importNext}\nexport const f${i + 1} = ${i + 1}\n`, 'utf8');
|
|
205
|
+
}
|
|
206
|
+
const importF1 = ['im', 'port { f1 } from "./f1"'].join('');
|
|
207
|
+
await fs.writeFile(testFile, `${importF1}\n`, 'utf8');
|
|
208
|
+
try {
|
|
209
|
+
const { testMap } = await autoDiscoverTargetsAndTests(tmpDir, {
|
|
210
|
+
testPatterns: ['**/*.test.ts'],
|
|
211
|
+
});
|
|
212
|
+
const f5Abs = normalizePath(files[4]);
|
|
213
|
+
const testAbs = normalizePath(testFile);
|
|
214
|
+
expect(testMap.get(f5Abs)?.has(testAbs)).toBe(true);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
it('2 tests directly importing same file both appear in directTestMap', async () => {
|
|
221
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct2-'));
|
|
222
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
223
|
+
const sharedDep = path.join(srcDir, 'shared.ts');
|
|
224
|
+
const test1 = path.join(srcDir, 'a.test.ts');
|
|
225
|
+
const test2 = path.join(srcDir, 'b.test.ts');
|
|
226
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
227
|
+
await fs.writeFile(sharedDep, 'export const shared = 1\n', 'utf8');
|
|
228
|
+
const importShared = ['im', 'port { shared } from "./shared"'].join('');
|
|
229
|
+
await fs.writeFile(test1, `${importShared}\n`, 'utf8');
|
|
230
|
+
await fs.writeFile(test2, `${importShared}\n`, 'utf8');
|
|
231
|
+
try {
|
|
232
|
+
const { directTestMap } = await autoDiscoverTargetsAndTests(tmpDir, {
|
|
233
|
+
testPatterns: ['**/*.test.ts'],
|
|
234
|
+
});
|
|
235
|
+
const sharedAbs = normalizePath(sharedDep);
|
|
236
|
+
const test1Abs = normalizePath(test1);
|
|
237
|
+
const test2Abs = normalizePath(test2);
|
|
238
|
+
expect(directTestMap.get(sharedAbs)?.has(test1Abs)).toBe(true);
|
|
239
|
+
expect(directTestMap.get(sharedAbs)?.has(test2Abs)).toBe(true);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
117
245
|
it('ignores test files when collecting mutate targets', async () => {
|
|
118
246
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-'));
|
|
119
247
|
const srcDir = path.join(tmpDir, 'src');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
// Mock all heavy dependencies before importing orchestrator
|
|
3
3
|
vi.mock('../config.js', () => ({
|
|
4
4
|
loadMutineerConfig: vi.fn(),
|
|
@@ -34,11 +34,26 @@ vi.mock('../discover.js', () => ({
|
|
|
34
34
|
vi.mock('../changed.js', () => ({
|
|
35
35
|
listChangedFiles: vi.fn().mockReturnValue([]),
|
|
36
36
|
}));
|
|
37
|
-
|
|
37
|
+
vi.mock('../pool-executor.js', () => ({
|
|
38
|
+
executePool: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock('../variants.js', () => ({
|
|
41
|
+
enumerateAllVariants: vi.fn().mockResolvedValue([]),
|
|
42
|
+
getTargetFile: vi
|
|
43
|
+
.fn()
|
|
44
|
+
.mockImplementation((t) => typeof t === 'string' ? t : t.file),
|
|
45
|
+
}));
|
|
46
|
+
vi.mock('../tasks.js', () => ({
|
|
47
|
+
prepareTasks: vi.fn().mockReturnValue([]),
|
|
48
|
+
}));
|
|
49
|
+
import { runOrchestrator, parseMutantTimeoutMs } from '../orchestrator.js';
|
|
38
50
|
import { loadMutineerConfig } from '../config.js';
|
|
39
51
|
import { createVitestAdapter } from '../vitest/index.js';
|
|
40
52
|
import { autoDiscoverTargetsAndTests } from '../discover.js';
|
|
41
53
|
import { listChangedFiles } from '../changed.js';
|
|
54
|
+
import { executePool } from '../pool-executor.js';
|
|
55
|
+
import { prepareTasks } from '../tasks.js';
|
|
56
|
+
import { enumerateAllVariants } from '../variants.js';
|
|
42
57
|
const mockAdapter = {
|
|
43
58
|
name: 'vitest',
|
|
44
59
|
init: vi.fn().mockResolvedValue(undefined),
|
|
@@ -54,6 +69,95 @@ beforeEach(() => {
|
|
|
54
69
|
vi.clearAllMocks();
|
|
55
70
|
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
56
71
|
});
|
|
72
|
+
describe('parseMutantTimeoutMs', () => {
|
|
73
|
+
it('returns the parsed value for a valid positive number', () => {
|
|
74
|
+
expect(parseMutantTimeoutMs('5000')).toBe(5000);
|
|
75
|
+
});
|
|
76
|
+
it('returns 30_000 for undefined', () => {
|
|
77
|
+
expect(parseMutantTimeoutMs(undefined)).toBe(30_000);
|
|
78
|
+
});
|
|
79
|
+
it('returns 30_000 for zero (kills tightenGT: n>=0 would return 0)', () => {
|
|
80
|
+
expect(parseMutantTimeoutMs('0')).toBe(30_000);
|
|
81
|
+
});
|
|
82
|
+
it('returns 30_000 for Infinity (kills andToOr: || would return Infinity)', () => {
|
|
83
|
+
expect(parseMutantTimeoutMs('Infinity')).toBe(30_000);
|
|
84
|
+
});
|
|
85
|
+
it('returns 30_000 for negative values', () => {
|
|
86
|
+
expect(parseMutantTimeoutMs('-1')).toBe(30_000);
|
|
87
|
+
});
|
|
88
|
+
it('returns 30_000 for non-numeric strings', () => {
|
|
89
|
+
expect(parseMutantTimeoutMs('abc')).toBe(30_000);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('runOrchestrator no tests found', () => {
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
process.exitCode = undefined;
|
|
95
|
+
});
|
|
96
|
+
it('sets exitCode=1 when no tests are found for targets', async () => {
|
|
97
|
+
process.exitCode = undefined;
|
|
98
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
99
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
100
|
+
targets: [],
|
|
101
|
+
testMap: new Map(),
|
|
102
|
+
directTestMap: new Map(),
|
|
103
|
+
});
|
|
104
|
+
await runOrchestrator([], '/cwd');
|
|
105
|
+
expect(process.exitCode).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
it('logs error message when no tests are found for targets', async () => {
|
|
108
|
+
process.exitCode = undefined;
|
|
109
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
110
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
111
|
+
targets: [],
|
|
112
|
+
testMap: new Map(),
|
|
113
|
+
directTestMap: new Map(),
|
|
114
|
+
});
|
|
115
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
116
|
+
await runOrchestrator([], '/cwd');
|
|
117
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No tests found for the selected targets'));
|
|
118
|
+
});
|
|
119
|
+
it('does not run baseline when no tests are found', async () => {
|
|
120
|
+
process.exitCode = undefined;
|
|
121
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
122
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
123
|
+
targets: [],
|
|
124
|
+
testMap: new Map(),
|
|
125
|
+
directTestMap: new Map(),
|
|
126
|
+
});
|
|
127
|
+
await runOrchestrator([], '/cwd');
|
|
128
|
+
expect(mockAdapter.runBaseline).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('runOrchestrator discovery logging', () => {
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
process.exitCode = undefined;
|
|
134
|
+
});
|
|
135
|
+
it('logs "Discovering tests..." before calling autoDiscoverTargetsAndTests', async () => {
|
|
136
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
137
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
138
|
+
await runOrchestrator([], '/cwd');
|
|
139
|
+
const calls = consoleSpy.mock.calls.map((c) => c[0]);
|
|
140
|
+
const discoveringIdx = calls.findIndex((m) => m === 'Discovering tests...');
|
|
141
|
+
expect(discoveringIdx).toBeGreaterThanOrEqual(0);
|
|
142
|
+
});
|
|
143
|
+
it('passes an onProgress callback to autoDiscoverTargetsAndTests', async () => {
|
|
144
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
145
|
+
await runOrchestrator([], '/cwd');
|
|
146
|
+
const [, , onProgress] = vi.mocked(autoDiscoverTargetsAndTests).mock
|
|
147
|
+
.calls[0];
|
|
148
|
+
expect(typeof onProgress).toBe('function');
|
|
149
|
+
});
|
|
150
|
+
it('logs progress messages emitted by autoDiscoverTargetsAndTests', async () => {
|
|
151
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
152
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockImplementationOnce(async (_root, _cfg, onProgress) => {
|
|
153
|
+
onProgress?.('Discovery complete: 3 source file(s), 2 test file(s)');
|
|
154
|
+
return { targets: [], testMap: new Map(), directTestMap: new Map() };
|
|
155
|
+
});
|
|
156
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
157
|
+
await runOrchestrator([], '/cwd');
|
|
158
|
+
expect(consoleSpy).toHaveBeenCalledWith('Discovery complete: 3 source file(s), 2 test file(s)');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
57
161
|
describe('runOrchestrator --changed-with-deps diagnostic', () => {
|
|
58
162
|
it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
|
|
59
163
|
const cfg = {};
|
|
@@ -139,3 +243,64 @@ describe('runOrchestrator timeout precedence', () => {
|
|
|
139
243
|
expect(Number.isFinite(call.timeoutMs)).toBe(true);
|
|
140
244
|
});
|
|
141
245
|
});
|
|
246
|
+
describe('runOrchestrator shard filtering', () => {
|
|
247
|
+
const targetFile = '/cwd/src/foo.ts';
|
|
248
|
+
const testFile = '/cwd/src/__tests__/foo.spec.ts';
|
|
249
|
+
function makeTask(key) {
|
|
250
|
+
return {
|
|
251
|
+
key,
|
|
252
|
+
v: {
|
|
253
|
+
id: `${key}`,
|
|
254
|
+
name: 'flipEQ',
|
|
255
|
+
file: targetFile,
|
|
256
|
+
code: '',
|
|
257
|
+
line: 1,
|
|
258
|
+
col: 0,
|
|
259
|
+
tests: [testFile],
|
|
260
|
+
},
|
|
261
|
+
tests: [testFile],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
vi.clearAllMocks();
|
|
266
|
+
process.exitCode = undefined;
|
|
267
|
+
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
268
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({
|
|
269
|
+
targets: [targetFile],
|
|
270
|
+
});
|
|
271
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
272
|
+
targets: [targetFile],
|
|
273
|
+
testMap: new Map([[targetFile, new Set([testFile])]]),
|
|
274
|
+
directTestMap: new Map(),
|
|
275
|
+
});
|
|
276
|
+
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
277
|
+
// Return a non-empty variants array so orchestrator doesn't exit early
|
|
278
|
+
vi.mocked(enumerateAllVariants).mockResolvedValue([{}]);
|
|
279
|
+
const tasks = ['k0', 'k1', 'k2', 'k3'].map(makeTask);
|
|
280
|
+
vi.mocked(prepareTasks).mockReturnValue(tasks);
|
|
281
|
+
});
|
|
282
|
+
afterEach(() => {
|
|
283
|
+
process.exitCode = undefined;
|
|
284
|
+
});
|
|
285
|
+
it('shard 1/2 assigns even-indexed tasks', async () => {
|
|
286
|
+
await runOrchestrator(['--shard', '1/2'], '/cwd');
|
|
287
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
288
|
+
expect(call.tasks.map((t) => t.key)).toEqual(['k0', 'k2']);
|
|
289
|
+
});
|
|
290
|
+
it('shard 2/2 assigns odd-indexed tasks', async () => {
|
|
291
|
+
await runOrchestrator(['--shard', '2/2'], '/cwd');
|
|
292
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
293
|
+
expect(call.tasks.map((t) => t.key)).toEqual(['k1', 'k3']);
|
|
294
|
+
});
|
|
295
|
+
it('does not call executePool when shard has no tasks', async () => {
|
|
296
|
+
// Only 1 task total; shard 2/2 gets nothing
|
|
297
|
+
vi.mocked(prepareTasks).mockReturnValue([makeTask('only')]);
|
|
298
|
+
await runOrchestrator(['--shard', '2/2'], '/cwd');
|
|
299
|
+
expect(executePool).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
it('propagates shard to executePool', async () => {
|
|
302
|
+
await runOrchestrator(['--shard', '1/4'], '/cwd');
|
|
303
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
304
|
+
expect(call.shard).toEqual({ index: 1, total: 4 });
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -189,10 +189,47 @@ describe('executePool', () => {
|
|
|
189
189
|
progressMode: 'list',
|
|
190
190
|
cwd: tmpDir,
|
|
191
191
|
});
|
|
192
|
-
const cacheFile = path.join(tmpDir, '.
|
|
192
|
+
const cacheFile = path.join(tmpDir, '.mutineer-cache.json');
|
|
193
193
|
const content = JSON.parse(await fs.readFile(cacheFile, 'utf8'));
|
|
194
194
|
expect(content['persist-key']).toBeDefined();
|
|
195
195
|
});
|
|
196
|
+
it('saves to shard-named cache file when shard is provided', async () => {
|
|
197
|
+
const adapter = makeAdapter();
|
|
198
|
+
const cache = {};
|
|
199
|
+
await executePool({
|
|
200
|
+
tasks: [makeTask({ key: 'shard-key' })],
|
|
201
|
+
adapter,
|
|
202
|
+
cache,
|
|
203
|
+
concurrency: 1,
|
|
204
|
+
progressMode: 'list',
|
|
205
|
+
cwd: tmpDir,
|
|
206
|
+
shard: { index: 2, total: 4 },
|
|
207
|
+
});
|
|
208
|
+
const shardFile = path.join(tmpDir, '.mutineer-cache-shard-2-of-4.json');
|
|
209
|
+
const content = JSON.parse(await fs.readFile(shardFile, 'utf8'));
|
|
210
|
+
expect(content['shard-key']).toBeDefined();
|
|
211
|
+
// default file should NOT exist
|
|
212
|
+
await expect(fs.access(path.join(tmpDir, '.mutineer-cache.json'))).rejects.toThrow();
|
|
213
|
+
});
|
|
214
|
+
it('writes shard-suffixed JSON report when shard and reportFormat=json are set', async () => {
|
|
215
|
+
const adapter = makeAdapter();
|
|
216
|
+
const cache = {};
|
|
217
|
+
await executePool({
|
|
218
|
+
tasks: [makeTask({ key: 'shard-report-key' })],
|
|
219
|
+
adapter,
|
|
220
|
+
cache,
|
|
221
|
+
concurrency: 1,
|
|
222
|
+
progressMode: 'list',
|
|
223
|
+
cwd: tmpDir,
|
|
224
|
+
reportFormat: 'json',
|
|
225
|
+
shard: { index: 1, total: 2 },
|
|
226
|
+
});
|
|
227
|
+
const reportFile = path.join(tmpDir, 'mutineer-report-shard-1-of-2.json');
|
|
228
|
+
const content = JSON.parse(await fs.readFile(reportFile, 'utf8'));
|
|
229
|
+
expect(content.schemaVersion).toBe(1);
|
|
230
|
+
// default report should NOT exist
|
|
231
|
+
await expect(fs.access(path.join(tmpDir, 'mutineer-report.json'))).rejects.toThrow();
|
|
232
|
+
});
|
|
196
233
|
it('escaped mutant stores snippets when lines differ', async () => {
|
|
197
234
|
const tmpFile = path.join(tmpDir, 'source.ts');
|
|
198
235
|
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
@@ -410,6 +447,28 @@ describe('executePool', () => {
|
|
|
410
447
|
expect(cache['cache-key-3'].originalSnippet).toBe('const x = a + b');
|
|
411
448
|
expect(cache['cache-key-3'].mutatedSnippet).toBe('const x = a / b');
|
|
412
449
|
});
|
|
450
|
+
it('does not eagerly JSON.stringify cache on every task (only once for save)', async () => {
|
|
451
|
+
const adapter = makeAdapter();
|
|
452
|
+
const cache = {};
|
|
453
|
+
const stringifySpy = vi.spyOn(JSON, 'stringify');
|
|
454
|
+
// 3 tasks — if stringify(cache) ran per-task it would be called 3 times with cache
|
|
455
|
+
await executePool({
|
|
456
|
+
tasks: [
|
|
457
|
+
makeTask({ key: 'k1' }),
|
|
458
|
+
makeTask({ key: 'k2' }),
|
|
459
|
+
makeTask({ key: 'k3' }),
|
|
460
|
+
],
|
|
461
|
+
adapter,
|
|
462
|
+
cache,
|
|
463
|
+
concurrency: 1,
|
|
464
|
+
progressMode: 'list',
|
|
465
|
+
cwd: tmpDir,
|
|
466
|
+
});
|
|
467
|
+
// saveCacheAtomic calls stringify(cache) exactly once; per-task eager call was removed
|
|
468
|
+
const cacheStringifyCalls = stringifySpy.mock.calls.filter((args) => args[0] === cache);
|
|
469
|
+
expect(cacheStringifyCalls.length).toBe(1);
|
|
470
|
+
stringifySpy.mockRestore();
|
|
471
|
+
});
|
|
413
472
|
it('handles adapter errors gracefully and still shuts down', async () => {
|
|
414
473
|
const adapter = makeAdapter({
|
|
415
474
|
runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
|
package/dist/runner/args.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export interface ParsedCliOptions {
|
|
|
20
20
|
readonly minKillPercent: number | undefined;
|
|
21
21
|
readonly runner: 'vitest' | 'jest';
|
|
22
22
|
readonly timeout: number | undefined;
|
|
23
|
+
readonly reportFormat: 'text' | 'json';
|
|
24
|
+
readonly shard: {
|
|
25
|
+
index: number;
|
|
26
|
+
total: number;
|
|
27
|
+
} | undefined;
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
25
30
|
* Parse a numeric CLI flag value.
|
|
@@ -54,6 +59,14 @@ export declare function parseProgressMode(args: readonly string[]): 'bar' | 'lis
|
|
|
54
59
|
* Handles --config=path, -c=path, --config path, and -c path.
|
|
55
60
|
*/
|
|
56
61
|
export declare function extractConfigPath(args: readonly string[]): string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Parse the --shard <n>/<total> option.
|
|
64
|
+
* Throws on bad format or out-of-range values.
|
|
65
|
+
*/
|
|
66
|
+
export declare function parseShardOption(args: readonly string[]): {
|
|
67
|
+
index: number;
|
|
68
|
+
total: number;
|
|
69
|
+
} | undefined;
|
|
57
70
|
/**
|
|
58
71
|
* Parse all CLI options.
|
|
59
72
|
*/
|
package/dist/runner/args.js
CHANGED
|
@@ -105,6 +105,28 @@ export function parseProgressMode(args) {
|
|
|
105
105
|
export function extractConfigPath(args) {
|
|
106
106
|
return readStringFlag(args, '--config', '-c');
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse the --shard <n>/<total> option.
|
|
110
|
+
* Throws on bad format or out-of-range values.
|
|
111
|
+
*/
|
|
112
|
+
export function parseShardOption(args) {
|
|
113
|
+
const raw = readStringFlag(args, '--shard');
|
|
114
|
+
if (raw === undefined)
|
|
115
|
+
return undefined;
|
|
116
|
+
const match = /^(\d+)\/(\d+)$/.exec(raw);
|
|
117
|
+
if (!match) {
|
|
118
|
+
throw new Error(`Invalid --shard format: expected <n>/<total>, got "${raw}"`);
|
|
119
|
+
}
|
|
120
|
+
const index = parseInt(match[1], 10);
|
|
121
|
+
const total = parseInt(match[2], 10);
|
|
122
|
+
if (total < 1) {
|
|
123
|
+
throw new Error(`Invalid --shard: total must be >= 1 (got ${total})`);
|
|
124
|
+
}
|
|
125
|
+
if (index < 1 || index > total) {
|
|
126
|
+
throw new Error(`Invalid --shard: index must be between 1 and ${total} (got ${index})`);
|
|
127
|
+
}
|
|
128
|
+
return { index, total };
|
|
129
|
+
}
|
|
108
130
|
/**
|
|
109
131
|
* Parse all CLI options.
|
|
110
132
|
*/
|
|
@@ -127,6 +149,9 @@ export function parseCliOptions(args, cfg) {
|
|
|
127
149
|
const configKillPercent = validatePercent(cfg.minKillPercent, 'mutineer.config minKillPercent');
|
|
128
150
|
const minKillPercent = cliKillPercent ?? configKillPercent;
|
|
129
151
|
const timeout = validatePositiveMs(readNumberFlag(args, '--timeout'), '--timeout');
|
|
152
|
+
const reportFlag = readStringFlag(args, '--report');
|
|
153
|
+
const reportFormat = reportFlag === 'json' || cfg.report === 'json' ? 'json' : 'text';
|
|
154
|
+
const shard = parseShardOption(args);
|
|
130
155
|
return {
|
|
131
156
|
configPath,
|
|
132
157
|
wantsChanged,
|
|
@@ -139,5 +164,7 @@ export function parseCliOptions(args, cfg) {
|
|
|
139
164
|
minKillPercent,
|
|
140
165
|
runner,
|
|
141
166
|
timeout,
|
|
167
|
+
reportFormat,
|
|
168
|
+
shard,
|
|
142
169
|
};
|
|
143
170
|
}
|
package/dist/runner/cache.d.ts
CHANGED
|
@@ -5,14 +5,27 @@
|
|
|
5
5
|
* Handles reading, writing, and decoding cache entries.
|
|
6
6
|
*/
|
|
7
7
|
import type { MutantCacheEntry } from '../types/mutant.js';
|
|
8
|
+
/**
|
|
9
|
+
* Get the cache filename for a given shard (or the default if none).
|
|
10
|
+
*/
|
|
11
|
+
export declare function getCacheFilename(shard?: {
|
|
12
|
+
index: number;
|
|
13
|
+
total: number;
|
|
14
|
+
}): string;
|
|
8
15
|
/**
|
|
9
16
|
* Clear the cache file at the start of a run.
|
|
10
17
|
*/
|
|
11
|
-
export declare function clearCacheOnStart(cwd: string
|
|
18
|
+
export declare function clearCacheOnStart(cwd: string, shard?: {
|
|
19
|
+
index: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}): Promise<void>;
|
|
12
22
|
/**
|
|
13
23
|
* Save cache atomically using a temp file + rename.
|
|
14
24
|
*/
|
|
15
|
-
export declare function saveCacheAtomic(cwd: string, cache: Record<string, MutantCacheEntry
|
|
25
|
+
export declare function saveCacheAtomic(cwd: string, cache: Record<string, MutantCacheEntry>, shard?: {
|
|
26
|
+
index: number;
|
|
27
|
+
total: number;
|
|
28
|
+
}): Promise<void>;
|
|
16
29
|
/**
|
|
17
30
|
* Decode a cache key into its component parts.
|
|
18
31
|
* Cache keys have the format: testSig:codeSig:file:line,col:mutator
|
|
@@ -35,4 +48,7 @@ export declare function hash(s: string): string;
|
|
|
35
48
|
* Read the mutant cache from disk.
|
|
36
49
|
* Normalizes both old (string status) and new (object) formats.
|
|
37
50
|
*/
|
|
38
|
-
export declare function readMutantCache(cwd: string
|
|
51
|
+
export declare function readMutantCache(cwd: string, shard?: {
|
|
52
|
+
index: number;
|
|
53
|
+
total: number;
|
|
54
|
+
}): Promise<Record<string, MutantCacheEntry>>;
|
package/dist/runner/cache.js
CHANGED
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
import fs from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Get the cache filename for a given shard (or the default if none).
|
|
12
|
+
*/
|
|
13
|
+
export function getCacheFilename(shard) {
|
|
14
|
+
if (!shard)
|
|
15
|
+
return '.mutineer-cache.json';
|
|
16
|
+
return `.mutineer-cache-shard-${shard.index}-of-${shard.total}.json`;
|
|
17
|
+
}
|
|
11
18
|
/**
|
|
12
19
|
* Clear the cache file at the start of a run.
|
|
13
20
|
*/
|
|
14
|
-
export async function clearCacheOnStart(cwd) {
|
|
15
|
-
const p = path.join(cwd,
|
|
21
|
+
export async function clearCacheOnStart(cwd, shard) {
|
|
22
|
+
const p = path.join(cwd, getCacheFilename(shard));
|
|
16
23
|
try {
|
|
17
24
|
await fs.unlink(p);
|
|
18
25
|
}
|
|
@@ -23,8 +30,8 @@ export async function clearCacheOnStart(cwd) {
|
|
|
23
30
|
/**
|
|
24
31
|
* Save cache atomically using a temp file + rename.
|
|
25
32
|
*/
|
|
26
|
-
export async function saveCacheAtomic(cwd, cache) {
|
|
27
|
-
const p = path.join(cwd,
|
|
33
|
+
export async function saveCacheAtomic(cwd, cache, shard) {
|
|
34
|
+
const p = path.join(cwd, getCacheFilename(shard));
|
|
28
35
|
const tmp = p + '.tmp';
|
|
29
36
|
const json = JSON.stringify(cache, null, 2);
|
|
30
37
|
await fs.writeFile(tmp, json, 'utf8');
|
|
@@ -87,8 +94,8 @@ export function hash(s) {
|
|
|
87
94
|
* Read the mutant cache from disk.
|
|
88
95
|
* Normalizes both old (string status) and new (object) formats.
|
|
89
96
|
*/
|
|
90
|
-
export async function readMutantCache(cwd) {
|
|
91
|
-
const p = path.join(cwd,
|
|
97
|
+
export async function readMutantCache(cwd, shard) {
|
|
98
|
+
const p = path.join(cwd, getCacheFilename(shard));
|
|
92
99
|
try {
|
|
93
100
|
const data = await fs.readFile(p, 'utf8');
|
|
94
101
|
const raw = JSON.parse(data);
|
package/dist/runner/cleanup.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Clean up all __mutineer__ temp directories created during mutation testing.
|
|
3
3
|
*/
|
|
4
|
-
export declare function cleanupMutineerDirs(cwd: string
|
|
4
|
+
export declare function cleanupMutineerDirs(cwd: string, opts?: {
|
|
5
|
+
includeCacheFiles?: boolean;
|
|
6
|
+
}): Promise<void>;
|
package/dist/runner/cleanup.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
/**
|
|
3
3
|
* Clean up all __mutineer__ temp directories created during mutation testing.
|
|
4
4
|
*/
|
|
5
|
-
export async function cleanupMutineerDirs(cwd) {
|
|
5
|
+
export async function cleanupMutineerDirs(cwd, opts = {}) {
|
|
6
6
|
const glob = await import('fast-glob');
|
|
7
7
|
const dirs = await glob.default('**/__mutineer__', {
|
|
8
8
|
cwd,
|
|
@@ -18,4 +18,21 @@ export async function cleanupMutineerDirs(cwd) {
|
|
|
18
18
|
// Ignore cleanup errors
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
if (opts.includeCacheFiles) {
|
|
22
|
+
// Remove cache files (new name + legacy .mutate-cache* for migration)
|
|
23
|
+
const cacheFiles = await glob.default([
|
|
24
|
+
'.mutineer-cache*.json',
|
|
25
|
+
'.mutineer-cache*.json.tmp',
|
|
26
|
+
'.mutate-cache*.json',
|
|
27
|
+
'.mutate-cache*.json.tmp',
|
|
28
|
+
], { cwd, absolute: true });
|
|
29
|
+
for (const f of cacheFiles) {
|
|
30
|
+
try {
|
|
31
|
+
await fs.unlink(f);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Ignore cleanup errors
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
21
38
|
}
|
|
@@ -15,7 +15,7 @@ export async function resolveCoverageConfig(opts, cfg, adapter, cliArgs) {
|
|
|
15
15
|
? true
|
|
16
16
|
: coveragePreference === false
|
|
17
17
|
? false
|
|
18
|
-
: isCoverageRequestedInArgs([...cliArgs])
|
|
18
|
+
: isCoverageRequestedInArgs([...cliArgs]);
|
|
19
19
|
// Load pre-existing coverage data if provided
|
|
20
20
|
let coverageData = null;
|
|
21
21
|
if (opts.coverageFilePath) {
|
|
@@ -5,4 +5,4 @@ export interface DiscoveryResult {
|
|
|
5
5
|
readonly testMap: TestMap;
|
|
6
6
|
readonly directTestMap: TestMap;
|
|
7
7
|
}
|
|
8
|
-
export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig): Promise<DiscoveryResult>;
|
|
8
|
+
export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig, onProgress?: (msg: string) => void): Promise<DiscoveryResult>;
|