@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
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { mutateModuleSource } from '../module.js';
|
|
3
|
+
describe('mutateModuleSource', () => {
|
|
4
|
+
it('returns empty array for code with no mutable patterns', () => {
|
|
5
|
+
const result = mutateModuleSource('const x = 1');
|
|
6
|
+
expect(result).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
it('generates mutations for equality operators', () => {
|
|
9
|
+
const code = 'if (a === b) {}';
|
|
10
|
+
const result = mutateModuleSource(code);
|
|
11
|
+
expect(result.length).toBeGreaterThan(0);
|
|
12
|
+
expect(result.some((v) => v.name === 'flipStrictEQ')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('generates mutations for logical operators', () => {
|
|
15
|
+
const code = 'const x = a && b';
|
|
16
|
+
const result = mutateModuleSource(code);
|
|
17
|
+
expect(result.some((v) => v.name === 'andToOr')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it('generates mutations for arithmetic operators', () => {
|
|
20
|
+
const code = 'const x = a + b';
|
|
21
|
+
const result = mutateModuleSource(code);
|
|
22
|
+
expect(result.some((v) => v.name === 'addToSub')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it('deduplicates identical mutations', () => {
|
|
25
|
+
const code = 'if (a === b) {}';
|
|
26
|
+
const result = mutateModuleSource(code);
|
|
27
|
+
const codes = result.map((v) => v.code);
|
|
28
|
+
expect(new Set(codes).size).toBe(codes.length);
|
|
29
|
+
});
|
|
30
|
+
it('respects include filter', () => {
|
|
31
|
+
const code = 'if (a === b && c || d) {}';
|
|
32
|
+
const result = mutateModuleSource(code, ['andToOr']);
|
|
33
|
+
expect(result.every((v) => v.name === 'andToOr')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('respects exclude filter', () => {
|
|
36
|
+
const code = 'if (a === b && c || d) {}';
|
|
37
|
+
const result = mutateModuleSource(code, undefined, ['flipStrictEQ']);
|
|
38
|
+
expect(result.every((v) => v.name !== 'flipStrictEQ')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('respects max limit', () => {
|
|
41
|
+
const code = 'if (a === b && c || d) {}';
|
|
42
|
+
const all = mutateModuleSource(code);
|
|
43
|
+
expect(all.length).toBeGreaterThan(1);
|
|
44
|
+
const limited = mutateModuleSource(code, undefined, undefined, 1);
|
|
45
|
+
expect(limited).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
it('throws when max is 0', () => {
|
|
48
|
+
expect(() => mutateModuleSource('code', undefined, undefined, 0)).toThrow('max must be a positive number');
|
|
49
|
+
});
|
|
50
|
+
it('throws when max is negative', () => {
|
|
51
|
+
expect(() => mutateModuleSource('code', undefined, undefined, -1)).toThrow('max must be a positive number');
|
|
52
|
+
});
|
|
53
|
+
it('includes line and col in variants', () => {
|
|
54
|
+
const code = 'const x = a + b';
|
|
55
|
+
const result = mutateModuleSource(code);
|
|
56
|
+
for (const v of result) {
|
|
57
|
+
expect(typeof v.line).toBe('number');
|
|
58
|
+
expect(typeof v.col).toBe('number');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
it('produces valid mutated code strings', () => {
|
|
62
|
+
const code = 'const x = a <= b';
|
|
63
|
+
const result = mutateModuleSource(code);
|
|
64
|
+
for (const v of result) {
|
|
65
|
+
expect(typeof v.code).toBe('string');
|
|
66
|
+
expect(v.code).not.toBe(code);
|
|
67
|
+
}
|
|
5
68
|
});
|
|
6
69
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mutateVueSfcScriptSetup } from '../sfc.js';
|
|
3
|
+
// Mock @vue/compiler-sfc
|
|
4
|
+
vi.mock('@vue/compiler-sfc', () => ({
|
|
5
|
+
parse: (code, _opts) => {
|
|
6
|
+
// Simple mock that extracts content between <script setup> tags
|
|
7
|
+
const startTag = '<script setup>';
|
|
8
|
+
const endTag = '</script>';
|
|
9
|
+
const startIdx = code.indexOf(startTag);
|
|
10
|
+
if (startIdx === -1) {
|
|
11
|
+
return { descriptor: { scriptSetup: null } };
|
|
12
|
+
}
|
|
13
|
+
const contentStart = startIdx + startTag.length;
|
|
14
|
+
const contentEnd = code.indexOf(endTag, contentStart);
|
|
15
|
+
return {
|
|
16
|
+
descriptor: {
|
|
17
|
+
scriptSetup: {
|
|
18
|
+
loc: {
|
|
19
|
+
start: { offset: contentStart },
|
|
20
|
+
end: { offset: contentEnd },
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
describe('mutateVueSfcScriptSetup', () => {
|
|
28
|
+
it('returns empty array when there is no script setup block', async () => {
|
|
29
|
+
const code = '<template><div>hello</div></template>';
|
|
30
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code);
|
|
31
|
+
expect(result).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
it('throws when max is 0', async () => {
|
|
34
|
+
await expect(mutateVueSfcScriptSetup('test.vue', '<script setup></script>', [], [], 0)).rejects.toThrow('max must be a positive number, got: 0');
|
|
35
|
+
});
|
|
36
|
+
it('throws when max is negative', async () => {
|
|
37
|
+
await expect(mutateVueSfcScriptSetup('test.vue', '<script setup></script>', [], [], -1)).rejects.toThrow('max must be a positive number, got: -1');
|
|
38
|
+
});
|
|
39
|
+
it('generates mutations for script setup content', async () => {
|
|
40
|
+
const code = '<script setup>\nconst x = a && b\n</script>';
|
|
41
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code);
|
|
42
|
+
// Should find at least the andToOr mutation
|
|
43
|
+
expect(result.length).toBeGreaterThan(0);
|
|
44
|
+
// Every result should have the full SFC code (containing template tags)
|
|
45
|
+
for (const v of result) {
|
|
46
|
+
expect(v.code).toContain('<script setup>');
|
|
47
|
+
expect(v.code).toContain('</script>');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it('deduplicates identical mutations', async () => {
|
|
51
|
+
const code = '<script setup>\nconst x = a && b\n</script>';
|
|
52
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code);
|
|
53
|
+
const outputs = result.map((v) => v.code);
|
|
54
|
+
const unique = new Set(outputs);
|
|
55
|
+
expect(outputs.length).toBe(unique.size);
|
|
56
|
+
});
|
|
57
|
+
it('respects max limit', async () => {
|
|
58
|
+
const code = '<script setup>\nconst x = a && b\nconst y = c || d\n</script>';
|
|
59
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code, undefined, undefined, 1);
|
|
60
|
+
expect(result.length).toBeLessThanOrEqual(1);
|
|
61
|
+
});
|
|
62
|
+
it('filters mutators with include', async () => {
|
|
63
|
+
const code = '<script setup>\nconst x = a && b\n</script>';
|
|
64
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code, ['andToOr']);
|
|
65
|
+
for (const v of result) {
|
|
66
|
+
expect(v.name).toBe('andToOr');
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it('filters mutators with exclude', async () => {
|
|
70
|
+
const code = '<script setup>\nconst x = a && b\n</script>';
|
|
71
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code, undefined, ['andToOr']);
|
|
72
|
+
for (const v of result) {
|
|
73
|
+
expect(v.name).not.toBe('andToOr');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateMutationVariants, getFilteredRegistry } from '../variant-utils.js';
|
|
3
|
+
function makeMutator(name, mutations) {
|
|
4
|
+
return {
|
|
5
|
+
name,
|
|
6
|
+
description: name,
|
|
7
|
+
apply: () => mutations,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
describe('generateMutationVariants', () => {
|
|
11
|
+
it('returns empty array for empty registry', () => {
|
|
12
|
+
expect(generateMutationVariants([], 'const x = 1')).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
it('generates variants from a single mutator', () => {
|
|
15
|
+
const mutator = makeMutator('flip', [
|
|
16
|
+
{ code: 'const x = 2', line: 1, col: 0 },
|
|
17
|
+
]);
|
|
18
|
+
const result = generateMutationVariants([mutator], 'const x = 1');
|
|
19
|
+
expect(result).toEqual([
|
|
20
|
+
{ name: 'flip', code: 'const x = 2', line: 1, col: 0 },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it('generates variants from multiple mutators', () => {
|
|
24
|
+
const m1 = makeMutator('a', [{ code: 'code_a', line: 1, col: 0 }]);
|
|
25
|
+
const m2 = makeMutator('b', [{ code: 'code_b', line: 2, col: 5 }]);
|
|
26
|
+
const result = generateMutationVariants([m1, m2], 'original');
|
|
27
|
+
expect(result).toHaveLength(2);
|
|
28
|
+
expect(result[0].name).toBe('a');
|
|
29
|
+
expect(result[1].name).toBe('b');
|
|
30
|
+
});
|
|
31
|
+
it('deduplicates identical mutation outputs', () => {
|
|
32
|
+
const m1 = makeMutator('a', [{ code: 'same', line: 1, col: 0 }]);
|
|
33
|
+
const m2 = makeMutator('b', [{ code: 'same', line: 2, col: 0 }]);
|
|
34
|
+
const result = generateMutationVariants([m1, m2], 'original');
|
|
35
|
+
expect(result).toHaveLength(1);
|
|
36
|
+
expect(result[0].name).toBe('a');
|
|
37
|
+
});
|
|
38
|
+
it('throws when max is 0', () => {
|
|
39
|
+
expect(() => generateMutationVariants([], 'code', { max: 0 })).toThrow('max must be a positive number, got: 0');
|
|
40
|
+
});
|
|
41
|
+
it('throws when max is negative', () => {
|
|
42
|
+
expect(() => generateMutationVariants([], 'code', { max: -5 })).toThrow('max must be a positive number, got: -5');
|
|
43
|
+
});
|
|
44
|
+
it('respects max limit', () => {
|
|
45
|
+
const mutator = makeMutator('m', [
|
|
46
|
+
{ code: 'v1', line: 1, col: 0 },
|
|
47
|
+
{ code: 'v2', line: 2, col: 0 },
|
|
48
|
+
{ code: 'v3', line: 3, col: 0 },
|
|
49
|
+
]);
|
|
50
|
+
const result = generateMutationVariants([mutator], 'code', { max: 2 });
|
|
51
|
+
expect(result).toHaveLength(2);
|
|
52
|
+
});
|
|
53
|
+
it('stops iterating mutators once max is reached via inner return', () => {
|
|
54
|
+
const m1 = makeMutator('a', [
|
|
55
|
+
{ code: 'v1', line: 1, col: 0 },
|
|
56
|
+
{ code: 'v2', line: 2, col: 0 },
|
|
57
|
+
]);
|
|
58
|
+
const m2 = makeMutator('b', [{ code: 'v3', line: 3, col: 0 }]);
|
|
59
|
+
const result = generateMutationVariants([m1, m2], 'code', { max: 2 });
|
|
60
|
+
expect(result).toHaveLength(2);
|
|
61
|
+
expect(result.every((v) => v.name === 'a')).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
// NOTE: The outer loop `break` (variant-utils.ts line 42) is dead code.
|
|
64
|
+
// The inner loop always `return`s immediately when max is reached after adding
|
|
65
|
+
// a new unique variant, so the outer loop check can never fire.
|
|
66
|
+
// Same applies to sfc.ts line 43.
|
|
67
|
+
it('works with undefined max', () => {
|
|
68
|
+
const mutator = makeMutator('m', [
|
|
69
|
+
{ code: 'v1', line: 1, col: 0 },
|
|
70
|
+
{ code: 'v2', line: 2, col: 0 },
|
|
71
|
+
]);
|
|
72
|
+
const result = generateMutationVariants([mutator], 'code', {});
|
|
73
|
+
expect(result).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('getFilteredRegistry', () => {
|
|
77
|
+
it('returns the full registry when no filters', () => {
|
|
78
|
+
const result = getFilteredRegistry();
|
|
79
|
+
expect(result.length).toBeGreaterThan(0);
|
|
80
|
+
expect(result[0]).toHaveProperty('name');
|
|
81
|
+
expect(result[0]).toHaveProperty('apply');
|
|
82
|
+
});
|
|
83
|
+
it('filters by include', () => {
|
|
84
|
+
const result = getFilteredRegistry(['flipEQ', 'andToOr']);
|
|
85
|
+
expect(result.map((r) => r.name)).toEqual(['andToOr', 'flipEQ']);
|
|
86
|
+
});
|
|
87
|
+
it('filters by exclude', () => {
|
|
88
|
+
const all = getFilteredRegistry();
|
|
89
|
+
const result = getFilteredRegistry(undefined, ['flipEQ']);
|
|
90
|
+
expect(result.length).toBe(all.length - 1);
|
|
91
|
+
expect(result.find((r) => r.name === 'flipEQ')).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
|
|
3
|
+
describe('parseFlagNumber', () => {
|
|
4
|
+
it('parses valid integers', () => {
|
|
5
|
+
expect(parseFlagNumber('42', '--flag')).toBe(42);
|
|
6
|
+
});
|
|
7
|
+
it('parses valid floats', () => {
|
|
8
|
+
expect(parseFlagNumber('3.14', '--flag')).toBe(3.14);
|
|
9
|
+
});
|
|
10
|
+
it('parses zero', () => {
|
|
11
|
+
expect(parseFlagNumber('0', '--flag')).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
it('parses negative numbers', () => {
|
|
14
|
+
expect(parseFlagNumber('-5', '--flag')).toBe(-5);
|
|
15
|
+
});
|
|
16
|
+
it('throws on non-numeric strings', () => {
|
|
17
|
+
expect(() => parseFlagNumber('abc', '--flag')).toThrow('Invalid value for --flag: abc');
|
|
18
|
+
});
|
|
19
|
+
it('parses empty string as 0', () => {
|
|
20
|
+
// Number('') === 0, which is finite
|
|
21
|
+
expect(parseFlagNumber('', '--flag')).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
it('throws on NaN-producing values', () => {
|
|
24
|
+
expect(() => parseFlagNumber('not-a-number', '--flag')).toThrow('Invalid value for --flag: not-a-number');
|
|
25
|
+
});
|
|
26
|
+
it('throws on Infinity', () => {
|
|
27
|
+
expect(() => parseFlagNumber('Infinity', '--flag')).toThrow('Invalid value for --flag: Infinity');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('readNumberFlag', () => {
|
|
31
|
+
it('reads flag with separate value', () => {
|
|
32
|
+
expect(readNumberFlag(['--count', '5'], '--count')).toBe(5);
|
|
33
|
+
});
|
|
34
|
+
it('reads flag with = syntax', () => {
|
|
35
|
+
expect(readNumberFlag(['--count=10'], '--count')).toBe(10);
|
|
36
|
+
});
|
|
37
|
+
it('returns undefined when flag is not present', () => {
|
|
38
|
+
expect(readNumberFlag(['--other', '5'], '--count')).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
it('throws when no value follows flag', () => {
|
|
41
|
+
expect(() => readNumberFlag(['--count'], '--count')).toThrow('Expected a numeric value after --count');
|
|
42
|
+
});
|
|
43
|
+
it('throws when value is not a number', () => {
|
|
44
|
+
expect(() => readNumberFlag(['--count', 'abc'], '--count')).toThrow('Invalid value for --count: abc');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('readStringFlag', () => {
|
|
48
|
+
it('reads flag with separate value', () => {
|
|
49
|
+
expect(readStringFlag(['--config', 'path.ts'], '--config')).toBe('path.ts');
|
|
50
|
+
});
|
|
51
|
+
it('reads flag with = syntax', () => {
|
|
52
|
+
expect(readStringFlag(['--config=path.ts'], '--config')).toBe('path.ts');
|
|
53
|
+
});
|
|
54
|
+
it('reads alias', () => {
|
|
55
|
+
expect(readStringFlag(['-c', 'path.ts'], '--config', '-c')).toBe('path.ts');
|
|
56
|
+
});
|
|
57
|
+
it('reads alias with = syntax', () => {
|
|
58
|
+
expect(readStringFlag(['-c=path.ts'], '--config', '-c')).toBe('path.ts');
|
|
59
|
+
});
|
|
60
|
+
it('returns undefined when flag is not present', () => {
|
|
61
|
+
expect(readStringFlag(['--other', 'val'], '--config')).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
it('throws when no value follows flag', () => {
|
|
64
|
+
expect(() => readStringFlag(['--config'], '--config')).toThrow('Expected a value after --config');
|
|
65
|
+
});
|
|
66
|
+
it('throws when no value follows alias', () => {
|
|
67
|
+
expect(() => readStringFlag(['-c'], '--config', '-c')).toThrow('Expected a value after -c');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('validatePercent', () => {
|
|
71
|
+
it('returns undefined for undefined', () => {
|
|
72
|
+
expect(validatePercent(undefined, 'test')).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
it('returns the value for valid percentages', () => {
|
|
75
|
+
expect(validatePercent(0, 'test')).toBe(0);
|
|
76
|
+
expect(validatePercent(50, 'test')).toBe(50);
|
|
77
|
+
expect(validatePercent(100, 'test')).toBe(100);
|
|
78
|
+
});
|
|
79
|
+
it('throws for negative values', () => {
|
|
80
|
+
expect(() => validatePercent(-1, 'test')).toThrow('Invalid test: expected value between 0 and 100 (received -1)');
|
|
81
|
+
});
|
|
82
|
+
it('throws for values over 100', () => {
|
|
83
|
+
expect(() => validatePercent(101, 'test')).toThrow('Invalid test: expected value between 0 and 100 (received 101)');
|
|
84
|
+
});
|
|
85
|
+
it('throws for non-finite values', () => {
|
|
86
|
+
expect(() => validatePercent(NaN, 'test')).toThrow('Invalid test: expected a number between 0 and 100');
|
|
87
|
+
expect(() => validatePercent(Infinity, 'test')).toThrow('Invalid test: expected a number between 0 and 100');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('parseConcurrency', () => {
|
|
91
|
+
it('returns default when flag not present', () => {
|
|
92
|
+
const result = parseConcurrency([]);
|
|
93
|
+
expect(result).toBeGreaterThanOrEqual(1);
|
|
94
|
+
});
|
|
95
|
+
it('parses explicit concurrency', () => {
|
|
96
|
+
expect(parseConcurrency(['--concurrency', '4'])).toBe(4);
|
|
97
|
+
});
|
|
98
|
+
it('clamps to minimum of 1', () => {
|
|
99
|
+
expect(parseConcurrency(['--concurrency', '0'])).toBeGreaterThanOrEqual(1);
|
|
100
|
+
});
|
|
101
|
+
it('handles invalid concurrency value gracefully', () => {
|
|
102
|
+
const result = parseConcurrency(['--concurrency', 'abc']);
|
|
103
|
+
expect(result).toBeGreaterThanOrEqual(1);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('parseProgressMode', () => {
|
|
107
|
+
it('returns bar by default', () => {
|
|
108
|
+
expect(parseProgressMode([])).toBe('bar');
|
|
109
|
+
});
|
|
110
|
+
it('returns list when specified', () => {
|
|
111
|
+
expect(parseProgressMode(['--progress', 'list'])).toBe('list');
|
|
112
|
+
});
|
|
113
|
+
it('returns quiet when specified', () => {
|
|
114
|
+
expect(parseProgressMode(['--progress', 'quiet'])).toBe('quiet');
|
|
115
|
+
});
|
|
116
|
+
it('returns bar for unknown values', () => {
|
|
117
|
+
expect(parseProgressMode(['--progress', 'unknown'])).toBe('bar');
|
|
118
|
+
});
|
|
119
|
+
it('defaults to bar when --progress has no following value', () => {
|
|
120
|
+
expect(parseProgressMode(['--progress'])).toBe('bar');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('parseCliOptions', () => {
|
|
124
|
+
const emptyCfg = {};
|
|
125
|
+
it('parses --changed flag', () => {
|
|
126
|
+
const opts = parseCliOptions(['--changed'], emptyCfg);
|
|
127
|
+
expect(opts.wantsChanged).toBe(true);
|
|
128
|
+
expect(opts.wantsChangedWithDeps).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
it('parses --changed-with-deps flag', () => {
|
|
131
|
+
const opts = parseCliOptions(['--changed-with-deps'], emptyCfg);
|
|
132
|
+
expect(opts.wantsChangedWithDeps).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
it('parses --only-covered-lines flag', () => {
|
|
135
|
+
const opts = parseCliOptions(['--only-covered-lines'], emptyCfg);
|
|
136
|
+
expect(opts.wantsOnlyCoveredLines).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it('reads onlyCoveredLines from config', () => {
|
|
139
|
+
const opts = parseCliOptions([], { onlyCoveredLines: true });
|
|
140
|
+
expect(opts.wantsOnlyCoveredLines).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
it('parses --per-test-coverage flag', () => {
|
|
143
|
+
const opts = parseCliOptions(['--per-test-coverage'], emptyCfg);
|
|
144
|
+
expect(opts.wantsPerTestCoverage).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
it('reads perTestCoverage from config', () => {
|
|
147
|
+
const opts = parseCliOptions([], { perTestCoverage: true });
|
|
148
|
+
expect(opts.wantsPerTestCoverage).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
it('parses --coverage-file flag', () => {
|
|
151
|
+
const opts = parseCliOptions(['--coverage-file', 'coverage.json'], emptyCfg);
|
|
152
|
+
expect(opts.coverageFilePath).toBe('coverage.json');
|
|
153
|
+
});
|
|
154
|
+
it('reads coverageFile from config', () => {
|
|
155
|
+
const opts = parseCliOptions([], { coverageFile: 'cov.json' });
|
|
156
|
+
expect(opts.coverageFilePath).toBe('cov.json');
|
|
157
|
+
});
|
|
158
|
+
it('CLI coverage-file takes precedence over config', () => {
|
|
159
|
+
const opts = parseCliOptions(['--coverage-file', 'cli.json'], {
|
|
160
|
+
coverageFile: 'config.json',
|
|
161
|
+
});
|
|
162
|
+
expect(opts.coverageFilePath).toBe('cli.json');
|
|
163
|
+
});
|
|
164
|
+
it('parses --runner vitest', () => {
|
|
165
|
+
const opts = parseCliOptions(['--runner', 'vitest'], emptyCfg);
|
|
166
|
+
expect(opts.runner).toBe('vitest');
|
|
167
|
+
});
|
|
168
|
+
it('parses --runner jest', () => {
|
|
169
|
+
const opts = parseCliOptions(['--runner', 'jest'], emptyCfg);
|
|
170
|
+
expect(opts.runner).toBe('jest');
|
|
171
|
+
});
|
|
172
|
+
it('falls back to config runner', () => {
|
|
173
|
+
const opts = parseCliOptions([], { runner: 'jest' });
|
|
174
|
+
expect(opts.runner).toBe('jest');
|
|
175
|
+
});
|
|
176
|
+
it('defaults to vitest', () => {
|
|
177
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
178
|
+
expect(opts.runner).toBe('vitest');
|
|
179
|
+
});
|
|
180
|
+
it('parses --config flag', () => {
|
|
181
|
+
const opts = parseCliOptions(['--config', 'my.config.ts'], emptyCfg);
|
|
182
|
+
expect(opts.configPath).toBe('my.config.ts');
|
|
183
|
+
});
|
|
184
|
+
it('parses -c alias', () => {
|
|
185
|
+
const opts = parseCliOptions(['-c', 'my.config.ts'], emptyCfg);
|
|
186
|
+
expect(opts.configPath).toBe('my.config.ts');
|
|
187
|
+
});
|
|
188
|
+
it('parses --min-kill-percent from CLI', () => {
|
|
189
|
+
const opts = parseCliOptions(['--min-kill-percent', '80'], emptyCfg);
|
|
190
|
+
expect(opts.minKillPercent).toBe(80);
|
|
191
|
+
});
|
|
192
|
+
it('reads minKillPercent from config', () => {
|
|
193
|
+
const opts = parseCliOptions([], { minKillPercent: 75 });
|
|
194
|
+
expect(opts.minKillPercent).toBe(75);
|
|
195
|
+
});
|
|
196
|
+
it('CLI min-kill-percent takes precedence over config', () => {
|
|
197
|
+
const opts = parseCliOptions(['--min-kill-percent', '90'], {
|
|
198
|
+
minKillPercent: 75,
|
|
199
|
+
});
|
|
200
|
+
expect(opts.minKillPercent).toBe(90);
|
|
201
|
+
});
|
|
202
|
+
it('rejects invalid --min-kill-percent', () => {
|
|
203
|
+
expect(() => parseCliOptions(['--min-kill-percent', '150'], emptyCfg)).toThrow('expected value between 0 and 100');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('extractConfigPath', () => {
|
|
207
|
+
it('extracts --config with separate value', () => {
|
|
208
|
+
expect(extractConfigPath(['--config', 'my.config.ts'])).toBe('my.config.ts');
|
|
209
|
+
});
|
|
210
|
+
it('extracts --config with = syntax', () => {
|
|
211
|
+
expect(extractConfigPath(['--config=my.config.ts'])).toBe('my.config.ts');
|
|
212
|
+
});
|
|
213
|
+
it('extracts -c alias with separate value', () => {
|
|
214
|
+
expect(extractConfigPath(['-c', 'my.config.ts'])).toBe('my.config.ts');
|
|
215
|
+
});
|
|
216
|
+
it('extracts -c alias with = syntax', () => {
|
|
217
|
+
expect(extractConfigPath(['-c=my.config.ts'])).toBe('my.config.ts');
|
|
218
|
+
});
|
|
219
|
+
it('returns undefined when no config flag is present', () => {
|
|
220
|
+
expect(extractConfigPath(['--changed', '--runner', 'vitest'])).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
it('returns undefined for empty args', () => {
|
|
223
|
+
expect(extractConfigPath([])).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
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 { clearCacheOnStart, saveCacheAtomic, decodeCacheKey, keyForTests, hash, readMutantCache, } from '../cache.js';
|
|
6
|
+
let tmpDir;
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-cache-'));
|
|
9
|
+
});
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
describe('clearCacheOnStart', () => {
|
|
14
|
+
it('removes the cache file if it exists', async () => {
|
|
15
|
+
const cacheFile = path.join(tmpDir, '.mutate-cache.json');
|
|
16
|
+
await fs.writeFile(cacheFile, '{}');
|
|
17
|
+
await clearCacheOnStart(tmpDir);
|
|
18
|
+
await expect(fs.access(cacheFile)).rejects.toThrow();
|
|
19
|
+
});
|
|
20
|
+
it('does not throw if cache file does not exist', async () => {
|
|
21
|
+
await expect(clearCacheOnStart(tmpDir)).resolves.toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('saveCacheAtomic', () => {
|
|
25
|
+
it('writes cache data to the file', async () => {
|
|
26
|
+
const cache = {
|
|
27
|
+
key1: {
|
|
28
|
+
status: 'killed',
|
|
29
|
+
file: 'foo.ts',
|
|
30
|
+
line: 1,
|
|
31
|
+
col: 0,
|
|
32
|
+
mutator: 'flipEQ',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
await saveCacheAtomic(tmpDir, cache);
|
|
36
|
+
const content = await fs.readFile(path.join(tmpDir, '.mutate-cache.json'), 'utf8');
|
|
37
|
+
expect(JSON.parse(content)).toEqual(cache);
|
|
38
|
+
});
|
|
39
|
+
it('overwrites existing cache', async () => {
|
|
40
|
+
await saveCacheAtomic(tmpDir, { old: {} });
|
|
41
|
+
const newCache = {
|
|
42
|
+
new: {
|
|
43
|
+
status: 'escaped',
|
|
44
|
+
file: 'bar.ts',
|
|
45
|
+
line: 2,
|
|
46
|
+
col: 3,
|
|
47
|
+
mutator: 'andToOr',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
await saveCacheAtomic(tmpDir, newCache);
|
|
51
|
+
const content = await fs.readFile(path.join(tmpDir, '.mutate-cache.json'), 'utf8');
|
|
52
|
+
expect(JSON.parse(content)).toEqual(newCache);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('decodeCacheKey', () => {
|
|
56
|
+
it('decodes a full cache key', () => {
|
|
57
|
+
const key = 'testsig:codesig:src/foo.ts:10,5:flipEQ';
|
|
58
|
+
const decoded = decodeCacheKey(key);
|
|
59
|
+
expect(decoded.file).toBe('src/foo.ts');
|
|
60
|
+
expect(decoded.line).toBe(10);
|
|
61
|
+
expect(decoded.col).toBe(5);
|
|
62
|
+
expect(decoded.mutator).toBe('flipEQ');
|
|
63
|
+
});
|
|
64
|
+
it('handles key with no colons', () => {
|
|
65
|
+
const decoded = decodeCacheKey('nodelimiters');
|
|
66
|
+
expect(decoded.mutator).toBe('unknown');
|
|
67
|
+
});
|
|
68
|
+
it('handles key with only one colon', () => {
|
|
69
|
+
const decoded = decodeCacheKey('only:one');
|
|
70
|
+
expect(decoded.mutator).toBe('one');
|
|
71
|
+
});
|
|
72
|
+
it('handles malformed position', () => {
|
|
73
|
+
const key = 'a:b:file:badpos:mutator';
|
|
74
|
+
const decoded = decodeCacheKey(key);
|
|
75
|
+
expect(decoded.mutator).toBe('mutator');
|
|
76
|
+
});
|
|
77
|
+
it('handles key with exactly two colons (no firstColon in rest)', () => {
|
|
78
|
+
// Key: "pos:mutator" after splitting last colon → rest = "pos", no colon in rest
|
|
79
|
+
// Full key: "10,5:mutator" → lastColon splits mutator, positionColon splits pos
|
|
80
|
+
// rest becomes empty string before positionColon
|
|
81
|
+
const decoded = decodeCacheKey('10,5:mutator');
|
|
82
|
+
expect(decoded.mutator).toBe('mutator');
|
|
83
|
+
expect(decoded.line).toBe(0); // positionColon = -1, returns early
|
|
84
|
+
});
|
|
85
|
+
it('handles key with exactly three colons (firstColon but no secondColon)', () => {
|
|
86
|
+
// "sig:10,5:mutator" → mutator='mutator', posRaw='10,5', rest='sig'
|
|
87
|
+
// firstColon in 'sig' = -1, returns early at line 70
|
|
88
|
+
const decoded = decodeCacheKey('sig:10,5:mutator');
|
|
89
|
+
expect(decoded.mutator).toBe('mutator');
|
|
90
|
+
expect(decoded.line).toBe(10);
|
|
91
|
+
expect(decoded.col).toBe(5);
|
|
92
|
+
});
|
|
93
|
+
it('handles key with four colons (firstColon but no secondColon in restAfterFirst)', () => {
|
|
94
|
+
// "tsig:csig:10,5:mutator" → mutator='mutator', posRaw='10,5', rest='tsig:csig'
|
|
95
|
+
// firstColon=4, restAfterFirst='csig', secondColon in 'csig' = -1, returns at line 73
|
|
96
|
+
const decoded = decodeCacheKey('tsig:csig:10,5:mutator');
|
|
97
|
+
expect(decoded.mutator).toBe('mutator');
|
|
98
|
+
expect(decoded.line).toBe(10);
|
|
99
|
+
expect(decoded.col).toBe(5);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('keyForTests', () => {
|
|
103
|
+
it('produces deterministic keys regardless of input order', () => {
|
|
104
|
+
const key1 = keyForTests(['b.test.ts', 'a.test.ts']);
|
|
105
|
+
const key2 = keyForTests(['a.test.ts', 'b.test.ts']);
|
|
106
|
+
expect(key1).toBe(key2);
|
|
107
|
+
});
|
|
108
|
+
it('produces different keys for different test sets', () => {
|
|
109
|
+
const key1 = keyForTests(['a.test.ts']);
|
|
110
|
+
const key2 = keyForTests(['b.test.ts']);
|
|
111
|
+
expect(key1).not.toBe(key2);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('hash', () => {
|
|
115
|
+
it('returns a 12 character hex string', () => {
|
|
116
|
+
const h = hash('test');
|
|
117
|
+
expect(h).toMatch(/^[0-9a-f]{12}$/);
|
|
118
|
+
});
|
|
119
|
+
it('returns the same hash for the same input', () => {
|
|
120
|
+
expect(hash('hello')).toBe(hash('hello'));
|
|
121
|
+
});
|
|
122
|
+
it('returns different hashes for different inputs', () => {
|
|
123
|
+
expect(hash('a')).not.toBe(hash('b'));
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe('readMutantCache', () => {
|
|
127
|
+
it('returns empty object when no cache file exists', async () => {
|
|
128
|
+
const result = await readMutantCache(tmpDir);
|
|
129
|
+
expect(result).toEqual({});
|
|
130
|
+
});
|
|
131
|
+
it('reads and normalizes object-format cache entries', async () => {
|
|
132
|
+
const cache = {
|
|
133
|
+
'testsig:codesig:file.ts:1,0:flip': {
|
|
134
|
+
status: 'killed',
|
|
135
|
+
file: 'file.ts',
|
|
136
|
+
line: 1,
|
|
137
|
+
col: 0,
|
|
138
|
+
mutator: 'flip',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), JSON.stringify(cache));
|
|
142
|
+
const result = await readMutantCache(tmpDir);
|
|
143
|
+
expect(result['testsig:codesig:file.ts:1,0:flip']).toEqual({
|
|
144
|
+
status: 'killed',
|
|
145
|
+
file: 'file.ts',
|
|
146
|
+
line: 1,
|
|
147
|
+
col: 0,
|
|
148
|
+
mutator: 'flip',
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
it('reads and normalizes old string-format cache entries', async () => {
|
|
152
|
+
const cache = {
|
|
153
|
+
'testsig:codesig:file.ts:1,0:flip': 'killed',
|
|
154
|
+
};
|
|
155
|
+
await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), JSON.stringify(cache));
|
|
156
|
+
const result = await readMutantCache(tmpDir);
|
|
157
|
+
const entry = result['testsig:codesig:file.ts:1,0:flip'];
|
|
158
|
+
expect(entry.status).toBe('killed');
|
|
159
|
+
expect(entry.mutator).toBe('flip');
|
|
160
|
+
});
|
|
161
|
+
it('returns empty object for invalid JSON', async () => {
|
|
162
|
+
await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), 'not json');
|
|
163
|
+
const result = await readMutantCache(tmpDir);
|
|
164
|
+
expect(result).toEqual({});
|
|
165
|
+
});
|
|
166
|
+
it('normalizes partial object entries with decoded fallbacks', async () => {
|
|
167
|
+
const cache = {
|
|
168
|
+
'testsig:codesig:file.ts:5,3:mut': {
|
|
169
|
+
status: 'escaped',
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), JSON.stringify(cache));
|
|
173
|
+
const result = await readMutantCache(tmpDir);
|
|
174
|
+
const entry = result['testsig:codesig:file.ts:5,3:mut'];
|
|
175
|
+
expect(entry.status).toBe('escaped');
|
|
176
|
+
expect(entry.line).toBe(5);
|
|
177
|
+
expect(entry.col).toBe(3);
|
|
178
|
+
expect(entry.mutator).toBe('mut');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|