@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
package/README.md
CHANGED
|
@@ -13,13 +13,11 @@ Mutineer is a fast, targeted mutation testing framework for JavaScript and TypeS
|
|
|
13
13
|
|
|
14
14
|
Built for **Vitest** with first-class **Jest** support. Other test runners can be added via the adapter interface.
|
|
15
15
|
|
|
16
|
-
**Author**: [Billy Jones](https://www.linkedin.com/in/billyjonesy/)
|
|
17
|
-
|
|
18
16
|
## How It Works
|
|
19
17
|
|
|
20
18
|
1. **Baseline** -- runs your test suite to make sure everything passes before mutating
|
|
21
19
|
2. **Mutate** -- applies AST-safe operator replacements to your source files (not your tests)
|
|
22
|
-
3. **Test** -- re-runs only the tests that import the mutated file
|
|
20
|
+
3. **Test** -- re-runs only the tests that import the mutated file; compile errors are detected via parallel TypeScript workers and surfaced in an interactive UI
|
|
23
21
|
4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
|
|
24
22
|
|
|
25
23
|
Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is written to a temporary `__mutineer__` directory next to each source file, then loaded at runtime via Vite plugins (Vitest) or custom resolvers (Jest).
|
|
@@ -95,18 +93,22 @@ npm run mutineer
|
|
|
95
93
|
|
|
96
94
|
### CLI Options (for `mutineer run`)
|
|
97
95
|
|
|
98
|
-
| Flag | Description
|
|
99
|
-
| ------------------------ |
|
|
100
|
-
| `--runner <type>` | Test runner: `vitest` or `jest`
|
|
101
|
-
| `--config`, `-c` | Path to config file
|
|
102
|
-
| `--concurrency <n>` | Parallel workers (min 1)
|
|
103
|
-
| `--changed` | Only mutate files changed vs base branch
|
|
104
|
-
| `--changed-with-deps` | Include dependents of changed files
|
|
105
|
-
| `--only-covered-lines` | Skip mutations on uncovered lines
|
|
106
|
-
| `--per-test-coverage` | Run only tests that cover the mutated line
|
|
107
|
-
| `--coverage-file <path>` | Path to Istanbul coverage JSON
|
|
108
|
-
| `--min-kill-percent <n>` | Fail if kill rate is below threshold
|
|
109
|
-
| `--progress <mode>` | Display mode: `bar`, `list`, or `quiet`
|
|
96
|
+
| Flag | Description | Default |
|
|
97
|
+
| ------------------------ | --------------------------------------------------------------- | ------------- |
|
|
98
|
+
| `--runner <type>` | Test runner: `vitest` or `jest` | `vitest` |
|
|
99
|
+
| `--config`, `-c` | Path to config file | auto-detected |
|
|
100
|
+
| `--concurrency <n>` | Parallel workers (min 1) | CPUs - 1 |
|
|
101
|
+
| `--changed` | Only mutate files changed vs base branch | -- |
|
|
102
|
+
| `--changed-with-deps` | Include dependents of changed files | -- |
|
|
103
|
+
| `--only-covered-lines` | Skip mutations on uncovered lines | -- |
|
|
104
|
+
| `--per-test-coverage` | Run only tests that cover the mutated line | -- |
|
|
105
|
+
| `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
|
|
106
|
+
| `--min-kill-percent <n>` | Fail if kill rate is below threshold | -- |
|
|
107
|
+
| `--progress <mode>` | Display mode: `bar`, `list`, or `quiet` | `bar` |
|
|
108
|
+
| `--timeout <ms>` | Per-mutant test timeout | `30000` |
|
|
109
|
+
| `--report <format>` | Output format: `text` or `json` (writes `mutineer-report.json`) | `text` |
|
|
110
|
+
| `--shard <n>/<total>` | Run a slice of mutants (e.g. `--shard 1/4`) | -- |
|
|
111
|
+
| `--skip-baseline` | Skip the baseline test run | -- |
|
|
110
112
|
|
|
111
113
|
### Examples
|
|
112
114
|
|
|
@@ -168,6 +170,17 @@ export default defineMutineerConfig({
|
|
|
168
170
|
|
|
169
171
|
Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
|
|
170
172
|
|
|
173
|
+
When you run `mutineer run` without `--changed` or `--changed-with-deps` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
Warning: Running on the full codebase may take a while.
|
|
177
|
+
|
|
178
|
+
[1] Continue (full codebase)
|
|
179
|
+
[2] --changed (git-changed files only)
|
|
180
|
+
[3] --changed-with-deps (changed + their local deps)
|
|
181
|
+
[4] Abort
|
|
182
|
+
```
|
|
183
|
+
|
|
171
184
|
### 1. PR-scoped runs (CI) — `--changed-with-deps`
|
|
172
185
|
|
|
173
186
|
Run only on files changed in the branch plus their direct dependents:
|
|
@@ -262,6 +275,10 @@ export function createMyRunnerAdapter(
|
|
|
262
275
|
|
|
263
276
|
The key requirement is the **file-swap mechanism** -- the adapter needs a way to intercept module resolution so the mutated source code is loaded instead of the original file on disk. See the Vitest adapter (Vite plugin + ESM loader) and Jest adapter (custom resolver) for working reference implementations in `src/runner/vitest/` and `src/runner/jest/`.
|
|
264
277
|
|
|
278
|
+
## Maintainers
|
|
279
|
+
|
|
280
|
+
- [Billy Jones](https://www.linkedin.com/in/billyjonesy/)
|
|
281
|
+
|
|
265
282
|
## License
|
|
266
283
|
|
|
267
284
|
MIT
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
4
|
+
import { HELP_TEXT, getVersion, confirmFullRun } from '../mutineer.js';
|
|
4
5
|
describe('HELP_TEXT', () => {
|
|
5
6
|
const flags = [
|
|
6
7
|
'--config',
|
|
@@ -31,6 +32,70 @@ describe('HELP_TEXT', () => {
|
|
|
31
32
|
expect(HELP_TEXT).toContain('local dependencies');
|
|
32
33
|
});
|
|
33
34
|
});
|
|
35
|
+
describe('confirmFullRun()', () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
function mockTTY(isTTY) {
|
|
40
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
41
|
+
value: isTTY,
|
|
42
|
+
configurable: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function mockReadline(answers) {
|
|
46
|
+
let callIndex = 0;
|
|
47
|
+
vi.spyOn(readline, 'createInterface').mockReturnValue({
|
|
48
|
+
question(_prompt, cb) {
|
|
49
|
+
cb(answers[callIndex++] ?? '');
|
|
50
|
+
},
|
|
51
|
+
close: vi.fn(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
it('returns args unchanged when --changed is present', async () => {
|
|
55
|
+
mockTTY(true);
|
|
56
|
+
const args = ['--changed', '--concurrency', '4'];
|
|
57
|
+
expect(await confirmFullRun(args)).toBe(args);
|
|
58
|
+
});
|
|
59
|
+
it('returns args unchanged when --changed-with-deps is present', async () => {
|
|
60
|
+
mockTTY(true);
|
|
61
|
+
const args = ['--changed-with-deps'];
|
|
62
|
+
expect(await confirmFullRun(args)).toBe(args);
|
|
63
|
+
});
|
|
64
|
+
it('skips prompt and returns args unchanged when stdin is not a TTY', async () => {
|
|
65
|
+
mockTTY(false);
|
|
66
|
+
const createSpy = vi.spyOn(readline, 'createInterface');
|
|
67
|
+
const args = ['--concurrency', '2'];
|
|
68
|
+
expect(await confirmFullRun(args)).toBe(args);
|
|
69
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it('choice 1 (default Enter) returns args unchanged', async () => {
|
|
72
|
+
mockTTY(true);
|
|
73
|
+
mockReadline(['']);
|
|
74
|
+
const args = ['--concurrency', '2'];
|
|
75
|
+
expect(await confirmFullRun(args)).toEqual(args);
|
|
76
|
+
});
|
|
77
|
+
it('choice 1 returns args unchanged', async () => {
|
|
78
|
+
mockTTY(true);
|
|
79
|
+
mockReadline(['1']);
|
|
80
|
+
const args = [];
|
|
81
|
+
expect(await confirmFullRun(args)).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
it('choice 2 appends --changed', async () => {
|
|
84
|
+
mockTTY(true);
|
|
85
|
+
mockReadline(['2']);
|
|
86
|
+
expect(await confirmFullRun([])).toEqual(['--changed']);
|
|
87
|
+
});
|
|
88
|
+
it('choice 3 appends --changed-with-deps', async () => {
|
|
89
|
+
mockTTY(true);
|
|
90
|
+
mockReadline(['3']);
|
|
91
|
+
expect(await confirmFullRun([])).toEqual(['--changed-with-deps']);
|
|
92
|
+
});
|
|
93
|
+
it('invalid input re-prompts, then accepts valid choice', async () => {
|
|
94
|
+
mockTTY(true);
|
|
95
|
+
mockReadline(['9', 'x', '2']);
|
|
96
|
+
expect(await confirmFullRun([])).toEqual(['--changed']);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
34
99
|
describe('getVersion()', () => {
|
|
35
100
|
it('returns a semver string', () => {
|
|
36
101
|
expect(getVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
package/dist/bin/mutineer.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export declare const HELP_TEXT = "Usage: mutineer <command> [options]\n\nCommands:\n init Create a mutineer.config.ts template\n run Run mutation testing\n clean Remove __mutineer__ temp directories\n\nOptions (run):\n --config, -c <path> Config file path\n --concurrency <n> Worker count (default: CPU count - 1)\n --runner <vitest|jest> Test runner (default: vitest)\n --progress <bar|list|quiet> Progress display (default: bar)\n --changed Mutate only git-changed files\n --changed-with-deps Mutate changed files + their local dependencies\n --only-covered-lines Mutate only lines covered by tests\n --per-test-coverage Collect per-test coverage data\n --coverage-file <path> Path to coverage JSON\n --min-kill-percent <n> Minimum kill % threshold (0\u2013100)\n --timeout <ms> Per-mutant test timeout in ms (default: 30000)\n\n --help, -h Show this help\n --version, -V Show version\n";
|
|
2
|
+
export declare const HELP_TEXT = "Usage: mutineer <command> [options]\n\nCommands:\n init Create a mutineer.config.ts template\n run Run mutation testing\n clean Remove __mutineer__ temp directories\n\nOptions (run):\n --config, -c <path> Config file path\n --concurrency <n> Worker count (default: CPU count - 1)\n --runner <vitest|jest> Test runner (default: vitest)\n --progress <bar|list|quiet> Progress display (default: bar)\n --changed Mutate only git-changed files\n --changed-with-deps Mutate changed files + their local dependencies\n --only-covered-lines Mutate only lines covered by tests\n --per-test-coverage Collect per-test coverage data\n --coverage-file <path> Path to coverage JSON\n --min-kill-percent <n> Minimum kill % threshold (0\u2013100)\n --timeout <ms> Per-mutant test timeout in ms (default: 30000)\n --report <text|json> Output format: text (default) or json (writes mutineer-report.json)\n --shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)\n --skip-baseline Skip the baseline test run\n\n --help, -h Show this help\n --version, -V Show version\n";
|
|
3
3
|
export declare function getVersion(): string;
|
|
4
|
+
/**
|
|
5
|
+
* When running in full-codebase mode on an interactive TTY, warn the user and
|
|
6
|
+
* let them narrow scope or abort. Returns args (possibly with a flag appended).
|
|
7
|
+
*/
|
|
8
|
+
export declare function confirmFullRun(args: string[]): Promise<string[]>;
|
package/dist/bin/mutineer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
4
5
|
import { createRequire } from 'node:module';
|
|
5
6
|
import { runOrchestrator } from '../runner/orchestrator.js';
|
|
6
7
|
import { cleanupMutineerDirs } from '../runner/cleanup.js';
|
|
@@ -28,6 +29,9 @@ Options (run):
|
|
|
28
29
|
--coverage-file <path> Path to coverage JSON
|
|
29
30
|
--min-kill-percent <n> Minimum kill % threshold (0–100)
|
|
30
31
|
--timeout <ms> Per-mutant test timeout in ms (default: 30000)
|
|
32
|
+
--report <text|json> Output format: text (default) or json (writes mutineer-report.json)
|
|
33
|
+
--shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)
|
|
34
|
+
--skip-baseline Skip the baseline test run
|
|
31
35
|
|
|
32
36
|
--help, -h Show this help
|
|
33
37
|
--version, -V Show version
|
|
@@ -43,6 +47,57 @@ export default defineMutineerConfig({
|
|
|
43
47
|
source: 'src',
|
|
44
48
|
})
|
|
45
49
|
`;
|
|
50
|
+
const FULL_RUN_WARNING = `
|
|
51
|
+
Warning: Running on the full codebase may take a while.
|
|
52
|
+
|
|
53
|
+
[1] Continue (full codebase)
|
|
54
|
+
[2] --changed (git-changed files only)
|
|
55
|
+
[3] --changed-with-deps (changed + their local deps)
|
|
56
|
+
[4] Abort
|
|
57
|
+
|
|
58
|
+
`;
|
|
59
|
+
/**
|
|
60
|
+
* When running in full-codebase mode on an interactive TTY, warn the user and
|
|
61
|
+
* let them narrow scope or abort. Returns args (possibly with a flag appended).
|
|
62
|
+
*/
|
|
63
|
+
export async function confirmFullRun(args) {
|
|
64
|
+
const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-deps');
|
|
65
|
+
if (!isFullRun || !process.stdin.isTTY)
|
|
66
|
+
return args;
|
|
67
|
+
process.stdout.write(FULL_RUN_WARNING);
|
|
68
|
+
const rl = readline.createInterface({
|
|
69
|
+
input: process.stdin,
|
|
70
|
+
output: process.stdout,
|
|
71
|
+
});
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const ask = () => {
|
|
74
|
+
rl.question('Choice [1]: ', (answer) => {
|
|
75
|
+
const choice = answer.trim() || '1';
|
|
76
|
+
if (choice === '1') {
|
|
77
|
+
rl.close();
|
|
78
|
+
resolve(args);
|
|
79
|
+
}
|
|
80
|
+
else if (choice === '2') {
|
|
81
|
+
rl.close();
|
|
82
|
+
resolve([...args, '--changed']);
|
|
83
|
+
}
|
|
84
|
+
else if (choice === '3') {
|
|
85
|
+
rl.close();
|
|
86
|
+
resolve([...args, '--changed-with-deps']);
|
|
87
|
+
}
|
|
88
|
+
else if (choice === '4') {
|
|
89
|
+
rl.close();
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
process.stdout.write('Please enter 1, 2, 3, or 4.\n');
|
|
94
|
+
ask();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
ask();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
46
101
|
/**
|
|
47
102
|
* Main entry point - routes to orchestrator or clean
|
|
48
103
|
*/
|
|
@@ -61,7 +116,8 @@ async function main() {
|
|
|
61
116
|
process.stdout.write(HELP_TEXT);
|
|
62
117
|
process.exit(0);
|
|
63
118
|
}
|
|
64
|
-
await
|
|
119
|
+
const runArgs = await confirmFullRun(args.slice(1));
|
|
120
|
+
await runOrchestrator(runArgs, process.cwd());
|
|
65
121
|
}
|
|
66
122
|
else if (args[0] === INIT_COMMAND) {
|
|
67
123
|
const configFile = path.join(process.cwd(), 'mutineer.config.ts');
|
|
@@ -76,7 +132,7 @@ async function main() {
|
|
|
76
132
|
}
|
|
77
133
|
else if (args[0] === CLEAN_COMMAND) {
|
|
78
134
|
console.log('Cleaning up __mutineer__ directories...');
|
|
79
|
-
await cleanupMutineerDirs(process.cwd());
|
|
135
|
+
await cleanupMutineerDirs(process.cwd(), { includeCacheFiles: true });
|
|
80
136
|
console.log('Done.');
|
|
81
137
|
}
|
|
82
138
|
else {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateSchema } from '../schemata.js';
|
|
3
|
+
function makeVariant(id, code) {
|
|
4
|
+
return {
|
|
5
|
+
id,
|
|
6
|
+
name: 'test',
|
|
7
|
+
file: '/src/foo.ts',
|
|
8
|
+
code,
|
|
9
|
+
line: 1,
|
|
10
|
+
col: 1,
|
|
11
|
+
tests: [],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
describe('generateSchema', () => {
|
|
15
|
+
it('returns original code with ts-nocheck header for empty variants', () => {
|
|
16
|
+
const original = 'const x = 1 + 2';
|
|
17
|
+
const { schemaCode, fallbackIds } = generateSchema(original, []);
|
|
18
|
+
expect(schemaCode).toBe('// @ts-nocheck\n' + original);
|
|
19
|
+
expect(fallbackIds.size).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
it('embeds an operator mutation using the enclosing expression', () => {
|
|
22
|
+
// '+' is operator-only char diff — uses AST to find BinaryExpression 'x + y'
|
|
23
|
+
const original = 'x + y';
|
|
24
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
25
|
+
makeVariant('f#0', 'x - y'),
|
|
26
|
+
]);
|
|
27
|
+
expect(fallbackIds.size).toBe(0);
|
|
28
|
+
expect(schemaCode).toContain("'f#0'");
|
|
29
|
+
expect(schemaCode).toContain('x - y');
|
|
30
|
+
expect(schemaCode).toContain('x + y');
|
|
31
|
+
});
|
|
32
|
+
it('embeds === to !== operator mutation using the enclosing BinaryExpression', () => {
|
|
33
|
+
const original = 'x === true';
|
|
34
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
35
|
+
makeVariant('f#0', 'x !== true'),
|
|
36
|
+
]);
|
|
37
|
+
expect(fallbackIds.size).toBe(0);
|
|
38
|
+
expect(schemaCode).toContain("'f#0'");
|
|
39
|
+
expect(schemaCode).toContain('x !== true');
|
|
40
|
+
expect(schemaCode).toContain('x === true');
|
|
41
|
+
});
|
|
42
|
+
it('handles variable-length operator replacement (> → >=) without truncating operands', () => {
|
|
43
|
+
// '>' is 1 char, '>=' is 2 chars — the AST end from original must be shifted by +1
|
|
44
|
+
const original = 'hitCount > 0';
|
|
45
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
46
|
+
makeVariant('f#0', 'hitCount >= 0'),
|
|
47
|
+
]);
|
|
48
|
+
expect(fallbackIds.size).toBe(0);
|
|
49
|
+
expect(schemaCode).toContain('hitCount >= 0');
|
|
50
|
+
expect(schemaCode).toContain('hitCount > 0');
|
|
51
|
+
});
|
|
52
|
+
it('handles variable-length operator replacement (>= → >) without truncating operands', () => {
|
|
53
|
+
// '>=' is 2 chars, '>' is 1 char — AST end shifts by -1
|
|
54
|
+
const original = 'hitCount >= 0';
|
|
55
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
56
|
+
makeVariant('f#0', 'hitCount > 0'),
|
|
57
|
+
]);
|
|
58
|
+
expect(fallbackIds.size).toBe(0);
|
|
59
|
+
expect(schemaCode).toContain('hitCount > 0');
|
|
60
|
+
expect(schemaCode).toContain('hitCount >= 0');
|
|
61
|
+
});
|
|
62
|
+
it('chains multiple operator variants on the same expression site', () => {
|
|
63
|
+
const original = 'x + y';
|
|
64
|
+
const v0 = makeVariant('f#0', 'x - y');
|
|
65
|
+
const v1 = makeVariant('f#1', 'x * y');
|
|
66
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
|
|
67
|
+
expect(fallbackIds.size).toBe(0);
|
|
68
|
+
expect(schemaCode).toContain("'f#0'");
|
|
69
|
+
expect(schemaCode).toContain("'f#1'");
|
|
70
|
+
expect(schemaCode).toContain('x - y');
|
|
71
|
+
expect(schemaCode).toContain('x * y');
|
|
72
|
+
expect(schemaCode).toContain('x + y');
|
|
73
|
+
});
|
|
74
|
+
it('wraps a value mutation site in a ternary', () => {
|
|
75
|
+
// 'true' → 'false': both valid identifiers, char diff path
|
|
76
|
+
const original = 'return true';
|
|
77
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
78
|
+
makeVariant('f#0', 'return false'),
|
|
79
|
+
]);
|
|
80
|
+
expect(fallbackIds.size).toBe(0);
|
|
81
|
+
expect(schemaCode).toContain("'f#0'");
|
|
82
|
+
expect(schemaCode).toContain('false');
|
|
83
|
+
expect(schemaCode).toContain('true');
|
|
84
|
+
});
|
|
85
|
+
it('chains multiple value variants on the same site', () => {
|
|
86
|
+
const original = 'return true';
|
|
87
|
+
const v0 = makeVariant('f#0', 'return false');
|
|
88
|
+
const v1 = makeVariant('f#1', 'return null');
|
|
89
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
|
|
90
|
+
expect(fallbackIds.size).toBe(0);
|
|
91
|
+
expect(schemaCode).toContain("'f#0'");
|
|
92
|
+
expect(schemaCode).toContain("'f#1'");
|
|
93
|
+
expect(schemaCode).toContain('false');
|
|
94
|
+
expect(schemaCode).toContain('null');
|
|
95
|
+
expect(schemaCode).toContain('true');
|
|
96
|
+
});
|
|
97
|
+
it('produces separate ternaries for non-overlapping sites', () => {
|
|
98
|
+
// 'return true || false': two value sites, non-overlapping
|
|
99
|
+
const original = 'return true || false';
|
|
100
|
+
const v0 = makeVariant('f#0', 'return false || false'); // first 'true' → 'false'
|
|
101
|
+
const v1 = makeVariant('f#1', 'return true || true'); // second 'false' → 'true'
|
|
102
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
|
|
103
|
+
expect(fallbackIds.size).toBe(0);
|
|
104
|
+
expect(schemaCode).toContain("'f#0'");
|
|
105
|
+
expect(schemaCode).toContain("'f#1'");
|
|
106
|
+
});
|
|
107
|
+
it('marks outer expression as fallback when inner and outer sites overlap (nested binary)', () => {
|
|
108
|
+
// 'x + y - z': '+' → inner BinaryExpression 'x + y', '-' → outer 'x + y - z'
|
|
109
|
+
// Inner is kept in schema; outer (containing inner) is fallback.
|
|
110
|
+
const original = 'x + y - z';
|
|
111
|
+
const vInner = makeVariant('f#0', 'x - y - z'); // '+' → '-'
|
|
112
|
+
const vOuter = makeVariant('f#1', 'x + y + z'); // '-' → '+'
|
|
113
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
114
|
+
vInner,
|
|
115
|
+
vOuter,
|
|
116
|
+
]);
|
|
117
|
+
// Inner (x + y site) kept in schema, outer (x + y - z site) fallback
|
|
118
|
+
expect(fallbackIds.has('f#1')).toBe(true);
|
|
119
|
+
expect(fallbackIds.has('f#0')).toBe(false);
|
|
120
|
+
expect(schemaCode).toContain("'f#0'");
|
|
121
|
+
});
|
|
122
|
+
it('embeds two value-mutation variants on the same site', () => {
|
|
123
|
+
const orig2 = 'return true';
|
|
124
|
+
const vX = makeVariant('g#0', 'return false');
|
|
125
|
+
const vY = makeVariant('g#1', 'return null');
|
|
126
|
+
const { schemaCode: sc2, fallbackIds: fb2 } = generateSchema(orig2, [
|
|
127
|
+
vX,
|
|
128
|
+
vY,
|
|
129
|
+
]);
|
|
130
|
+
expect(fb2.size).toBe(0);
|
|
131
|
+
expect(sc2).toContain("'g#0'");
|
|
132
|
+
expect(sc2).toContain("'g#1'");
|
|
133
|
+
});
|
|
134
|
+
it('marks variant as fallback when diff produces empty range', () => {
|
|
135
|
+
// Variant identical to original → empty diff
|
|
136
|
+
const original = 'const x = 1';
|
|
137
|
+
const identical = makeVariant('f#0', 'const x = 1');
|
|
138
|
+
const { fallbackIds } = generateSchema(original, [identical]);
|
|
139
|
+
expect(fallbackIds.has('f#0')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
it('escapes single quotes in variant IDs', () => {
|
|
142
|
+
const original = 'return true';
|
|
143
|
+
const v = makeVariant("it's#0", 'return false');
|
|
144
|
+
const { schemaCode } = generateSchema(original, [v]);
|
|
145
|
+
expect(schemaCode).toContain("it\\'s#0");
|
|
146
|
+
});
|
|
147
|
+
it('handles multi-character replacements correctly', () => {
|
|
148
|
+
const original = 'return true';
|
|
149
|
+
const v = makeVariant('r#0', 'return false');
|
|
150
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [v]);
|
|
151
|
+
expect(fallbackIds.size).toBe(0);
|
|
152
|
+
expect(schemaCode).toContain("'r#0'");
|
|
153
|
+
expect(schemaCode).toMatch(/globalThis\.__mutineer_active_id__/);
|
|
154
|
+
});
|
|
155
|
+
it('merges variants into same site when word-boundary extension unifies their spans', () => {
|
|
156
|
+
// 'abcde' (all word chars): both variants extend to the full 5-char span [0,5]
|
|
157
|
+
const original = 'abcde';
|
|
158
|
+
const v0 = makeVariant('f#0', 'xyzde');
|
|
159
|
+
const v1 = makeVariant('f#1', 'aUVWe');
|
|
160
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
|
|
161
|
+
expect(fallbackIds.size).toBe(0);
|
|
162
|
+
expect(schemaCode).toContain("'f#0'");
|
|
163
|
+
expect(schemaCode).toContain("'f#1'");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Variant } from '../types/mutant.js';
|
|
2
|
+
export interface SchemaResult {
|
|
3
|
+
schemaCode: string;
|
|
4
|
+
fallbackIds: Set<string>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Generate a schema file that embeds all mutation variants as a ternary chain.
|
|
8
|
+
*
|
|
9
|
+
* The schema uses `globalThis.__mutineer_active_id__` at call time to select
|
|
10
|
+
* which mutant is active, avoiding per-mutant module reloads.
|
|
11
|
+
*
|
|
12
|
+
* For value mutations (true→false, null→undefined), a character-level diff with
|
|
13
|
+
* word-boundary extension finds the span. For operator mutations (+→-, ===→!==),
|
|
14
|
+
* the Babel AST is used to find the enclosing expression (x + y, x === y) so
|
|
15
|
+
* the ternary branch is a valid JS expression.
|
|
16
|
+
*
|
|
17
|
+
* @param originalCode - The original source file contents
|
|
18
|
+
* @param variants - All variants to embed (must all be from the same source file)
|
|
19
|
+
* @returns schemaCode (the embedded schema) and fallbackIds (variants that
|
|
20
|
+
* couldn't be embedded due to overlapping diff ranges or parse errors)
|
|
21
|
+
*/
|
|
22
|
+
export declare function generateSchema(originalCode: string, variants: readonly Variant[]): SchemaResult;
|