@mutineerjs/mutineer 0.9.0 → 0.11.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 +52 -47
- package/dist/__tests__/index.spec.js +8 -0
- package/dist/bin/__tests__/mutineer.spec.js +7 -7
- package/dist/bin/mutineer.d.ts +1 -1
- package/dist/bin/mutineer.js +7 -4
- package/dist/core/__tests__/schemata.spec.js +62 -0
- package/dist/core/__tests__/sfc.spec.js +41 -1
- package/dist/core/schemata.js +15 -21
- package/dist/core/sfc.js +0 -4
- package/dist/core/variant-utils.js +0 -4
- package/dist/mutators/__tests__/utils.spec.js +65 -1
- package/dist/mutators/operator.js +13 -27
- package/dist/mutators/return-value.js +3 -7
- package/dist/mutators/utils.d.ts +2 -2
- package/dist/mutators/utils.js +59 -96
- package/dist/runner/__tests__/args.spec.js +8 -4
- package/dist/runner/__tests__/cache.spec.js +24 -0
- package/dist/runner/__tests__/changed.spec.js +75 -0
- package/dist/runner/__tests__/config.spec.js +50 -1
- package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
- package/dist/runner/__tests__/discover.spec.js +179 -0
- package/dist/runner/__tests__/orchestrator.spec.js +336 -11
- package/dist/runner/__tests__/pool-executor.spec.js +77 -0
- package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
- package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
- package/dist/runner/__tests__/ts-checker.spec.js +89 -2
- package/dist/runner/args.d.ts +1 -1
- package/dist/runner/args.js +2 -2
- package/dist/runner/config.js +3 -4
- package/dist/runner/coverage-resolver.js +2 -1
- package/dist/runner/discover.js +2 -2
- package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
- package/dist/runner/jest/__tests__/pool.spec.js +223 -1
- package/dist/runner/jest/adapter.js +3 -45
- package/dist/runner/jest/pool.js +4 -10
- package/dist/runner/jest/worker-runtime.js +2 -1
- package/dist/runner/orchestrator.js +8 -7
- package/dist/runner/pool-executor.js +7 -12
- package/dist/runner/shared/__tests__/strip-mutineer-args.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/strip-mutineer-args.spec.js +104 -0
- package/dist/runner/shared/__tests__/worker-script.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/worker-script.spec.js +32 -0
- package/dist/runner/shared/index.d.ts +4 -0
- package/dist/runner/shared/index.js +2 -0
- package/dist/runner/shared/pending-task.d.ts +9 -0
- package/dist/runner/shared/pending-task.js +1 -0
- package/dist/runner/shared/strip-mutineer-args.d.ts +11 -0
- package/dist/runner/shared/strip-mutineer-args.js +47 -0
- package/dist/runner/shared/worker-script.d.ts +5 -0
- package/dist/runner/shared/worker-script.js +12 -0
- package/dist/runner/ts-checker-worker.d.ts +10 -1
- package/dist/runner/ts-checker-worker.js +27 -25
- package/dist/runner/ts-checker.d.ts +6 -0
- package/dist/runner/ts-checker.js +1 -1
- package/dist/runner/vitest/__tests__/adapter.spec.js +294 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
- package/dist/runner/vitest/__tests__/pool.spec.js +711 -0
- package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +81 -0
- package/dist/runner/vitest/adapter.js +14 -46
- package/dist/runner/vitest/plugin.js +1 -7
- package/dist/runner/vitest/pool.js +6 -19
- package/dist/runner/vitest/redirect-loader.js +3 -1
- package/dist/runner/vitest/worker-runtime.js +16 -1
- package/dist/runner/vitest/worker.mjs +1 -0
- package/dist/types/config.d.ts +2 -2
- package/dist/types/mutant.d.ts +3 -0
- package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
- package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
- package/dist/utils/__tests__/coverage.spec.js +89 -0
- package/dist/utils/__tests__/logger.spec.js +9 -0
- package/dist/utils/__tests__/progress.spec.js +38 -0
- package/dist/utils/__tests__/summary.spec.js +70 -31
- package/dist/utils/coverage.js +3 -4
- package/dist/utils/errors.d.ts +4 -0
- package/dist/utils/errors.js +6 -0
- package/dist/utils/summary.d.ts +2 -3
- package/dist/utils/summary.js +5 -6
- package/package.json +1 -1
- package/dist/utils/CompileErrors.d.ts +0 -7
- package/dist/utils/CompileErrors.js +0 -24
- package/dist/utils/__tests__/CompileErrors.spec.js +0 -96
- /package/dist/{utils/__tests__/CompileErrors.spec.d.ts → __tests__/index.spec.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
=== ~> !== · && ~> || · + ~> -
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Mutineer is a fast, targeted mutation testing framework for JavaScript and TypeScript. Mutineer introduces small code changes (mutations) into your source files and runs your existing tests to see if they catch the defect. If a test fails, the mutant is "killed"
|
|
12
|
+
Mutineer is a fast, targeted mutation testing framework for JavaScript and TypeScript. Mutineer introduces small code changes (mutations) into your source files and runs your existing tests to see if they catch the defect. If a test fails, the mutant is "killed" - meaning your tests are doing their job. If all tests pass, the mutant "escaped" - revealing a gap in your test coverage.
|
|
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
16
|
## How It Works
|
|
17
17
|
|
|
18
|
-
1. **Baseline**
|
|
19
|
-
2. **Mutate**
|
|
20
|
-
3. **Test**
|
|
21
|
-
4. **Report**
|
|
18
|
+
1. **Baseline** - runs your test suite to make sure everything passes before mutating
|
|
19
|
+
2. **Mutate** - applies AST-safe operator replacements to your source files (not your tests)
|
|
20
|
+
3. **Test** - re-runs only the tests that import the mutated file; compile errors are detected via parallel TypeScript workers
|
|
21
|
+
4. **Report** - prints a summary with kill rate, escaped mutants, and per-file breakdowns
|
|
22
22
|
|
|
23
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).
|
|
24
24
|
|
|
@@ -93,23 +93,26 @@ npm run mutineer
|
|
|
93
93
|
|
|
94
94
|
### CLI Options (for `mutineer run`)
|
|
95
95
|
|
|
96
|
-
| Flag
|
|
97
|
-
|
|
|
98
|
-
| `--runner <type>`
|
|
99
|
-
| `--config`, `-c`
|
|
100
|
-
| `--concurrency <n>`
|
|
101
|
-
| `--changed`
|
|
102
|
-
| `--changed-with-
|
|
103
|
-
| `--full`
|
|
104
|
-
| `--only-covered-lines`
|
|
105
|
-
| `--per-test-coverage`
|
|
106
|
-
| `--coverage-file <path>`
|
|
107
|
-
| `--min-kill-percent <n>`
|
|
108
|
-
| `--progress <mode>`
|
|
109
|
-
| `--timeout <ms>`
|
|
110
|
-
| `--report <format>`
|
|
111
|
-
| `--shard <n>/<total>`
|
|
112
|
-
| `--skip-baseline`
|
|
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-imports` | Include local imports of changed files | -- |
|
|
103
|
+
| `--full` | Mutate full codebase, skipping confirmation prompt | -- |
|
|
104
|
+
| `--only-covered-lines` | Skip mutations on uncovered lines | -- |
|
|
105
|
+
| `--per-test-coverage` | Run only tests that cover the mutated line | -- |
|
|
106
|
+
| `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
|
|
107
|
+
| `--min-kill-percent <n>` | Fail if kill rate is below threshold | -- |
|
|
108
|
+
| `--progress <mode>` | Display mode: `bar`, `list`, or `quiet` | `bar` |
|
|
109
|
+
| `--timeout <ms>` | Per-mutant test timeout | `30000` |
|
|
110
|
+
| `--report <format>` | Output format: `text` or `json` (writes `mutineer-report.json`) | `text` |
|
|
111
|
+
| `--shard <n>/<total>` | Run a slice of mutants (e.g. `--shard 1/4`) | -- |
|
|
112
|
+
| `--skip-baseline` | Skip the baseline test run | -- |
|
|
113
|
+
| `--vitest-project <name>` | Filter mutations to a specific Vitest workspace project (requires `test.projects`) | -- |
|
|
114
|
+
| `--typescript` | Enable TypeScript type-check pre-filtering (skips mutants that cause compile errors) | auto |
|
|
115
|
+
| `--no-typescript` | Disable TypeScript type-check pre-filtering | -- |
|
|
113
116
|
|
|
114
117
|
### Examples
|
|
115
118
|
|
|
@@ -149,53 +152,55 @@ export default defineMutineerConfig({
|
|
|
149
152
|
|
|
150
153
|
### Config Options
|
|
151
154
|
|
|
152
|
-
| Option | Type
|
|
153
|
-
| ------------------- |
|
|
154
|
-
| `source` | `string \| string[]`
|
|
155
|
-
| `targets` | `MutateTarget[]`
|
|
156
|
-
| `runner` | `'vitest' \| 'jest'`
|
|
157
|
-
| `vitestConfig` | `string`
|
|
158
|
-
| `jestConfig` | `string`
|
|
159
|
-
| `include` | `string[]`
|
|
160
|
-
| `exclude` | `string[]`
|
|
161
|
-
| `excludePaths` | `string[]`
|
|
162
|
-
| `maxMutantsPerFile` | `number`
|
|
163
|
-
| `minKillPercent` | `number`
|
|
164
|
-
| `onlyCoveredLines` | `boolean`
|
|
165
|
-
| `perTestCoverage` | `boolean`
|
|
166
|
-
| `baseRef` | `string`
|
|
167
|
-
| `testPatterns` | `string[]`
|
|
168
|
-
| `extensions` | `string[]`
|
|
155
|
+
| Option | Type | Description |
|
|
156
|
+
| ------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
|
|
157
|
+
| `source` | `string \| string[]` | Glob patterns for source files to mutate |
|
|
158
|
+
| `targets` | `MutateTarget[]` | Explicit list of files to mutate |
|
|
159
|
+
| `runner` | `'vitest' \| 'jest'` | Test runner to use |
|
|
160
|
+
| `vitestConfig` | `string` | Path to vitest config |
|
|
161
|
+
| `jestConfig` | `string` | Path to jest config |
|
|
162
|
+
| `include` | `string[]` | Only run these mutators |
|
|
163
|
+
| `exclude` | `string[]` | Skip these mutators |
|
|
164
|
+
| `excludePaths` | `string[]` | Glob patterns for paths to skip |
|
|
165
|
+
| `maxMutantsPerFile` | `number` | Cap mutations per file |
|
|
166
|
+
| `minKillPercent` | `number` | Fail if kill rate is below this |
|
|
167
|
+
| `onlyCoveredLines` | `boolean` | Only mutate lines covered by tests |
|
|
168
|
+
| `perTestCoverage` | `boolean` | Use per-test coverage to select tests |
|
|
169
|
+
| `baseRef` | `string` | Git ref for `--changed` (default: `origin/main`) |
|
|
170
|
+
| `testPatterns` | `string[]` | Globs for test file discovery |
|
|
171
|
+
| `extensions` | `string[]` | File extensions to consider |
|
|
172
|
+
| `vitestProject` | `string \| string[]` | Filter to a specific Vitest workspace project |
|
|
173
|
+
| `typescript` | `boolean \| { tsconfig?: string }` | Enable TS type-check pre-filtering; auto-detected if `tsconfig.json` present |
|
|
169
174
|
|
|
170
175
|
## Recommended Workflow
|
|
171
176
|
|
|
172
177
|
Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
|
|
173
178
|
|
|
174
|
-
When you run `mutineer run` without `--changed`, `--changed-with-
|
|
179
|
+
When you run `mutineer run` without `--changed`, `--changed-with-imports`, or `--full` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
|
|
175
180
|
|
|
176
181
|
```
|
|
177
182
|
Warning: Running on the full codebase may take a while.
|
|
178
183
|
|
|
179
184
|
[1] Continue (full codebase)
|
|
180
185
|
[2] --changed (git-changed files only)
|
|
181
|
-
[3] --changed-with-
|
|
186
|
+
[3] --changed-with-imports (changed + their local imports)
|
|
182
187
|
[4] Abort
|
|
183
188
|
```
|
|
184
189
|
|
|
185
|
-
### 1. PR-scoped runs (CI) — `--changed-with-
|
|
190
|
+
### 1. PR-scoped runs (CI) — `--changed-with-imports`
|
|
186
191
|
|
|
187
|
-
Run only on files changed in the branch plus their
|
|
192
|
+
Run only on files changed in the branch plus their local imports:
|
|
188
193
|
|
|
189
194
|
```bash
|
|
190
|
-
mutineer run --changed-with-
|
|
195
|
+
mutineer run --changed-with-imports
|
|
191
196
|
```
|
|
192
197
|
|
|
193
|
-
- Tune the
|
|
198
|
+
- Tune the import resolution depth with `importDepth` in config (default: `1`)
|
|
194
199
|
- Add `--per-test-coverage` to only run tests that cover the mutated line
|
|
195
200
|
- Recommended `package.json` script:
|
|
196
201
|
|
|
197
202
|
```json
|
|
198
|
-
"mutineer:ci": "mutineer run --changed-with-
|
|
203
|
+
"mutineer:ci": "mutineer run --changed-with-imports --per-test-coverage"
|
|
199
204
|
```
|
|
200
205
|
|
|
201
206
|
### 2. Split configs by domain
|
|
@@ -215,7 +220,7 @@ Each config sets its own `source` glob and `minKillPercent`. Good for monorepos
|
|
|
215
220
|
- Combine for maximum focus:
|
|
216
221
|
|
|
217
222
|
```bash
|
|
218
|
-
mutineer run --changed-with-
|
|
223
|
+
mutineer run --changed-with-imports --only-covered-lines --per-test-coverage
|
|
219
224
|
```
|
|
220
225
|
|
|
221
226
|
## File Support
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { defineMutineerConfig } from '../index.js';
|
|
3
|
+
describe('defineMutineerConfig', () => {
|
|
4
|
+
it('returns the config object unchanged', () => {
|
|
5
|
+
const cfg = { runner: 'vitest' };
|
|
6
|
+
expect(defineMutineerConfig(cfg)).toBe(cfg);
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -10,7 +10,7 @@ describe('HELP_TEXT', () => {
|
|
|
10
10
|
'--runner',
|
|
11
11
|
'--progress',
|
|
12
12
|
'--changed',
|
|
13
|
-
'--changed-with-
|
|
13
|
+
'--changed-with-imports',
|
|
14
14
|
'--full',
|
|
15
15
|
'--only-covered-lines',
|
|
16
16
|
'--per-test-coverage',
|
|
@@ -29,8 +29,8 @@ describe('HELP_TEXT', () => {
|
|
|
29
29
|
expect(HELP_TEXT).toContain('run');
|
|
30
30
|
expect(HELP_TEXT).toContain('clean');
|
|
31
31
|
});
|
|
32
|
-
it('--changed-with-
|
|
33
|
-
expect(HELP_TEXT).toContain('local
|
|
32
|
+
it('--changed-with-imports description mentions local imports', () => {
|
|
33
|
+
expect(HELP_TEXT).toContain('local imports');
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
36
|
describe('confirmFullRun()', () => {
|
|
@@ -57,9 +57,9 @@ describe('confirmFullRun()', () => {
|
|
|
57
57
|
const args = ['--changed', '--concurrency', '4'];
|
|
58
58
|
expect(await confirmFullRun(args)).toBe(args);
|
|
59
59
|
});
|
|
60
|
-
it('returns args unchanged when --changed-with-
|
|
60
|
+
it('returns args unchanged when --changed-with-imports is present', async () => {
|
|
61
61
|
mockTTY(true);
|
|
62
|
-
const args = ['--changed-with-
|
|
62
|
+
const args = ['--changed-with-imports'];
|
|
63
63
|
expect(await confirmFullRun(args)).toBe(args);
|
|
64
64
|
});
|
|
65
65
|
it('returns args unchanged when --full is present, skipping prompt', async () => {
|
|
@@ -93,10 +93,10 @@ describe('confirmFullRun()', () => {
|
|
|
93
93
|
mockReadline(['2']);
|
|
94
94
|
expect(await confirmFullRun([])).toEqual(['--changed']);
|
|
95
95
|
});
|
|
96
|
-
it('choice 3 appends --changed-with-
|
|
96
|
+
it('choice 3 appends --changed-with-imports', async () => {
|
|
97
97
|
mockTTY(true);
|
|
98
98
|
mockReadline(['3']);
|
|
99
|
-
expect(await confirmFullRun([])).toEqual(['--changed-with-
|
|
99
|
+
expect(await confirmFullRun([])).toEqual(['--changed-with-imports']);
|
|
100
100
|
});
|
|
101
101
|
it('invalid input re-prompts, then accepts valid choice', async () => {
|
|
102
102
|
mockTTY(true);
|
package/dist/bin/mutineer.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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-
|
|
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-imports Mutate changed files + their local imports\n --full Mutate full codebase, skipping confirmation prompt\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 --vitest-project <name> Filter to a specific Vitest workspace project\n --typescript Enable TS type-check pre-filtering\n --no-typescript Disable TS type-check pre-filtering\n\n --help, -h Show this help\n --version, -V Show version\n";
|
|
3
3
|
export declare function getVersion(): string;
|
|
4
4
|
/**
|
|
5
5
|
* When running in full-codebase mode on an interactive TTY, warn the user and
|
package/dist/bin/mutineer.js
CHANGED
|
@@ -23,7 +23,7 @@ Options (run):
|
|
|
23
23
|
--runner <vitest|jest> Test runner (default: vitest)
|
|
24
24
|
--progress <bar|list|quiet> Progress display (default: bar)
|
|
25
25
|
--changed Mutate only git-changed files
|
|
26
|
-
--changed-with-
|
|
26
|
+
--changed-with-imports Mutate changed files + their local imports
|
|
27
27
|
--full Mutate full codebase, skipping confirmation prompt
|
|
28
28
|
--only-covered-lines Mutate only lines covered by tests
|
|
29
29
|
--per-test-coverage Collect per-test coverage data
|
|
@@ -33,6 +33,9 @@ Options (run):
|
|
|
33
33
|
--report <text|json> Output format: text (default) or json (writes mutineer-report.json)
|
|
34
34
|
--shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)
|
|
35
35
|
--skip-baseline Skip the baseline test run
|
|
36
|
+
--vitest-project <name> Filter to a specific Vitest workspace project
|
|
37
|
+
--typescript Enable TS type-check pre-filtering
|
|
38
|
+
--no-typescript Disable TS type-check pre-filtering
|
|
36
39
|
|
|
37
40
|
--help, -h Show this help
|
|
38
41
|
--version, -V Show version
|
|
@@ -53,7 +56,7 @@ Warning: Running on the full codebase may take a while.
|
|
|
53
56
|
|
|
54
57
|
[1] Continue (full codebase)
|
|
55
58
|
[2] --changed (git-changed files only)
|
|
56
|
-
[3] --changed-with-
|
|
59
|
+
[3] --changed-with-imports (changed + their local imports)
|
|
57
60
|
[4] Abort
|
|
58
61
|
|
|
59
62
|
`;
|
|
@@ -62,7 +65,7 @@ Warning: Running on the full codebase may take a while.
|
|
|
62
65
|
* let them narrow scope or abort. Returns args (possibly with a flag appended).
|
|
63
66
|
*/
|
|
64
67
|
export async function confirmFullRun(args) {
|
|
65
|
-
const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-
|
|
68
|
+
const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-imports');
|
|
66
69
|
if (!isFullRun || !process.stdin.isTTY || args.includes('--full'))
|
|
67
70
|
return args;
|
|
68
71
|
process.stdout.write(FULL_RUN_WARNING);
|
|
@@ -84,7 +87,7 @@ export async function confirmFullRun(args) {
|
|
|
84
87
|
}
|
|
85
88
|
else if (choice === '3') {
|
|
86
89
|
rl.close();
|
|
87
|
-
resolve([...args, '--changed-with-
|
|
90
|
+
resolve([...args, '--changed-with-imports']);
|
|
88
91
|
}
|
|
89
92
|
else if (choice === '4') {
|
|
90
93
|
rl.close();
|
|
@@ -162,4 +162,66 @@ describe('generateSchema', () => {
|
|
|
162
162
|
expect(schemaCode).toContain("'f#0'");
|
|
163
163
|
expect(schemaCode).toContain("'f#1'");
|
|
164
164
|
});
|
|
165
|
+
it('finds the smallest enclosing expression when operator is nested inside a call', () => {
|
|
166
|
+
// CallExpression 'foo(x + y)' and BinaryExpression 'x + y' both contain the '+'
|
|
167
|
+
// offset. Walk visits outer (CallExpression) first — !best true — then inner
|
|
168
|
+
// (BinaryExpression) — !best false, span < best span — so best is updated to inner.
|
|
169
|
+
const original = 'foo(x + y)';
|
|
170
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
171
|
+
makeVariant('f#0', 'foo(x - y)'),
|
|
172
|
+
]);
|
|
173
|
+
expect(fallbackIds.size).toBe(0);
|
|
174
|
+
expect(schemaCode).toContain('x - y');
|
|
175
|
+
expect(schemaCode).toContain('x + y');
|
|
176
|
+
});
|
|
177
|
+
it('handles sparse array elements (null entries in Babel AST arrays) without crashing', () => {
|
|
178
|
+
// '[1,,a+b]' produces an ArrayExpression with a null element for the elision.
|
|
179
|
+
// The walk must skip the null entry and still find the BinaryExpression 'a+b'.
|
|
180
|
+
const original = '[1,,a+b]';
|
|
181
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
182
|
+
makeVariant('f#0', '[1,,a-b]'),
|
|
183
|
+
]);
|
|
184
|
+
expect(fallbackIds.size).toBe(0);
|
|
185
|
+
expect(schemaCode).toContain('a-b');
|
|
186
|
+
expect(schemaCode).toContain('a+b');
|
|
187
|
+
});
|
|
188
|
+
it('marks operator variant as fallback when original code cannot be parsed', () => {
|
|
189
|
+
// Unterminated brace causes Babel parse error → parseForSchema returns null
|
|
190
|
+
const original = 'const x = {';
|
|
191
|
+
const v = makeVariant('f#0', 'const x = |');
|
|
192
|
+
const { fallbackIds } = generateSchema(original, [v]);
|
|
193
|
+
expect(fallbackIds.has('f#0')).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
it('marks operator variant as fallback when operator is inside a comment (no enclosing expression)', () => {
|
|
196
|
+
// parseForSchema succeeds but findSmallestEnclosingExpression returns null
|
|
197
|
+
// because position 3 is inside a comment, not any AST expression node
|
|
198
|
+
const original = '// +\nconst x = 1';
|
|
199
|
+
const v = makeVariant('f#0', '// -\nconst x = 1');
|
|
200
|
+
const { fallbackIds } = generateSchema(original, [v]);
|
|
201
|
+
expect(fallbackIds.has('f#0')).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
it('marks outer site as fallback when inner site is fully contained within it', () => {
|
|
204
|
+
// '&&' maps to outer BinaryExpression [0,12]; '+' maps to inner 'b + c' [6,11]
|
|
205
|
+
// b.origEnd=11 <= a.origEnd=12 → outer marked fallback, inner kept
|
|
206
|
+
const original = 'a && (b + c)';
|
|
207
|
+
const vOuter = makeVariant('f#0', 'a || (b + c)'); // && → ||
|
|
208
|
+
const vInner = makeVariant('f#1', 'a && (b - c)'); // + → -
|
|
209
|
+
const { schemaCode, fallbackIds } = generateSchema(original, [
|
|
210
|
+
vOuter,
|
|
211
|
+
vInner,
|
|
212
|
+
]);
|
|
213
|
+
expect(fallbackIds.has('f#0')).toBe(true);
|
|
214
|
+
expect(fallbackIds.has('f#1')).toBe(false);
|
|
215
|
+
expect(schemaCode).not.toContain("'f#0'");
|
|
216
|
+
expect(schemaCode).toContain("'f#1'");
|
|
217
|
+
});
|
|
218
|
+
it('marks both sites as fallback when they partially overlap', () => {
|
|
219
|
+
// Sites [0:3] and [2:5] partially overlap (neither fully contains the other)
|
|
220
|
+
const original = 'a>b>c';
|
|
221
|
+
const v0 = makeVariant('f#0', 'X>Y>c'); // changes [0:3] (a>b → X>Y)
|
|
222
|
+
const v1 = makeVariant('f#1', 'a>B>C'); // changes [2:5] (b>c → B>C)
|
|
223
|
+
const { fallbackIds } = generateSchema(original, [v0, v1]);
|
|
224
|
+
expect(fallbackIds.has('f#0')).toBe(true);
|
|
225
|
+
expect(fallbackIds.has('f#1')).toBe(true);
|
|
226
|
+
});
|
|
165
227
|
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { mutateVueSfcScriptSetup } from '../sfc.js';
|
|
3
|
+
// Allow individual tests to override getFilteredRegistry
|
|
4
|
+
vi.mock('../variant-utils.js', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal();
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
getFilteredRegistry: vi.fn(actual.getFilteredRegistry),
|
|
9
|
+
};
|
|
10
|
+
});
|
|
3
11
|
// Mock @vue/compiler-sfc
|
|
4
12
|
vi.mock('@vue/compiler-sfc', () => ({
|
|
5
13
|
parse: (code, _opts) => {
|
|
@@ -66,9 +74,41 @@ describe('mutateVueSfcScriptSetup', () => {
|
|
|
66
74
|
expect(v.name).toBe('andToOr');
|
|
67
75
|
}
|
|
68
76
|
});
|
|
77
|
+
it('uses apply() fallback when mutator has no applyWithContext', async () => {
|
|
78
|
+
const { getFilteredRegistry } = await import('../variant-utils.js');
|
|
79
|
+
const applyFn = vi.fn(() => [{ code: 'FALLBACK', line: 1, col: 0 }]);
|
|
80
|
+
vi.mocked(getFilteredRegistry).mockReturnValueOnce([
|
|
81
|
+
{ name: 'noCtx', description: 'noCtx', apply: applyFn },
|
|
82
|
+
]);
|
|
83
|
+
const code = '<script setup>\nconst x = 1\n</script>';
|
|
84
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code);
|
|
85
|
+
expect(applyFn).toHaveBeenCalled();
|
|
86
|
+
expect(result.length).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
it('deduplicates when two mutators produce the same full SFC output', async () => {
|
|
89
|
+
const { getFilteredRegistry } = await import('../variant-utils.js');
|
|
90
|
+
vi.mocked(getFilteredRegistry).mockReturnValueOnce([
|
|
91
|
+
{
|
|
92
|
+
name: 'A',
|
|
93
|
+
description: 'A',
|
|
94
|
+
apply: () => [{ code: 'SAME_OUTPUT', line: 1, col: 0 }],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'B',
|
|
98
|
+
description: 'B',
|
|
99
|
+
apply: () => [{ code: 'SAME_OUTPUT', line: 1, col: 0 }],
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
const code = '<script setup>\nconst x = 1\n</script>';
|
|
103
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code);
|
|
104
|
+
// Both mutators produce the same output; only one variant should exist
|
|
105
|
+
expect(result.length).toBe(1);
|
|
106
|
+
});
|
|
69
107
|
it('filters mutators with exclude', async () => {
|
|
70
108
|
const code = '<script setup>\nconst x = a && b\n</script>';
|
|
71
|
-
const result = await mutateVueSfcScriptSetup('test.vue', code, undefined, [
|
|
109
|
+
const result = await mutateVueSfcScriptSetup('test.vue', code, undefined, [
|
|
110
|
+
'andToOr',
|
|
111
|
+
]);
|
|
72
112
|
for (const v of result) {
|
|
73
113
|
expect(v.name).not.toBe('andToOr');
|
|
74
114
|
}
|
package/dist/core/schemata.js
CHANGED
|
@@ -31,6 +31,15 @@ function findSingleDiff(original, mutated) {
|
|
|
31
31
|
}
|
|
32
32
|
return { origStart: start, origEnd, mutEnd };
|
|
33
33
|
}
|
|
34
|
+
const AST_SKIP_KEYS = new Set([
|
|
35
|
+
'type',
|
|
36
|
+
'start',
|
|
37
|
+
'end',
|
|
38
|
+
'loc',
|
|
39
|
+
'range',
|
|
40
|
+
'errors',
|
|
41
|
+
'operator', // skip operator — forces expansion to the full enclosing expression
|
|
42
|
+
]);
|
|
34
43
|
function escapeId(id) {
|
|
35
44
|
return id.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
36
45
|
}
|
|
@@ -62,13 +71,10 @@ function parseForSchema(code) {
|
|
|
62
71
|
function findSmallestEnclosingExpression(root, offset) {
|
|
63
72
|
let best = null;
|
|
64
73
|
function walk(node) {
|
|
65
|
-
|
|
66
|
-
typeof node.start !== 'number' ||
|
|
67
|
-
typeof node.end !== 'number') {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
74
|
+
// prune: offset is outside this node's span
|
|
70
75
|
if (offset < node.start || offset >= node.end)
|
|
71
76
|
return;
|
|
77
|
+
// track this node if it's an expression and smaller than current best
|
|
72
78
|
if (t.isExpression(node)) {
|
|
73
79
|
const span = node.end - node.start;
|
|
74
80
|
if (!best || span < best.end - best.start) {
|
|
@@ -76,26 +82,19 @@ function findSmallestEnclosingExpression(root, offset) {
|
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
84
|
for (const key of Object.keys(node)) {
|
|
79
|
-
if (key
|
|
80
|
-
key === 'start' ||
|
|
81
|
-
key === 'end' ||
|
|
82
|
-
key === 'loc' ||
|
|
83
|
-
key === 'range' ||
|
|
84
|
-
key === 'errors' ||
|
|
85
|
-
key === 'operator') {
|
|
85
|
+
if (AST_SKIP_KEYS.has(key))
|
|
86
86
|
continue;
|
|
87
|
-
}
|
|
88
87
|
const child = node[key];
|
|
89
88
|
if (!child || typeof child !== 'object')
|
|
90
89
|
continue;
|
|
90
|
+
// recurse into child node arrays (e.g. function arguments)
|
|
91
91
|
if (Array.isArray(child)) {
|
|
92
92
|
for (const item of child) {
|
|
93
|
-
if (item &&
|
|
94
|
-
typeof item === 'object' &&
|
|
95
|
-
typeof item.start === 'number') {
|
|
93
|
+
if (item && typeof item.start === 'number') {
|
|
96
94
|
walk(item);
|
|
97
95
|
}
|
|
98
96
|
}
|
|
97
|
+
// recurse into single child nodes (e.g. callee, left, right)
|
|
99
98
|
}
|
|
100
99
|
else if (typeof child.start === 'number') {
|
|
101
100
|
walk(child);
|
|
@@ -171,11 +170,6 @@ export function generateSchema(originalCode, variants) {
|
|
|
171
170
|
// shorter by delta = mutEnd - origEnd. Adjust the slice end accordingly.
|
|
172
171
|
const delta = mutEnd - origEnd;
|
|
173
172
|
replacement = variant.code.slice(siteStart, siteEnd + delta);
|
|
174
|
-
// Sanity: if replacement equals original span, the mutation had no effect
|
|
175
|
-
if (replacement === originalCode.slice(siteStart, siteEnd)) {
|
|
176
|
-
fallbackIds.add(variant.id);
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
173
|
}
|
|
180
174
|
const key = `${siteStart}:${siteEnd}`;
|
|
181
175
|
if (!siteMap.has(key)) {
|
package/dist/core/sfc.js
CHANGED
|
@@ -29,10 +29,6 @@ export async function mutateVueSfcScriptSetup(filename, code, include, exclude,
|
|
|
29
29
|
const seenOutputs = new Set();
|
|
30
30
|
const ctx = buildParseContext(originalBlock);
|
|
31
31
|
for (const mutator of registry) {
|
|
32
|
-
// Early termination if limit reached
|
|
33
|
-
if (max !== undefined && variants.length >= max) {
|
|
34
|
-
break;
|
|
35
|
-
}
|
|
36
32
|
const blockMutations = mutator.applyWithContext
|
|
37
33
|
? mutator.applyWithContext(originalBlock, ctx)
|
|
38
34
|
: mutator.apply(originalBlock);
|
|
@@ -20,10 +20,6 @@ export function generateMutationVariants(registry, code, opts = {}) {
|
|
|
20
20
|
const seen = new Set();
|
|
21
21
|
const ctx = buildParseContext(code);
|
|
22
22
|
for (const mutator of registry) {
|
|
23
|
-
// Early termination if limit reached
|
|
24
|
-
if (max !== undefined && variants.length >= max) {
|
|
25
|
-
break;
|
|
26
|
-
}
|
|
27
23
|
const mutations = mutator.applyWithContext
|
|
28
24
|
? mutator.applyWithContext(code, ctx)
|
|
29
25
|
: mutator.apply(code);
|
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { collectOperatorTargets, collectOperatorTargetsFromContext, collectAllTargets, buildIgnoreLines, buildParseContext, parseSource, } from '../utils.js';
|
|
2
|
+
import { collectOperatorTargets, collectOperatorTargetsFromContext, collectAllTargets, buildIgnoreLines, buildParseContext, parseSource, getVisualColumn, } from '../utils.js';
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// buildIgnoreLines
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// getVisualColumn
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
describe('getVisualColumn', () => {
|
|
10
|
+
it('returns 1 for offset at start of line', () => {
|
|
11
|
+
expect(getVisualColumn('abc', 0)).toBe(1);
|
|
12
|
+
});
|
|
13
|
+
it('advances to next tab stop for a tab character', () => {
|
|
14
|
+
// '\t' at column 0 (visualCol=1) advances to next stop: (floor(0/4)+1)*4+1 = 5
|
|
15
|
+
expect(getVisualColumn('\tx', 1)).toBe(5);
|
|
16
|
+
});
|
|
17
|
+
it('handles tab mid-line correctly', () => {
|
|
18
|
+
// 'ab\tc': charOffset=3 ('c'), linePrefix='ab\t'
|
|
19
|
+
// 'a' → visualCol=2, 'b' → 3, '\t' → nextStop=(floor(2/4)+1)*4+1=5 → visualCol=5
|
|
20
|
+
expect(getVisualColumn('ab\tc', 3)).toBe(5);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
6
23
|
describe('buildIgnoreLines', () => {
|
|
7
24
|
function comment(text, startLine, endLine = startLine) {
|
|
8
25
|
return {
|
|
@@ -48,6 +65,19 @@ describe('buildIgnoreLines', () => {
|
|
|
48
65
|
const lines = buildIgnoreLines([comment('just a normal comment', 1)]);
|
|
49
66
|
expect(lines.size).toBe(0);
|
|
50
67
|
});
|
|
68
|
+
it('skips comment with empty or whitespace-only text', () => {
|
|
69
|
+
const c = {
|
|
70
|
+
type: 'CommentLine',
|
|
71
|
+
value: ' ',
|
|
72
|
+
start: 0,
|
|
73
|
+
end: 0,
|
|
74
|
+
loc: {
|
|
75
|
+
start: { line: 1, column: 0, index: 0 },
|
|
76
|
+
end: { line: 1, column: 0, index: 0 },
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
expect(buildIgnoreLines([c]).size).toBe(0);
|
|
80
|
+
});
|
|
51
81
|
});
|
|
52
82
|
// ---------------------------------------------------------------------------
|
|
53
83
|
// parseSource
|
|
@@ -63,6 +93,10 @@ describe('parseSource', () => {
|
|
|
63
93
|
const ast = parseSource(`const x = 1`);
|
|
64
94
|
expect(ast.type).toBe('File');
|
|
65
95
|
});
|
|
96
|
+
it('falls back to Flow parser for Flow-specific nullable type syntax', () => {
|
|
97
|
+
// ?string is Flow syntax; Babel TS parser throws, falls back to Flow plugin
|
|
98
|
+
expect(() => parseSource('const x: ?string = null')).not.toThrow();
|
|
99
|
+
});
|
|
66
100
|
});
|
|
67
101
|
// ---------------------------------------------------------------------------
|
|
68
102
|
// buildParseContext
|
|
@@ -142,6 +176,36 @@ describe('collectAllTargets', () => {
|
|
|
142
176
|
const fromCtx = collectOperatorTargetsFromContext(src, ctx, '&&');
|
|
143
177
|
expect(result.operatorTargets.get('&&')).toEqual(fromCtx);
|
|
144
178
|
});
|
|
179
|
+
it('collects update expression targets', () => {
|
|
180
|
+
const src = `i++`;
|
|
181
|
+
const ctx = buildParseContext(src);
|
|
182
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
183
|
+
expect(result.updateTargets.size).toBeGreaterThan(0);
|
|
184
|
+
});
|
|
185
|
+
it('skips update expressions on ignored lines', () => {
|
|
186
|
+
const src = `// mutineer-disable-next-line\ni++`;
|
|
187
|
+
const ctx = buildParseContext(src);
|
|
188
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
189
|
+
expect(result.updateTargets.size).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
it('collects assignment expression targets', () => {
|
|
192
|
+
const src = `i += 1`;
|
|
193
|
+
const ctx = buildParseContext(src);
|
|
194
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
195
|
+
expect(result.assignmentTargets.size).toBeGreaterThan(0);
|
|
196
|
+
});
|
|
197
|
+
it('skips assignment expressions on ignored lines', () => {
|
|
198
|
+
const src = `// mutineer-disable-next-line\ni += 1`;
|
|
199
|
+
const ctx = buildParseContext(src);
|
|
200
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
201
|
+
expect(result.assignmentTargets.size).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
it('groups multiple assignment expressions of same operator together', () => {
|
|
204
|
+
const src = `i += 1; j += 2`;
|
|
205
|
+
const ctx = buildParseContext(src);
|
|
206
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
207
|
+
expect(result.assignmentTargets.get('+=')?.length).toBe(2);
|
|
208
|
+
});
|
|
145
209
|
});
|
|
146
210
|
// ---------------------------------------------------------------------------
|
|
147
211
|
// collectOperatorTargets / collectOperatorTargetsFromContext
|