@mutineerjs/mutineer 0.5.1 → 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/README.md +42 -2
- package/dist/bin/__tests__/mutineer.spec.d.ts +1 -0
- package/dist/bin/__tests__/mutineer.spec.js +43 -0
- package/dist/bin/mutineer.d.ts +2 -1
- package/dist/bin/mutineer.js +44 -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 +101 -1
- package/dist/runner/__tests__/cache.spec.js +65 -8
- package/dist/runner/__tests__/changed.spec.js +85 -2
- package/dist/runner/__tests__/cleanup.spec.js +30 -0
- package/dist/runner/__tests__/config.spec.js +2 -13
- package/dist/runner/__tests__/coverage-resolver.spec.js +9 -2
- package/dist/runner/__tests__/discover.spec.js +128 -0
- package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
- package/dist/runner/__tests__/orchestrator.spec.js +306 -0
- package/dist/runner/__tests__/pool-executor.spec.js +60 -1
- package/dist/runner/args.d.ts +18 -0
- package/dist/runner/args.js +40 -0
- package/dist/runner/cache.d.ts +19 -3
- package/dist/runner/cache.js +14 -7
- package/dist/runner/changed.js +15 -43
- package/dist/runner/cleanup.d.ts +3 -1
- package/dist/runner/cleanup.js +18 -1
- package/dist/runner/config.js +1 -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/jest/__tests__/pool.spec.js +41 -0
- package/dist/runner/jest/pool.js +3 -3
- package/dist/runner/orchestrator.d.ts +1 -0
- package/dist/runner/orchestrator.js +38 -9
- 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 +60 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +57 -0
- package/dist/runner/vitest/adapter.js +16 -9
- package/dist/runner/vitest/pool.js +3 -3
- package/dist/types/config.d.ts +4 -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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
// Mock all heavy dependencies before importing orchestrator
|
|
3
|
+
vi.mock('../config.js', () => ({
|
|
4
|
+
loadMutineerConfig: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('../cache.js', () => ({
|
|
7
|
+
clearCacheOnStart: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
readMutantCache: vi.fn().mockResolvedValue({}),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('../vitest/index.js', () => ({
|
|
11
|
+
createVitestAdapter: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../jest/index.js', () => ({
|
|
14
|
+
createJestAdapter: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('../coverage-resolver.js', () => ({
|
|
17
|
+
resolveCoverageConfig: vi.fn().mockResolvedValue({
|
|
18
|
+
enableCoverageForBaseline: false,
|
|
19
|
+
wantsPerTestCoverage: false,
|
|
20
|
+
coverageData: null,
|
|
21
|
+
}),
|
|
22
|
+
loadCoverageAfterBaseline: vi.fn().mockResolvedValue({
|
|
23
|
+
coverageData: null,
|
|
24
|
+
perTestCoverage: null,
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('../discover.js', () => ({
|
|
28
|
+
autoDiscoverTargetsAndTests: vi.fn().mockResolvedValue({
|
|
29
|
+
targets: [],
|
|
30
|
+
testMap: new Map(),
|
|
31
|
+
directTestMap: new Map(),
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('../changed.js', () => ({
|
|
35
|
+
listChangedFiles: vi.fn().mockReturnValue([]),
|
|
36
|
+
}));
|
|
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';
|
|
50
|
+
import { loadMutineerConfig } from '../config.js';
|
|
51
|
+
import { createVitestAdapter } from '../vitest/index.js';
|
|
52
|
+
import { autoDiscoverTargetsAndTests } from '../discover.js';
|
|
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';
|
|
57
|
+
const mockAdapter = {
|
|
58
|
+
name: 'vitest',
|
|
59
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
60
|
+
runBaseline: vi.fn().mockResolvedValue(true),
|
|
61
|
+
runMutant: vi.fn().mockResolvedValue({ status: 'killed', durationMs: 10 }),
|
|
62
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
63
|
+
hasCoverageProvider: vi.fn().mockReturnValue(false),
|
|
64
|
+
detectCoverageConfig: vi
|
|
65
|
+
.fn()
|
|
66
|
+
.mockResolvedValue({ perTestEnabled: false, coverageEnabled: false }),
|
|
67
|
+
};
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.clearAllMocks();
|
|
70
|
+
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
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
|
+
});
|
|
161
|
+
describe('runOrchestrator --changed-with-deps diagnostic', () => {
|
|
162
|
+
it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
|
|
163
|
+
const cfg = {};
|
|
164
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
165
|
+
const depFile = '/cwd/src/dep.ts';
|
|
166
|
+
vi.mocked(listChangedFiles).mockReturnValue([depFile]);
|
|
167
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
168
|
+
targets: [depFile],
|
|
169
|
+
testMap: new Map(), // dep has no covering tests
|
|
170
|
+
directTestMap: new Map(),
|
|
171
|
+
});
|
|
172
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
173
|
+
await runOrchestrator(['--changed-with-deps'], '/cwd');
|
|
174
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 target(s) from --changed-with-deps have no covering tests and will be skipped'));
|
|
175
|
+
});
|
|
176
|
+
it('does not log when all changed-with-deps targets have covering tests', async () => {
|
|
177
|
+
const cfg = {};
|
|
178
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
179
|
+
const depFile = '/cwd/src/dep.ts';
|
|
180
|
+
const testFile = '/cwd/src/__tests__/dep.spec.ts';
|
|
181
|
+
vi.mocked(listChangedFiles).mockReturnValue([depFile]);
|
|
182
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
183
|
+
targets: [depFile],
|
|
184
|
+
testMap: new Map([[depFile, new Set([testFile])]]),
|
|
185
|
+
directTestMap: new Map(),
|
|
186
|
+
});
|
|
187
|
+
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
188
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
189
|
+
await runOrchestrator(['--changed-with-deps'], '/cwd');
|
|
190
|
+
const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
|
|
191
|
+
expect(diagnosticCalls).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
it('does not log diagnostic when wantsChangedWithDeps is false', async () => {
|
|
194
|
+
const cfg = {};
|
|
195
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
196
|
+
const depFile = '/cwd/src/dep.ts';
|
|
197
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
198
|
+
targets: [depFile],
|
|
199
|
+
testMap: new Map(),
|
|
200
|
+
directTestMap: new Map(),
|
|
201
|
+
});
|
|
202
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
203
|
+
await runOrchestrator([], '/cwd');
|
|
204
|
+
const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
|
|
205
|
+
expect(diagnosticCalls).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('runOrchestrator timeout precedence', () => {
|
|
209
|
+
it('uses CLI --timeout when provided', async () => {
|
|
210
|
+
const cfg = {};
|
|
211
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
212
|
+
await runOrchestrator(['--timeout', '5000'], '/cwd');
|
|
213
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 5000 }));
|
|
214
|
+
});
|
|
215
|
+
it('uses config timeout when CLI flag is absent', async () => {
|
|
216
|
+
const cfg = { timeout: 10000 };
|
|
217
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
218
|
+
await runOrchestrator([], '/cwd');
|
|
219
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 10000 }));
|
|
220
|
+
});
|
|
221
|
+
it('CLI --timeout takes precedence over config timeout', async () => {
|
|
222
|
+
const cfg = { timeout: 10000 };
|
|
223
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
224
|
+
await runOrchestrator(['--timeout', '2000'], '/cwd');
|
|
225
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2000 }));
|
|
226
|
+
});
|
|
227
|
+
it('falls back to MUTANT_TIMEOUT_MS default when neither CLI nor config timeout set', async () => {
|
|
228
|
+
const cfg = {};
|
|
229
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
230
|
+
await runOrchestrator([], '/cwd');
|
|
231
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
|
|
232
|
+
});
|
|
233
|
+
it('env var MUTINEER_MUTANT_TIMEOUT_MS affects default when no CLI/config timeout', async () => {
|
|
234
|
+
const cfg = {};
|
|
235
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
236
|
+
const orig = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
|
|
237
|
+
// env var is read at module load time, so we can only verify the default
|
|
238
|
+
// is used when no cli/config override is present
|
|
239
|
+
process.env.MUTINEER_MUTANT_TIMEOUT_MS = orig;
|
|
240
|
+
await runOrchestrator([], '/cwd');
|
|
241
|
+
const call = vi.mocked(createVitestAdapter).mock.calls[0][0];
|
|
242
|
+
expect(call.timeoutMs).toBeGreaterThan(0);
|
|
243
|
+
expect(Number.isFinite(call.timeoutMs)).toBe(true);
|
|
244
|
+
});
|
|
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
|
@@ -19,6 +19,12 @@ export interface ParsedCliOptions {
|
|
|
19
19
|
readonly progressMode: 'bar' | 'list' | 'quiet';
|
|
20
20
|
readonly minKillPercent: number | undefined;
|
|
21
21
|
readonly runner: 'vitest' | 'jest';
|
|
22
|
+
readonly timeout: number | undefined;
|
|
23
|
+
readonly reportFormat: 'text' | 'json';
|
|
24
|
+
readonly shard: {
|
|
25
|
+
index: number;
|
|
26
|
+
total: number;
|
|
27
|
+
} | undefined;
|
|
22
28
|
}
|
|
23
29
|
/**
|
|
24
30
|
* Parse a numeric CLI flag value.
|
|
@@ -36,6 +42,10 @@ export declare function readStringFlag(args: readonly string[], flag: string, al
|
|
|
36
42
|
* Validate a percentage value (0-100).
|
|
37
43
|
*/
|
|
38
44
|
export declare function validatePercent(value: number | undefined, source: string): number | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Validate a timeout value (must be a positive finite number).
|
|
47
|
+
*/
|
|
48
|
+
export declare function validatePositiveMs(value: number | undefined, source: string): number | undefined;
|
|
39
49
|
/**
|
|
40
50
|
* Parse concurrency from CLI args or use default.
|
|
41
51
|
*/
|
|
@@ -49,6 +59,14 @@ export declare function parseProgressMode(args: readonly string[]): 'bar' | 'lis
|
|
|
49
59
|
* Handles --config=path, -c=path, --config path, and -c path.
|
|
50
60
|
*/
|
|
51
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;
|
|
52
70
|
/**
|
|
53
71
|
* Parse all CLI options.
|
|
54
72
|
*/
|
package/dist/runner/args.js
CHANGED
|
@@ -70,6 +70,17 @@ export function validatePercent(value, source) {
|
|
|
70
70
|
}
|
|
71
71
|
return value;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Validate a timeout value (must be a positive finite number).
|
|
75
|
+
*/
|
|
76
|
+
export function validatePositiveMs(value, source) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
80
|
+
throw new Error(`Invalid ${source}: expected a positive number (received ${value})`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
73
84
|
/**
|
|
74
85
|
* Parse concurrency from CLI args or use default.
|
|
75
86
|
*/
|
|
@@ -94,6 +105,28 @@ export function parseProgressMode(args) {
|
|
|
94
105
|
export function extractConfigPath(args) {
|
|
95
106
|
return readStringFlag(args, '--config', '-c');
|
|
96
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
|
+
}
|
|
97
130
|
/**
|
|
98
131
|
* Parse all CLI options.
|
|
99
132
|
*/
|
|
@@ -115,6 +148,10 @@ export function parseCliOptions(args, cfg) {
|
|
|
115
148
|
const cliKillPercent = validatePercent(readNumberFlag(args, '--min-kill-percent'), '--min-kill-percent');
|
|
116
149
|
const configKillPercent = validatePercent(cfg.minKillPercent, 'mutineer.config minKillPercent');
|
|
117
150
|
const minKillPercent = cliKillPercent ?? configKillPercent;
|
|
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);
|
|
118
155
|
return {
|
|
119
156
|
configPath,
|
|
120
157
|
wantsChanged,
|
|
@@ -126,5 +163,8 @@ export function parseCliOptions(args, cfg) {
|
|
|
126
163
|
progressMode,
|
|
127
164
|
minKillPercent,
|
|
128
165
|
runner,
|
|
166
|
+
timeout,
|
|
167
|
+
reportFormat,
|
|
168
|
+
shard,
|
|
129
169
|
};
|
|
130
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/changed.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import { createRequire } from 'node:module';
|
|
5
4
|
import { normalizePath } from '../utils/normalizePath.js';
|
|
6
5
|
import { createLogger } from '../utils/logger.js';
|
|
7
6
|
const log = createLogger('changed');
|
|
@@ -50,52 +49,25 @@ function resolveLocalDependencies(file, cwd, seen = new Set(), maxDepth = 1, cur
|
|
|
50
49
|
const dep = match[1];
|
|
51
50
|
if (!dep.startsWith('.'))
|
|
52
51
|
continue;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
catch {
|
|
61
|
-
// Try different extensions if direct resolution fails
|
|
62
|
-
for (const ext of SUPPORTED_EXTENSIONS) {
|
|
63
|
-
try {
|
|
64
|
-
// Try replacing existing extension
|
|
65
|
-
resolvedPath = require.resolve(dep.replace(/\.(js|ts|vue)$/, ext));
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
try {
|
|
70
|
-
// Try adding extension
|
|
71
|
-
resolvedPath = require.resolve(dep + ext);
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (!resolvedPath) {
|
|
81
|
-
log.warn(`Could not resolve path for dependency ${dep} in ${file}`);
|
|
52
|
+
const dir = path.dirname(file);
|
|
53
|
+
const base = dep.replace(/\.(js|ts|mjs|cjs|vue)$/, '');
|
|
54
|
+
const candidates = [dep, ...SUPPORTED_EXTENSIONS.map((ext) => base + ext)];
|
|
55
|
+
let resolvedPath;
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
const abs = normalizePath(path.resolve(dir, candidate));
|
|
58
|
+
if (abs.includes('node_modules') || !abs.startsWith(cwd))
|
|
82
59
|
continue;
|
|
60
|
+
if (fs.existsSync(abs)) {
|
|
61
|
+
resolvedPath = abs;
|
|
62
|
+
break;
|
|
83
63
|
}
|
|
84
|
-
// Skip anything outside the repo/cwd, node_modules, tests, or missing
|
|
85
|
-
if (!resolvedPath.startsWith(cwd) ||
|
|
86
|
-
resolvedPath.includes('node_modules'))
|
|
87
|
-
continue;
|
|
88
|
-
if (TEST_FILE_PATTERN.test(resolvedPath))
|
|
89
|
-
continue;
|
|
90
|
-
if (!fs.existsSync(resolvedPath))
|
|
91
|
-
continue;
|
|
92
|
-
deps.push(normalizePath(resolvedPath));
|
|
93
|
-
deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
|
|
94
64
|
}
|
|
95
|
-
|
|
96
|
-
log.warn(`Failed to resolve dependency ${dep} in ${file}: ${err}`);
|
|
65
|
+
if (!resolvedPath)
|
|
97
66
|
continue;
|
|
98
|
-
|
|
67
|
+
if (TEST_FILE_PATTERN.test(resolvedPath))
|
|
68
|
+
continue;
|
|
69
|
+
deps.push(resolvedPath);
|
|
70
|
+
deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
|
|
99
71
|
}
|
|
100
72
|
}
|
|
101
73
|
return [...new Set(deps)];
|
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>;
|