@mutineerjs/mutineer 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -15
- package/dist/bin/__tests__/mutineer.spec.js +67 -2
- package/dist/bin/mutineer.d.ts +6 -1
- package/dist/bin/mutineer.js +58 -2
- 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/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 +89 -1
- package/dist/runner/__tests__/cache.spec.js +65 -8
- package/dist/runner/__tests__/cleanup.spec.js +37 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +5 -0
- package/dist/runner/__tests__/discover.spec.js +128 -0
- package/dist/runner/__tests__/orchestrator.spec.js +332 -2
- package/dist/runner/__tests__/pool-executor.spec.js +107 -1
- 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 +18 -0
- package/dist/runner/args.js +37 -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 +19 -2
- 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 +114 -19
- package/dist/runner/pool-executor.d.ts +7 -0
- package/dist/runner/pool-executor.js +29 -7
- 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 +41 -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 +14 -9
- 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 +16 -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 +126 -2
- package/dist/utils/summary.d.ts +23 -1
- package/dist/utils/summary.js +63 -3
- package/package.json +2 -1
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
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(),
|
|
@@ -34,11 +44,43 @@ vi.mock('../discover.js', () => ({
|
|
|
34
44
|
vi.mock('../changed.js', () => ({
|
|
35
45
|
listChangedFiles: vi.fn().mockReturnValue([]),
|
|
36
46
|
}));
|
|
37
|
-
|
|
47
|
+
vi.mock('../pool-executor.js', () => ({
|
|
48
|
+
executePool: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
}));
|
|
50
|
+
vi.mock('../variants.js', () => ({
|
|
51
|
+
enumerateAllVariants: vi.fn().mockResolvedValue([]),
|
|
52
|
+
getTargetFile: vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockImplementation((t) => typeof t === 'string' ? t : t.file),
|
|
55
|
+
}));
|
|
56
|
+
vi.mock('../tasks.js', () => ({
|
|
57
|
+
prepareTasks: vi.fn().mockReturnValue([]),
|
|
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
|
+
}));
|
|
72
|
+
import { runOrchestrator, parseMutantTimeoutMs } from '../orchestrator.js';
|
|
38
73
|
import { loadMutineerConfig } from '../config.js';
|
|
39
74
|
import { createVitestAdapter } from '../vitest/index.js';
|
|
40
75
|
import { autoDiscoverTargetsAndTests } from '../discover.js';
|
|
41
76
|
import { listChangedFiles } from '../changed.js';
|
|
77
|
+
import { executePool } from '../pool-executor.js';
|
|
78
|
+
import { prepareTasks } from '../tasks.js';
|
|
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';
|
|
42
84
|
const mockAdapter = {
|
|
43
85
|
name: 'vitest',
|
|
44
86
|
init: vi.fn().mockResolvedValue(undefined),
|
|
@@ -54,6 +96,95 @@ beforeEach(() => {
|
|
|
54
96
|
vi.clearAllMocks();
|
|
55
97
|
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
56
98
|
});
|
|
99
|
+
describe('parseMutantTimeoutMs', () => {
|
|
100
|
+
it('returns the parsed value for a valid positive number', () => {
|
|
101
|
+
expect(parseMutantTimeoutMs('5000')).toBe(5000);
|
|
102
|
+
});
|
|
103
|
+
it('returns 30_000 for undefined', () => {
|
|
104
|
+
expect(parseMutantTimeoutMs(undefined)).toBe(30_000);
|
|
105
|
+
});
|
|
106
|
+
it('returns 30_000 for zero (kills tightenGT: n>=0 would return 0)', () => {
|
|
107
|
+
expect(parseMutantTimeoutMs('0')).toBe(30_000);
|
|
108
|
+
});
|
|
109
|
+
it('returns 30_000 for Infinity (kills andToOr: || would return Infinity)', () => {
|
|
110
|
+
expect(parseMutantTimeoutMs('Infinity')).toBe(30_000);
|
|
111
|
+
});
|
|
112
|
+
it('returns 30_000 for negative values', () => {
|
|
113
|
+
expect(parseMutantTimeoutMs('-1')).toBe(30_000);
|
|
114
|
+
});
|
|
115
|
+
it('returns 30_000 for non-numeric strings', () => {
|
|
116
|
+
expect(parseMutantTimeoutMs('abc')).toBe(30_000);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('runOrchestrator no tests found', () => {
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
process.exitCode = undefined;
|
|
122
|
+
});
|
|
123
|
+
it('sets exitCode=1 when no tests are found for targets', async () => {
|
|
124
|
+
process.exitCode = undefined;
|
|
125
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
126
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
127
|
+
targets: [],
|
|
128
|
+
testMap: new Map(),
|
|
129
|
+
directTestMap: new Map(),
|
|
130
|
+
});
|
|
131
|
+
await runOrchestrator([], '/cwd');
|
|
132
|
+
expect(process.exitCode).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
it('logs error message when no tests are found for targets', async () => {
|
|
135
|
+
process.exitCode = undefined;
|
|
136
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
137
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
138
|
+
targets: [],
|
|
139
|
+
testMap: new Map(),
|
|
140
|
+
directTestMap: new Map(),
|
|
141
|
+
});
|
|
142
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
143
|
+
await runOrchestrator([], '/cwd');
|
|
144
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No tests found for the selected targets'));
|
|
145
|
+
});
|
|
146
|
+
it('does not run baseline when no tests are found', async () => {
|
|
147
|
+
process.exitCode = undefined;
|
|
148
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
149
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
150
|
+
targets: [],
|
|
151
|
+
testMap: new Map(),
|
|
152
|
+
directTestMap: new Map(),
|
|
153
|
+
});
|
|
154
|
+
await runOrchestrator([], '/cwd');
|
|
155
|
+
expect(mockAdapter.runBaseline).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('runOrchestrator discovery logging', () => {
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
process.exitCode = undefined;
|
|
161
|
+
});
|
|
162
|
+
it('logs "Discovering tests..." before calling autoDiscoverTargetsAndTests', async () => {
|
|
163
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
164
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
165
|
+
await runOrchestrator([], '/cwd');
|
|
166
|
+
const calls = consoleSpy.mock.calls.map((c) => c[0]);
|
|
167
|
+
const discoveringIdx = calls.findIndex((m) => m === 'Discovering tests...');
|
|
168
|
+
expect(discoveringIdx).toBeGreaterThanOrEqual(0);
|
|
169
|
+
});
|
|
170
|
+
it('passes an onProgress callback to autoDiscoverTargetsAndTests', async () => {
|
|
171
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
172
|
+
await runOrchestrator([], '/cwd');
|
|
173
|
+
const [, , onProgress] = vi.mocked(autoDiscoverTargetsAndTests).mock
|
|
174
|
+
.calls[0];
|
|
175
|
+
expect(typeof onProgress).toBe('function');
|
|
176
|
+
});
|
|
177
|
+
it('logs progress messages emitted by autoDiscoverTargetsAndTests', async () => {
|
|
178
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
179
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockImplementationOnce(async (_root, _cfg, onProgress) => {
|
|
180
|
+
onProgress?.('Discovery complete: 3 source file(s), 2 test file(s)');
|
|
181
|
+
return { targets: [], testMap: new Map(), directTestMap: new Map() };
|
|
182
|
+
});
|
|
183
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
184
|
+
await runOrchestrator([], '/cwd');
|
|
185
|
+
expect(consoleSpy).toHaveBeenCalledWith('Discovery complete: 3 source file(s), 2 test file(s)');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
57
188
|
describe('runOrchestrator --changed-with-deps diagnostic', () => {
|
|
58
189
|
it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
|
|
59
190
|
const cfg = {};
|
|
@@ -139,3 +270,202 @@ describe('runOrchestrator timeout precedence', () => {
|
|
|
139
270
|
expect(Number.isFinite(call.timeoutMs)).toBe(true);
|
|
140
271
|
});
|
|
141
272
|
});
|
|
273
|
+
describe('runOrchestrator shard filtering', () => {
|
|
274
|
+
const targetFile = '/cwd/src/foo.ts';
|
|
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
|
+
}
|
|
287
|
+
function makeTask(key) {
|
|
288
|
+
return {
|
|
289
|
+
key,
|
|
290
|
+
v: makeVariant(key),
|
|
291
|
+
tests: [testFile],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
beforeEach(() => {
|
|
295
|
+
vi.clearAllMocks();
|
|
296
|
+
process.exitCode = undefined;
|
|
297
|
+
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
298
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({
|
|
299
|
+
targets: [targetFile],
|
|
300
|
+
});
|
|
301
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
302
|
+
targets: [targetFile],
|
|
303
|
+
testMap: new Map([[targetFile, new Set([testFile])]]),
|
|
304
|
+
directTestMap: new Map(),
|
|
305
|
+
});
|
|
306
|
+
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
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)));
|
|
311
|
+
});
|
|
312
|
+
afterEach(() => {
|
|
313
|
+
process.exitCode = undefined;
|
|
314
|
+
});
|
|
315
|
+
it('shard 1/2 assigns even-indexed variants', async () => {
|
|
316
|
+
await runOrchestrator(['--shard', '1/2'], '/cwd');
|
|
317
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
318
|
+
expect(call.tasks.map((t) => t.key)).toEqual(['k0', 'k2']);
|
|
319
|
+
});
|
|
320
|
+
it('shard 2/2 assigns odd-indexed variants', async () => {
|
|
321
|
+
await runOrchestrator(['--shard', '2/2'], '/cwd');
|
|
322
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
323
|
+
expect(call.tasks.map((t) => t.key)).toEqual(['k1', 'k3']);
|
|
324
|
+
});
|
|
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')]);
|
|
328
|
+
await runOrchestrator(['--shard', '2/2'], '/cwd');
|
|
329
|
+
expect(executePool).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
it('propagates shard to executePool', async () => {
|
|
332
|
+
await runOrchestrator(['--shard', '1/4'], '/cwd');
|
|
333
|
+
const call = vi.mocked(executePool).mock.calls[0][0];
|
|
334
|
+
expect(call.shard).toEqual({ index: 1, total: 4 });
|
|
335
|
+
});
|
|
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
|
+
});
|
|
@@ -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');
|
|
@@ -370,6 +407,53 @@ describe('executePool', () => {
|
|
|
370
407
|
});
|
|
371
408
|
expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
|
|
372
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
|
+
});
|
|
373
457
|
it('correctly stores snippets for multiple escaped mutants from the same file', async () => {
|
|
374
458
|
const tmpFile = path.join(tmpDir, 'shared.ts');
|
|
375
459
|
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
@@ -410,6 +494,28 @@ describe('executePool', () => {
|
|
|
410
494
|
expect(cache['cache-key-3'].originalSnippet).toBe('const x = a + b');
|
|
411
495
|
expect(cache['cache-key-3'].mutatedSnippet).toBe('const x = a / b');
|
|
412
496
|
});
|
|
497
|
+
it('does not eagerly JSON.stringify cache on every task (only once for save)', async () => {
|
|
498
|
+
const adapter = makeAdapter();
|
|
499
|
+
const cache = {};
|
|
500
|
+
const stringifySpy = vi.spyOn(JSON, 'stringify');
|
|
501
|
+
// 3 tasks — if stringify(cache) ran per-task it would be called 3 times with cache
|
|
502
|
+
await executePool({
|
|
503
|
+
tasks: [
|
|
504
|
+
makeTask({ key: 'k1' }),
|
|
505
|
+
makeTask({ key: 'k2' }),
|
|
506
|
+
makeTask({ key: 'k3' }),
|
|
507
|
+
],
|
|
508
|
+
adapter,
|
|
509
|
+
cache,
|
|
510
|
+
concurrency: 1,
|
|
511
|
+
progressMode: 'list',
|
|
512
|
+
cwd: tmpDir,
|
|
513
|
+
});
|
|
514
|
+
// saveCacheAtomic calls stringify(cache) exactly once; per-task eager call was removed
|
|
515
|
+
const cacheStringifyCalls = stringifySpy.mock.calls.filter((args) => args[0] === cache);
|
|
516
|
+
expect(cacheStringifyCalls.length).toBe(1);
|
|
517
|
+
stringifySpy.mockRestore();
|
|
518
|
+
});
|
|
413
519
|
it('handles adapter errors gracefully and still shuts down', async () => {
|
|
414
520
|
const adapter = makeAdapter({
|
|
415
521
|
runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveTypescriptEnabled, resolveTsconfigPath, checkTypes, } from '../ts-checker.js';
|
|
4
|
+
// Directory with a real tsconfig.json (the project root, 3 levels up from __tests__)
|
|
5
|
+
const CWD_WITH_TSCONFIG = path.resolve(import.meta.dirname, '../../../');
|
|
6
|
+
// A temp-like directory unlikely to have tsconfig.json
|
|
7
|
+
const CWD_WITHOUT_TSCONFIG = '/tmp';
|
|
8
|
+
function makeVariant(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: 'foo.ts#0',
|
|
11
|
+
name: 'flipEQ',
|
|
12
|
+
file: '/nonexistent/foo.ts',
|
|
13
|
+
code: 'const x: number = 1',
|
|
14
|
+
line: 1,
|
|
15
|
+
col: 0,
|
|
16
|
+
tests: [],
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
describe('resolveTsconfigPath', () => {
|
|
21
|
+
it('returns undefined for boolean true', () => {
|
|
22
|
+
expect(resolveTsconfigPath({ typescript: true })).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it('returns undefined for boolean false', () => {
|
|
25
|
+
expect(resolveTsconfigPath({ typescript: false })).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
it('returns tsconfig path from object config', () => {
|
|
28
|
+
expect(resolveTsconfigPath({ typescript: { tsconfig: './tsconfig.app.json' } })).toBe('./tsconfig.app.json');
|
|
29
|
+
});
|
|
30
|
+
it('returns undefined when object config has no tsconfig property', () => {
|
|
31
|
+
expect(resolveTsconfigPath({ typescript: {} })).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
it('returns undefined when no typescript config key', () => {
|
|
34
|
+
expect(resolveTsconfigPath({})).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('resolveTypescriptEnabled', () => {
|
|
38
|
+
it('CLI false overrides everything', () => {
|
|
39
|
+
expect(resolveTypescriptEnabled(false, { typescript: true }, CWD_WITH_TSCONFIG)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
it('CLI true overrides everything', () => {
|
|
42
|
+
expect(resolveTypescriptEnabled(true, { typescript: false }, CWD_WITH_TSCONFIG)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it('config false disables checking', () => {
|
|
45
|
+
expect(resolveTypescriptEnabled(undefined, { typescript: false }, CWD_WITH_TSCONFIG)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it('config true enables checking', () => {
|
|
48
|
+
expect(resolveTypescriptEnabled(undefined, { typescript: true }, CWD_WITHOUT_TSCONFIG)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('config object enables checking', () => {
|
|
51
|
+
expect(resolveTypescriptEnabled(undefined, { typescript: { tsconfig: './tsconfig.json' } }, CWD_WITHOUT_TSCONFIG)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
it('auto-detects: enabled when tsconfig.json is present in cwd', () => {
|
|
54
|
+
// The project root has a tsconfig.json
|
|
55
|
+
expect(resolveTypescriptEnabled(undefined, {}, CWD_WITH_TSCONFIG)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('auto-detects: disabled when no tsconfig.json in cwd', () => {
|
|
58
|
+
// /tmp should not have a tsconfig.json
|
|
59
|
+
expect(resolveTypescriptEnabled(undefined, {}, CWD_WITHOUT_TSCONFIG)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('checkTypes', () => {
|
|
63
|
+
it('returns empty set for empty variants array', async () => {
|
|
64
|
+
const result = await checkTypes([], undefined, CWD_WITHOUT_TSCONFIG);
|
|
65
|
+
expect(result.size).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
it('does not flag valid TypeScript code', async () => {
|
|
68
|
+
const variant = makeVariant({
|
|
69
|
+
id: 'valid.ts#0',
|
|
70
|
+
file: '/nonexistent/valid.ts',
|
|
71
|
+
code: 'const x: number = 42; export default x',
|
|
72
|
+
});
|
|
73
|
+
const result = await checkTypes([variant], undefined, CWD_WITHOUT_TSCONFIG);
|
|
74
|
+
expect(result.has('valid.ts#0')).toBe(false);
|
|
75
|
+
}, 15000);
|
|
76
|
+
it('flags TypeScript type mismatch as compile error', async () => {
|
|
77
|
+
const variant = makeVariant({
|
|
78
|
+
id: 'bad.ts#0',
|
|
79
|
+
file: '/nonexistent/bad.ts',
|
|
80
|
+
code: 'const x: number = "this is not a number"',
|
|
81
|
+
});
|
|
82
|
+
const result = await checkTypes([variant], undefined, CWD_WITHOUT_TSCONFIG);
|
|
83
|
+
expect(result.has('bad.ts#0')).toBe(true);
|
|
84
|
+
}, 15000);
|
|
85
|
+
it('checks multiple variants for same file independently', async () => {
|
|
86
|
+
const valid = makeVariant({
|
|
87
|
+
id: 'multi.ts#0',
|
|
88
|
+
file: '/nonexistent/multi.ts',
|
|
89
|
+
code: 'const x: number = 1',
|
|
90
|
+
});
|
|
91
|
+
const invalid = makeVariant({
|
|
92
|
+
id: 'multi.ts#1',
|
|
93
|
+
file: '/nonexistent/multi.ts',
|
|
94
|
+
code: 'const x: number = "bad"',
|
|
95
|
+
});
|
|
96
|
+
const result = await checkTypes([valid, invalid], undefined, CWD_WITHOUT_TSCONFIG);
|
|
97
|
+
expect(result.has('multi.ts#0')).toBe(false);
|
|
98
|
+
expect(result.has('multi.ts#1')).toBe(true);
|
|
99
|
+
}, 15000);
|
|
100
|
+
it('checks variants from different files independently', async () => {
|
|
101
|
+
const validA = makeVariant({
|
|
102
|
+
id: 'a.ts#0',
|
|
103
|
+
file: '/nonexistent/a.ts',
|
|
104
|
+
code: 'const x: number = 1',
|
|
105
|
+
});
|
|
106
|
+
const invalidB = makeVariant({
|
|
107
|
+
id: 'b.ts#0',
|
|
108
|
+
file: '/nonexistent/b.ts',
|
|
109
|
+
code: 'const y: string = 999',
|
|
110
|
+
});
|
|
111
|
+
const result = await checkTypes([validA, invalidB], undefined, CWD_WITHOUT_TSCONFIG);
|
|
112
|
+
expect(result.has('a.ts#0')).toBe(false);
|
|
113
|
+
expect(result.has('b.ts#0')).toBe(true);
|
|
114
|
+
}, 15000);
|
|
115
|
+
});
|
package/dist/runner/args.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ 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;
|
|
28
|
+
/** undefined = use config/auto-detect, true = force enable, false = force disable */
|
|
29
|
+
readonly typescriptCheck: boolean | undefined;
|
|
30
|
+
/** Vitest workspace project name(s) to filter mutations to */
|
|
31
|
+
readonly vitestProject: string | undefined;
|
|
32
|
+
readonly skipBaseline: boolean;
|
|
23
33
|
}
|
|
24
34
|
/**
|
|
25
35
|
* Parse a numeric CLI flag value.
|
|
@@ -54,6 +64,14 @@ export declare function parseProgressMode(args: readonly string[]): 'bar' | 'lis
|
|
|
54
64
|
* Handles --config=path, -c=path, --config path, and -c path.
|
|
55
65
|
*/
|
|
56
66
|
export declare function extractConfigPath(args: readonly string[]): string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Parse the --shard <n>/<total> option.
|
|
69
|
+
* Throws on bad format or out-of-range values.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parseShardOption(args: readonly string[]): {
|
|
72
|
+
index: number;
|
|
73
|
+
total: number;
|
|
74
|
+
} | undefined;
|
|
57
75
|
/**
|
|
58
76
|
* Parse all CLI options.
|
|
59
77
|
*/
|