@mutineerjs/mutineer 0.2.3 → 0.2.4
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/core/__tests__/module.spec.js +66 -3
- package/dist/core/__tests__/sfc.spec.d.ts +1 -0
- package/dist/core/__tests__/sfc.spec.js +76 -0
- package/dist/core/__tests__/variant-utils.spec.d.ts +1 -0
- package/dist/core/__tests__/variant-utils.spec.js +93 -0
- package/dist/runner/__tests__/args.spec.d.ts +1 -0
- package/dist/runner/__tests__/args.spec.js +225 -0
- package/dist/runner/__tests__/cache.spec.d.ts +1 -0
- package/dist/runner/__tests__/cache.spec.js +180 -0
- package/dist/runner/__tests__/changed.spec.d.ts +1 -0
- package/dist/runner/__tests__/changed.spec.js +227 -0
- package/dist/runner/__tests__/cleanup.spec.d.ts +1 -0
- package/dist/runner/__tests__/cleanup.spec.js +41 -0
- package/dist/runner/__tests__/config.spec.d.ts +1 -0
- package/dist/runner/__tests__/config.spec.js +71 -0
- package/dist/runner/__tests__/coverage-resolver.spec.d.ts +1 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +171 -0
- package/dist/runner/__tests__/pool-executor.spec.d.ts +1 -0
- package/dist/runner/__tests__/pool-executor.spec.js +213 -0
- package/dist/runner/__tests__/tasks.spec.d.ts +1 -0
- package/dist/runner/__tests__/tasks.spec.js +95 -0
- package/dist/runner/__tests__/variants.spec.d.ts +1 -0
- package/dist/runner/__tests__/variants.spec.js +259 -0
- package/dist/runner/args.d.ts +5 -0
- package/dist/runner/args.js +7 -0
- package/dist/runner/config.js +2 -2
- package/dist/runner/coverage-resolver.d.ts +21 -0
- package/dist/runner/coverage-resolver.js +96 -0
- package/dist/runner/jest/__tests__/pool.spec.d.ts +1 -0
- package/dist/runner/jest/__tests__/pool.spec.js +212 -0
- package/dist/runner/jest/__tests__/worker-runtime.spec.d.ts +1 -0
- package/dist/runner/jest/__tests__/worker-runtime.spec.js +148 -0
- package/dist/runner/orchestrator.js +43 -295
- package/dist/runner/pool-executor.d.ts +17 -0
- package/dist/runner/pool-executor.js +143 -0
- package/dist/runner/shared/__tests__/mutant-paths.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/mutant-paths.spec.js +66 -0
- package/dist/runner/shared/__tests__/redirect-state.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/redirect-state.spec.js +56 -0
- package/dist/runner/tasks.d.ts +12 -0
- package/dist/runner/tasks.js +25 -0
- package/dist/runner/variants.d.ts +17 -2
- package/dist/runner/variants.js +33 -0
- package/dist/runner/vitest/__tests__/redirect-loader.spec.js +4 -0
- package/dist/utils/__tests__/logger.spec.d.ts +1 -0
- package/dist/utils/__tests__/logger.spec.js +61 -0
- package/dist/utils/__tests__/normalizePath.spec.d.ts +1 -0
- package/dist/utils/__tests__/normalizePath.spec.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { executePool } from '../pool-executor.js';
|
|
6
|
+
function makeAdapter(overrides = {}) {
|
|
7
|
+
return {
|
|
8
|
+
name: 'vitest',
|
|
9
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
runBaseline: vi.fn().mockResolvedValue(true),
|
|
11
|
+
runMutant: vi
|
|
12
|
+
.fn()
|
|
13
|
+
.mockResolvedValue({ status: 'killed', durationMs: 10 }),
|
|
14
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
hasCoverageProvider: vi.fn().mockReturnValue(false),
|
|
16
|
+
detectCoverageConfig: vi
|
|
17
|
+
.fn()
|
|
18
|
+
.mockResolvedValue({ perTestEnabled: false, coverageEnabled: false }),
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function makeTask(overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
v: {
|
|
25
|
+
id: 'file.ts#0',
|
|
26
|
+
name: 'flipStrictEQ',
|
|
27
|
+
file: '/src/file.ts',
|
|
28
|
+
code: 'const x = a !== b',
|
|
29
|
+
line: 1,
|
|
30
|
+
col: 10,
|
|
31
|
+
tests: ['/tests/file.test.ts'],
|
|
32
|
+
},
|
|
33
|
+
tests: ['/tests/file.test.ts'],
|
|
34
|
+
key: 'test-key-1',
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
describe('executePool', () => {
|
|
39
|
+
let tmpDir;
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-'));
|
|
42
|
+
process.exitCode = undefined;
|
|
43
|
+
});
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
process.exitCode = undefined;
|
|
46
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
it('initializes adapter with correct worker count', async () => {
|
|
49
|
+
const adapter = makeAdapter();
|
|
50
|
+
const cache = {};
|
|
51
|
+
const tasks = [makeTask()];
|
|
52
|
+
await executePool({
|
|
53
|
+
tasks,
|
|
54
|
+
adapter,
|
|
55
|
+
cache,
|
|
56
|
+
concurrency: 4,
|
|
57
|
+
progressMode: 'list',
|
|
58
|
+
cwd: tmpDir,
|
|
59
|
+
});
|
|
60
|
+
// workerCount = min(concurrency, tasks.length) = min(4, 1) = 1
|
|
61
|
+
expect(adapter.init).toHaveBeenCalledWith(1);
|
|
62
|
+
});
|
|
63
|
+
it('runs mutants through the adapter and populates cache', async () => {
|
|
64
|
+
const adapter = makeAdapter();
|
|
65
|
+
const cache = {};
|
|
66
|
+
const task = makeTask({ key: 'unique-key' });
|
|
67
|
+
await executePool({
|
|
68
|
+
tasks: [task],
|
|
69
|
+
adapter,
|
|
70
|
+
cache,
|
|
71
|
+
concurrency: 1,
|
|
72
|
+
progressMode: 'list',
|
|
73
|
+
cwd: tmpDir,
|
|
74
|
+
});
|
|
75
|
+
expect(adapter.runMutant).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(cache['unique-key']).toBeDefined();
|
|
77
|
+
expect(cache['unique-key'].status).toBe('killed');
|
|
78
|
+
});
|
|
79
|
+
it('skips cached tasks without calling runMutant', async () => {
|
|
80
|
+
const adapter = makeAdapter();
|
|
81
|
+
const cache = {
|
|
82
|
+
'cached-key': {
|
|
83
|
+
status: 'killed',
|
|
84
|
+
file: '/src/file.ts',
|
|
85
|
+
line: 1,
|
|
86
|
+
col: 10,
|
|
87
|
+
mutator: 'flipStrictEQ',
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
await executePool({
|
|
91
|
+
tasks: [makeTask({ key: 'cached-key' })],
|
|
92
|
+
adapter,
|
|
93
|
+
cache,
|
|
94
|
+
concurrency: 1,
|
|
95
|
+
progressMode: 'list',
|
|
96
|
+
cwd: tmpDir,
|
|
97
|
+
});
|
|
98
|
+
expect(adapter.runMutant).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
it('marks tasks with no tests as skipped', async () => {
|
|
101
|
+
const adapter = makeAdapter();
|
|
102
|
+
const cache = {};
|
|
103
|
+
const task = makeTask({ tests: [], key: 'no-tests-key' });
|
|
104
|
+
await executePool({
|
|
105
|
+
tasks: [task],
|
|
106
|
+
adapter,
|
|
107
|
+
cache,
|
|
108
|
+
concurrency: 1,
|
|
109
|
+
progressMode: 'list',
|
|
110
|
+
cwd: tmpDir,
|
|
111
|
+
});
|
|
112
|
+
expect(adapter.runMutant).not.toHaveBeenCalled();
|
|
113
|
+
expect(cache['no-tests-key'].status).toBe('skipped');
|
|
114
|
+
});
|
|
115
|
+
it('processes multiple tasks', async () => {
|
|
116
|
+
const adapter = makeAdapter();
|
|
117
|
+
const cache = {};
|
|
118
|
+
const tasks = [
|
|
119
|
+
makeTask({ key: 'key-1' }),
|
|
120
|
+
makeTask({ key: 'key-2' }),
|
|
121
|
+
makeTask({ key: 'key-3' }),
|
|
122
|
+
];
|
|
123
|
+
await executePool({
|
|
124
|
+
tasks,
|
|
125
|
+
adapter,
|
|
126
|
+
cache,
|
|
127
|
+
concurrency: 2,
|
|
128
|
+
progressMode: 'list',
|
|
129
|
+
cwd: tmpDir,
|
|
130
|
+
});
|
|
131
|
+
expect(adapter.runMutant).toHaveBeenCalledTimes(3);
|
|
132
|
+
expect(Object.keys(cache)).toHaveLength(3);
|
|
133
|
+
});
|
|
134
|
+
it('shuts down adapter after completion', async () => {
|
|
135
|
+
const adapter = makeAdapter();
|
|
136
|
+
const cache = {};
|
|
137
|
+
await executePool({
|
|
138
|
+
tasks: [makeTask()],
|
|
139
|
+
adapter,
|
|
140
|
+
cache,
|
|
141
|
+
concurrency: 1,
|
|
142
|
+
progressMode: 'list',
|
|
143
|
+
cwd: tmpDir,
|
|
144
|
+
});
|
|
145
|
+
expect(adapter.shutdown).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
it('sets exitCode when kill rate is below threshold', async () => {
|
|
148
|
+
const adapter = makeAdapter({
|
|
149
|
+
runMutant: vi
|
|
150
|
+
.fn()
|
|
151
|
+
.mockResolvedValue({ status: 'escaped', durationMs: 10 }),
|
|
152
|
+
});
|
|
153
|
+
const cache = {};
|
|
154
|
+
await executePool({
|
|
155
|
+
tasks: [makeTask()],
|
|
156
|
+
adapter,
|
|
157
|
+
cache,
|
|
158
|
+
concurrency: 1,
|
|
159
|
+
progressMode: 'list',
|
|
160
|
+
minKillPercent: 80,
|
|
161
|
+
cwd: tmpDir,
|
|
162
|
+
});
|
|
163
|
+
expect(process.exitCode).toBe(1);
|
|
164
|
+
});
|
|
165
|
+
it('does not set exitCode when kill rate meets threshold', async () => {
|
|
166
|
+
const adapter = makeAdapter({
|
|
167
|
+
runMutant: vi
|
|
168
|
+
.fn()
|
|
169
|
+
.mockResolvedValue({ status: 'killed', durationMs: 10 }),
|
|
170
|
+
});
|
|
171
|
+
const cache = {};
|
|
172
|
+
await executePool({
|
|
173
|
+
tasks: [makeTask()],
|
|
174
|
+
adapter,
|
|
175
|
+
cache,
|
|
176
|
+
concurrency: 1,
|
|
177
|
+
progressMode: 'list',
|
|
178
|
+
minKillPercent: 80,
|
|
179
|
+
cwd: tmpDir,
|
|
180
|
+
});
|
|
181
|
+
expect(process.exitCode).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
it('saves cache to disk after completion', async () => {
|
|
184
|
+
const adapter = makeAdapter();
|
|
185
|
+
const cache = {};
|
|
186
|
+
await executePool({
|
|
187
|
+
tasks: [makeTask({ key: 'persist-key' })],
|
|
188
|
+
adapter,
|
|
189
|
+
cache,
|
|
190
|
+
concurrency: 1,
|
|
191
|
+
progressMode: 'list',
|
|
192
|
+
cwd: tmpDir,
|
|
193
|
+
});
|
|
194
|
+
const cacheFile = path.join(tmpDir, '.mutate-cache.json');
|
|
195
|
+
const content = JSON.parse(await fs.readFile(cacheFile, 'utf8'));
|
|
196
|
+
expect(content['persist-key']).toBeDefined();
|
|
197
|
+
});
|
|
198
|
+
it('handles adapter errors gracefully and still shuts down', async () => {
|
|
199
|
+
const adapter = makeAdapter({
|
|
200
|
+
runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
|
|
201
|
+
});
|
|
202
|
+
const cache = {};
|
|
203
|
+
await expect(executePool({
|
|
204
|
+
tasks: [makeTask()],
|
|
205
|
+
adapter,
|
|
206
|
+
cache,
|
|
207
|
+
concurrency: 1,
|
|
208
|
+
progressMode: 'list',
|
|
209
|
+
cwd: tmpDir,
|
|
210
|
+
})).rejects.toThrow('adapter failure');
|
|
211
|
+
expect(adapter.shutdown).toHaveBeenCalledTimes(1);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { prepareTasks } from '../tasks.js';
|
|
3
|
+
import { hash, keyForTests } from '../cache.js';
|
|
4
|
+
function makeVariant(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'file.ts#0',
|
|
7
|
+
name: 'flipStrictEQ',
|
|
8
|
+
file: '/src/file.ts',
|
|
9
|
+
code: 'const x = a !== b',
|
|
10
|
+
line: 1,
|
|
11
|
+
col: 10,
|
|
12
|
+
tests: ['/tests/file.test.ts'],
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe('prepareTasks', () => {
|
|
17
|
+
it('creates tasks with sorted tests and computed cache keys', () => {
|
|
18
|
+
const v = makeVariant({
|
|
19
|
+
tests: ['/tests/b.test.ts', '/tests/a.test.ts'],
|
|
20
|
+
});
|
|
21
|
+
const tasks = prepareTasks([v], null);
|
|
22
|
+
expect(tasks).toHaveLength(1);
|
|
23
|
+
expect(tasks[0].tests).toEqual(['/tests/a.test.ts', '/tests/b.test.ts']);
|
|
24
|
+
expect(tasks[0].v).toBe(v);
|
|
25
|
+
// Key should be testSig:codeSig
|
|
26
|
+
const expectedTestSig = hash(keyForTests(['/tests/a.test.ts', '/tests/b.test.ts']));
|
|
27
|
+
const expectedCodeSig = hash(v.code);
|
|
28
|
+
expect(tasks[0].key).toBe(`${expectedTestSig}:${expectedCodeSig}`);
|
|
29
|
+
});
|
|
30
|
+
it('produces deterministic keys regardless of test order', () => {
|
|
31
|
+
const v1 = makeVariant({
|
|
32
|
+
tests: ['/tests/b.test.ts', '/tests/a.test.ts'],
|
|
33
|
+
});
|
|
34
|
+
const v2 = makeVariant({
|
|
35
|
+
tests: ['/tests/a.test.ts', '/tests/b.test.ts'],
|
|
36
|
+
});
|
|
37
|
+
const tasks1 = prepareTasks([v1], null);
|
|
38
|
+
const tasks2 = prepareTasks([v2], null);
|
|
39
|
+
expect(tasks1[0].key).toBe(tasks2[0].key);
|
|
40
|
+
});
|
|
41
|
+
it('produces different keys for different code', () => {
|
|
42
|
+
const v1 = makeVariant({ code: 'const x = a !== b' });
|
|
43
|
+
const v2 = makeVariant({ code: 'const x = a === b' });
|
|
44
|
+
const tasks = prepareTasks([v1, v2], null);
|
|
45
|
+
expect(tasks[0].key).not.toBe(tasks[1].key);
|
|
46
|
+
});
|
|
47
|
+
it('produces different keys for different test sets', () => {
|
|
48
|
+
const v1 = makeVariant({ tests: ['/tests/a.test.ts'] });
|
|
49
|
+
const v2 = makeVariant({ tests: ['/tests/b.test.ts'] });
|
|
50
|
+
const tasks = prepareTasks([v1, v2], null);
|
|
51
|
+
expect(tasks[0].key).not.toBe(tasks[1].key);
|
|
52
|
+
});
|
|
53
|
+
it('handles variants with no tests', () => {
|
|
54
|
+
const v = makeVariant({ tests: [] });
|
|
55
|
+
const tasks = prepareTasks([v], null);
|
|
56
|
+
expect(tasks).toHaveLength(1);
|
|
57
|
+
expect(tasks[0].tests).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
it('prunes tests via per-test coverage', () => {
|
|
60
|
+
const v = makeVariant({
|
|
61
|
+
file: '/src/file.ts',
|
|
62
|
+
line: 5,
|
|
63
|
+
tests: ['/tests/a.test.ts', '/tests/b.test.ts'],
|
|
64
|
+
});
|
|
65
|
+
// Only test-a covers line 5 of /src/file.ts
|
|
66
|
+
const perTestCoverage = new Map();
|
|
67
|
+
const aCoverage = new Map();
|
|
68
|
+
aCoverage.set('/src/file.ts', new Set([5, 6, 7]));
|
|
69
|
+
perTestCoverage.set('/tests/a.test.ts', aCoverage);
|
|
70
|
+
const bCoverage = new Map();
|
|
71
|
+
bCoverage.set('/src/file.ts', new Set([10, 11]));
|
|
72
|
+
perTestCoverage.set('/tests/b.test.ts', bCoverage);
|
|
73
|
+
const tasks = prepareTasks([v], perTestCoverage);
|
|
74
|
+
expect(tasks[0].tests).toEqual(['/tests/a.test.ts']);
|
|
75
|
+
});
|
|
76
|
+
it('does not prune when perTestCoverage is null', () => {
|
|
77
|
+
const v = makeVariant({
|
|
78
|
+
tests: ['/tests/a.test.ts', '/tests/b.test.ts'],
|
|
79
|
+
});
|
|
80
|
+
const tasks = prepareTasks([v], null);
|
|
81
|
+
expect(tasks[0].tests).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
it('handles multiple variants', () => {
|
|
84
|
+
const variants = [
|
|
85
|
+
makeVariant({ id: 'file.ts#0', code: 'code1' }),
|
|
86
|
+
makeVariant({ id: 'file.ts#1', code: 'code2' }),
|
|
87
|
+
makeVariant({ id: 'file.ts#2', code: 'code3' }),
|
|
88
|
+
];
|
|
89
|
+
const tasks = prepareTasks(variants, null);
|
|
90
|
+
expect(tasks).toHaveLength(3);
|
|
91
|
+
expect(tasks[0].v.id).toBe('file.ts#0');
|
|
92
|
+
expect(tasks[1].v.id).toBe('file.ts#1');
|
|
93
|
+
expect(tasks[2].v.id).toBe('file.ts#2');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { getTargetFile, enumerateVariantsForTarget, filterTestsByCoverage, enumerateAllVariants, } from '../variants.js';
|
|
6
|
+
import { normalizePath } from '../../utils/normalizePath.js';
|
|
7
|
+
describe('getTargetFile', () => {
|
|
8
|
+
it('returns the string directly for string targets', () => {
|
|
9
|
+
expect(getTargetFile('src/foo.ts')).toBe('src/foo.ts');
|
|
10
|
+
});
|
|
11
|
+
it('returns the file property for object targets', () => {
|
|
12
|
+
expect(getTargetFile({ file: 'src/bar.ts', kind: 'module' })).toBe('src/bar.ts');
|
|
13
|
+
});
|
|
14
|
+
it('returns the file property without kind', () => {
|
|
15
|
+
expect(getTargetFile({ file: 'src/baz.ts' })).toBe('src/baz.ts');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe('enumerateVariantsForTarget', () => {
|
|
19
|
+
let tmpDir;
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-variants-'));
|
|
22
|
+
});
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
it('enumerates variants for a module file with mutable code', async () => {
|
|
27
|
+
const srcFile = path.join(tmpDir, 'foo.ts');
|
|
28
|
+
await fs.writeFile(srcFile, 'const x = a === b');
|
|
29
|
+
const result = await enumerateVariantsForTarget(tmpDir, 'foo.ts');
|
|
30
|
+
expect(result.length).toBeGreaterThan(0);
|
|
31
|
+
expect(result[0].id).toBe('foo.ts#0');
|
|
32
|
+
expect(result[0].file).toBe(srcFile);
|
|
33
|
+
expect(typeof result[0].name).toBe('string');
|
|
34
|
+
});
|
|
35
|
+
it('auto-detects Vue files as vue:script-setup kind', async () => {
|
|
36
|
+
const vueFile = path.join(tmpDir, 'App.vue');
|
|
37
|
+
// Write a minimal Vue SFC - the sfc mutator needs @vue/compiler-sfc
|
|
38
|
+
// which may not be available, so this may return empty (caught error)
|
|
39
|
+
await fs.writeFile(vueFile, '<script setup>\nconst x = a === b\n</script>');
|
|
40
|
+
const result = await enumerateVariantsForTarget(tmpDir, 'App.vue');
|
|
41
|
+
// Either we get results (if @vue/compiler-sfc is available) or empty array (graceful)
|
|
42
|
+
expect(Array.isArray(result)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it('uses explicit kind=module for a .vue file', async () => {
|
|
45
|
+
const vueFile = path.join(tmpDir, 'App.vue');
|
|
46
|
+
await fs.writeFile(vueFile, 'const x = a && b');
|
|
47
|
+
const result = await enumerateVariantsForTarget(tmpDir, {
|
|
48
|
+
file: 'App.vue',
|
|
49
|
+
kind: 'module',
|
|
50
|
+
});
|
|
51
|
+
// Should treat the whole file as a module and find the && mutation
|
|
52
|
+
expect(result.some((v) => v.name === 'andToOr')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it('handles absolute paths', async () => {
|
|
55
|
+
const absFile = path.join(tmpDir, 'abs.ts');
|
|
56
|
+
await fs.writeFile(absFile, 'const x = a + b');
|
|
57
|
+
const result = await enumerateVariantsForTarget(tmpDir, absFile);
|
|
58
|
+
expect(result.length).toBeGreaterThan(0);
|
|
59
|
+
expect(result[0].file).toBe(absFile);
|
|
60
|
+
});
|
|
61
|
+
it('passes include/exclude/max correctly', async () => {
|
|
62
|
+
const srcFile = path.join(tmpDir, 'multi.ts');
|
|
63
|
+
await fs.writeFile(srcFile, 'const x = a === b && c || d');
|
|
64
|
+
const all = await enumerateVariantsForTarget(tmpDir, 'multi.ts');
|
|
65
|
+
expect(all.length).toBeGreaterThan(1);
|
|
66
|
+
const limited = await enumerateVariantsForTarget(tmpDir, 'multi.ts', undefined, undefined, 1);
|
|
67
|
+
expect(limited).toHaveLength(1);
|
|
68
|
+
});
|
|
69
|
+
it('filters by include', async () => {
|
|
70
|
+
const srcFile = path.join(tmpDir, 'inc.ts');
|
|
71
|
+
await fs.writeFile(srcFile, 'const x = a === b && c || d');
|
|
72
|
+
const result = await enumerateVariantsForTarget(tmpDir, 'inc.ts', ['andToOr']);
|
|
73
|
+
expect(result.every((v) => v.name === 'andToOr')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it('filters by exclude', async () => {
|
|
76
|
+
const srcFile = path.join(tmpDir, 'exc.ts');
|
|
77
|
+
await fs.writeFile(srcFile, 'const x = a === b && c');
|
|
78
|
+
const result = await enumerateVariantsForTarget(tmpDir, 'exc.ts', undefined, ['flipStrictEQ']);
|
|
79
|
+
expect(result.every((v) => v.name !== 'flipStrictEQ')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('returns empty array when file does not exist', async () => {
|
|
82
|
+
const result = await enumerateVariantsForTarget(tmpDir, 'nonexistent.ts');
|
|
83
|
+
expect(result).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
it('returns empty for code with no mutable patterns', async () => {
|
|
86
|
+
const srcFile = path.join(tmpDir, 'clean.ts');
|
|
87
|
+
await fs.writeFile(srcFile, 'const x = 1');
|
|
88
|
+
const result = await enumerateVariantsForTarget(tmpDir, 'clean.ts');
|
|
89
|
+
expect(result).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
it('generates correct id format', async () => {
|
|
92
|
+
const srcFile = path.join(tmpDir, 'ids.ts');
|
|
93
|
+
await fs.writeFile(srcFile, 'const x = a + b');
|
|
94
|
+
const result = await enumerateVariantsForTarget(tmpDir, 'ids.ts');
|
|
95
|
+
for (let i = 0; i < result.length; i++) {
|
|
96
|
+
expect(result[i].id).toBe(`ids.ts#${i}`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('filterTestsByCoverage', () => {
|
|
101
|
+
it('keeps tests that cover the specified line', () => {
|
|
102
|
+
const perTest = new Map();
|
|
103
|
+
const fileCoverage = new Map();
|
|
104
|
+
fileCoverage.set('src/foo.ts', new Set([1, 2, 3]));
|
|
105
|
+
perTest.set('test-a.ts', fileCoverage);
|
|
106
|
+
const result = filterTestsByCoverage(perTest, ['test-a.ts'], 'src/foo.ts', 2);
|
|
107
|
+
expect(result).toEqual(['test-a.ts']);
|
|
108
|
+
});
|
|
109
|
+
it('excludes tests that do not cover the specified line', () => {
|
|
110
|
+
const perTest = new Map();
|
|
111
|
+
const fileCoverage = new Map();
|
|
112
|
+
fileCoverage.set('src/foo.ts', new Set([1, 2, 3]));
|
|
113
|
+
perTest.set('test-a.ts', fileCoverage);
|
|
114
|
+
const result = filterTestsByCoverage(perTest, ['test-a.ts'], 'src/foo.ts', 99);
|
|
115
|
+
expect(result).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
it('includes tests that have no coverage data (conservative)', () => {
|
|
118
|
+
const perTest = new Map();
|
|
119
|
+
const result = filterTestsByCoverage(perTest, ['test-a.ts'], 'src/foo.ts', 1);
|
|
120
|
+
expect(result).toEqual(['test-a.ts']);
|
|
121
|
+
});
|
|
122
|
+
it('includes tests whose coverage does not track the file (conservative)', () => {
|
|
123
|
+
const perTest = new Map();
|
|
124
|
+
perTest.set('test-a.ts', new Map());
|
|
125
|
+
const result = filterTestsByCoverage(perTest, ['test-a.ts'], 'src/foo.ts', 1);
|
|
126
|
+
expect(result).toEqual(['test-a.ts']);
|
|
127
|
+
});
|
|
128
|
+
it('filters multiple tests correctly', () => {
|
|
129
|
+
const perTest = new Map();
|
|
130
|
+
const coverageA = new Map();
|
|
131
|
+
coverageA.set('src/foo.ts', new Set([1, 2]));
|
|
132
|
+
perTest.set('test-a.ts', coverageA);
|
|
133
|
+
const coverageB = new Map();
|
|
134
|
+
coverageB.set('src/foo.ts', new Set([3, 4]));
|
|
135
|
+
perTest.set('test-b.ts', coverageB);
|
|
136
|
+
const result = filterTestsByCoverage(perTest, ['test-a.ts', 'test-b.ts'], 'src/foo.ts', 2);
|
|
137
|
+
expect(result).toEqual(['test-a.ts']);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('enumerateAllVariants', () => {
|
|
141
|
+
let tmpDir;
|
|
142
|
+
beforeEach(async () => {
|
|
143
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-enumall-'));
|
|
144
|
+
});
|
|
145
|
+
afterEach(async () => {
|
|
146
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
147
|
+
});
|
|
148
|
+
it('enumerates variants for multiple targets and attaches tests', async () => {
|
|
149
|
+
const fileA = path.join(tmpDir, 'a.ts');
|
|
150
|
+
const fileB = path.join(tmpDir, 'b.ts');
|
|
151
|
+
await fs.writeFile(fileA, 'const x = a === b');
|
|
152
|
+
await fs.writeFile(fileB, 'const y = c + d');
|
|
153
|
+
const testMap = new Map();
|
|
154
|
+
testMap.set(normalizePath(fileA), new Set(['/tests/a.test.ts']));
|
|
155
|
+
testMap.set(normalizePath(fileB), new Set(['/tests/b.test.ts']));
|
|
156
|
+
const result = await enumerateAllVariants({
|
|
157
|
+
cwd: tmpDir,
|
|
158
|
+
targets: ['a.ts', 'b.ts'],
|
|
159
|
+
testMap,
|
|
160
|
+
changedFiles: null,
|
|
161
|
+
coverageData: null,
|
|
162
|
+
config: {},
|
|
163
|
+
});
|
|
164
|
+
expect(result.length).toBeGreaterThan(0);
|
|
165
|
+
const filesInResult = new Set(result.map((v) => path.basename(v.file)));
|
|
166
|
+
expect(filesInResult.has('a.ts')).toBe(true);
|
|
167
|
+
expect(filesInResult.has('b.ts')).toBe(true);
|
|
168
|
+
// Each variant should have its tests attached
|
|
169
|
+
for (const v of result) {
|
|
170
|
+
expect(Array.isArray(v.tests)).toBe(true);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
it('skips targets not in changedFiles set', async () => {
|
|
174
|
+
const fileA = path.join(tmpDir, 'a.ts');
|
|
175
|
+
const fileB = path.join(tmpDir, 'b.ts');
|
|
176
|
+
await fs.writeFile(fileA, 'const x = a === b');
|
|
177
|
+
await fs.writeFile(fileB, 'const y = c + d');
|
|
178
|
+
const testMap = new Map();
|
|
179
|
+
testMap.set(normalizePath(fileA), new Set(['/tests/a.test.ts']));
|
|
180
|
+
testMap.set(normalizePath(fileB), new Set(['/tests/b.test.ts']));
|
|
181
|
+
// Only fileA is in changedFiles
|
|
182
|
+
const changedFiles = new Set([normalizePath(fileA)]);
|
|
183
|
+
const result = await enumerateAllVariants({
|
|
184
|
+
cwd: tmpDir,
|
|
185
|
+
targets: ['a.ts', 'b.ts'],
|
|
186
|
+
testMap,
|
|
187
|
+
changedFiles,
|
|
188
|
+
coverageData: null,
|
|
189
|
+
config: {},
|
|
190
|
+
});
|
|
191
|
+
// All variants should be from a.ts only
|
|
192
|
+
for (const v of result) {
|
|
193
|
+
expect(path.basename(v.file)).toBe('a.ts');
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
it('filters variants by coverage data', async () => {
|
|
197
|
+
const fileA = path.join(tmpDir, 'cov.ts');
|
|
198
|
+
// Write code that produces mutations on different lines
|
|
199
|
+
await fs.writeFile(fileA, 'const x = a === b\nconst y = c === d');
|
|
200
|
+
const testMap = new Map();
|
|
201
|
+
testMap.set(normalizePath(fileA), new Set(['/tests/cov.test.ts']));
|
|
202
|
+
// Only line 1 is covered
|
|
203
|
+
const coverageData = {
|
|
204
|
+
coveredLines: new Map([[normalizePath(fileA), new Set([1])]]),
|
|
205
|
+
};
|
|
206
|
+
const all = await enumerateAllVariants({
|
|
207
|
+
cwd: tmpDir,
|
|
208
|
+
targets: ['cov.ts'],
|
|
209
|
+
testMap,
|
|
210
|
+
changedFiles: null,
|
|
211
|
+
coverageData: null,
|
|
212
|
+
config: {},
|
|
213
|
+
});
|
|
214
|
+
const filtered = await enumerateAllVariants({
|
|
215
|
+
cwd: tmpDir,
|
|
216
|
+
targets: ['cov.ts'],
|
|
217
|
+
testMap,
|
|
218
|
+
changedFiles: null,
|
|
219
|
+
coverageData,
|
|
220
|
+
config: {},
|
|
221
|
+
});
|
|
222
|
+
expect(filtered.length).toBeLessThanOrEqual(all.length);
|
|
223
|
+
// All filtered variants should be on line 1
|
|
224
|
+
for (const v of filtered) {
|
|
225
|
+
expect(v.line).toBe(1);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
it('returns empty array when no targets produce variants', async () => {
|
|
229
|
+
const fileA = path.join(tmpDir, 'clean.ts');
|
|
230
|
+
await fs.writeFile(fileA, 'const x = 1');
|
|
231
|
+
const testMap = new Map();
|
|
232
|
+
const result = await enumerateAllVariants({
|
|
233
|
+
cwd: tmpDir,
|
|
234
|
+
targets: ['clean.ts'],
|
|
235
|
+
testMap,
|
|
236
|
+
changedFiles: null,
|
|
237
|
+
coverageData: null,
|
|
238
|
+
config: {},
|
|
239
|
+
});
|
|
240
|
+
expect(result).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
it('attaches empty tests array when testMap has no entry for target', async () => {
|
|
243
|
+
const fileA = path.join(tmpDir, 'orphan.ts');
|
|
244
|
+
await fs.writeFile(fileA, 'const x = a === b');
|
|
245
|
+
const testMap = new Map(); // no entries
|
|
246
|
+
const result = await enumerateAllVariants({
|
|
247
|
+
cwd: tmpDir,
|
|
248
|
+
targets: ['orphan.ts'],
|
|
249
|
+
testMap,
|
|
250
|
+
changedFiles: null,
|
|
251
|
+
coverageData: null,
|
|
252
|
+
config: {},
|
|
253
|
+
});
|
|
254
|
+
expect(result.length).toBeGreaterThan(0);
|
|
255
|
+
for (const v of result) {
|
|
256
|
+
expect(v.tests).toEqual([]);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
package/dist/runner/args.d.ts
CHANGED
|
@@ -44,6 +44,11 @@ export declare function parseConcurrency(args: readonly string[]): number;
|
|
|
44
44
|
* Parse progress mode from CLI args.
|
|
45
45
|
*/
|
|
46
46
|
export declare function parseProgressMode(args: readonly string[]): 'bar' | 'list' | 'quiet';
|
|
47
|
+
/**
|
|
48
|
+
* Extract the config file path from CLI args (before full option parsing).
|
|
49
|
+
* Handles --config=path, -c=path, --config path, and -c path.
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractConfigPath(args: readonly string[]): string | undefined;
|
|
47
52
|
/**
|
|
48
53
|
* Parse all CLI options.
|
|
49
54
|
*/
|
package/dist/runner/args.js
CHANGED
|
@@ -87,6 +87,13 @@ export function parseProgressMode(args) {
|
|
|
87
87
|
const modeArg = progIdx >= 0 ? args[progIdx + 1] || 'bar' : 'bar';
|
|
88
88
|
return modeArg === 'list' ? 'list' : modeArg === 'quiet' ? 'quiet' : 'bar';
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Extract the config file path from CLI args (before full option parsing).
|
|
92
|
+
* Handles --config=path, -c=path, --config path, and -c path.
|
|
93
|
+
*/
|
|
94
|
+
export function extractConfigPath(args) {
|
|
95
|
+
return readStringFlag(args, '--config', '-c');
|
|
96
|
+
}
|
|
90
97
|
/**
|
|
91
98
|
* Parse all CLI options.
|
|
92
99
|
*/
|
package/dist/runner/config.js
CHANGED
|
@@ -19,7 +19,7 @@ const log = createLogger('config');
|
|
|
19
19
|
async function loadModule(filePath) {
|
|
20
20
|
const moduleUrl = pathToFileURL(filePath).href;
|
|
21
21
|
const mod = await import(moduleUrl);
|
|
22
|
-
return mod.default
|
|
22
|
+
return mod.default ?? mod;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Validate that the loaded configuration has the expected shape.
|
|
@@ -27,7 +27,7 @@ async function loadModule(filePath) {
|
|
|
27
27
|
* the structure is an object and not some other unexpected type.
|
|
28
28
|
*/
|
|
29
29
|
function validateConfig(config) {
|
|
30
|
-
if (typeof config !== 'object'
|
|
30
|
+
if (typeof config !== 'object' || config === null) {
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
33
|
// Config is valid even if empty; defaults are applied elsewhere.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ParsedCliOptions } from './args.js';
|
|
2
|
+
import type { MutineerConfig } from '../types/config.js';
|
|
3
|
+
import type { TestRunnerAdapter } from './types.js';
|
|
4
|
+
import { type CoverageData, type PerTestCoverageMap } from '../utils/coverage.js';
|
|
5
|
+
export interface CoverageResolution {
|
|
6
|
+
coverageData: CoverageData | null;
|
|
7
|
+
perTestCoverage: PerTestCoverageMap | null;
|
|
8
|
+
enableCoverageForBaseline: boolean;
|
|
9
|
+
wantsPerTestCoverage: boolean;
|
|
10
|
+
needsCoverageFromBaseline: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve all coverage-related configuration from CLI options, config, and adapter detection.
|
|
14
|
+
* Returns a unified resolution object used by the orchestrator.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveCoverageConfig(opts: ParsedCliOptions, cfg: MutineerConfig, adapter: TestRunnerAdapter, cliArgs: readonly string[]): Promise<CoverageResolution>;
|
|
17
|
+
/**
|
|
18
|
+
* Load coverage data produced during the baseline run.
|
|
19
|
+
* Mutates and returns an updated CoverageResolution.
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadCoverageAfterBaseline(resolution: CoverageResolution, cwd: string): Promise<CoverageResolution>;
|