@mutineerjs/mutineer 0.9.0 → 0.10.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 +43 -38
- package/dist/bin/mutineer.d.ts +1 -1
- package/dist/bin/mutineer.js +3 -0
- package/dist/runner/__tests__/orchestrator.spec.js +35 -3
- package/dist/runner/jest/adapter.js +1 -0
- package/dist/runner/orchestrator.js +1 -0
- package/dist/runner/pool-executor.js +7 -12
- package/dist/runner/vitest/__tests__/adapter.spec.js +40 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +37 -0
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +60 -0
- package/dist/runner/vitest/adapter.js +7 -1
- package/dist/runner/vitest/pool.js +1 -0
- package/dist/runner/vitest/worker-runtime.js +14 -0
- package/dist/runner/vitest/worker.mjs +1 -0
- package/dist/types/mutant.d.ts +3 -0
- package/dist/utils/__tests__/summary.spec.js +22 -39
- 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.d.ts +0 -1
- package/dist/utils/__tests__/CompileErrors.spec.js +0 -96
package/README.md
CHANGED
|
@@ -15,10 +15,10 @@ Built for **Vitest** with first-class **Jest** support. Other test runners can b
|
|
|
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-deps`
|
|
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-deps` | Include dependents 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,23 +152,25 @@ 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
|
|
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-deps Mutate changed files + their local dependencies\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\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 --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
|
@@ -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
|
|
@@ -62,9 +62,7 @@ vi.mock('../ts-checker.js', () => ({
|
|
|
62
62
|
resolveTsconfigPath: vi.fn().mockReturnValue(undefined),
|
|
63
63
|
}));
|
|
64
64
|
vi.mock('../../core/schemata.js', () => ({
|
|
65
|
-
generateSchema: vi
|
|
66
|
-
.fn()
|
|
67
|
-
.mockReturnValue({
|
|
65
|
+
generateSchema: vi.fn().mockReturnValue({
|
|
68
66
|
schemaCode: '// @ts-nocheck\n',
|
|
69
67
|
fallbackIds: new Set(),
|
|
70
68
|
}),
|
|
@@ -334,6 +332,40 @@ describe('runOrchestrator shard filtering', () => {
|
|
|
334
332
|
expect(call.shard).toEqual({ index: 1, total: 4 });
|
|
335
333
|
});
|
|
336
334
|
});
|
|
335
|
+
describe('runOrchestrator target ordering', () => {
|
|
336
|
+
const testFile = '/cwd/src/__tests__/foo.spec.ts';
|
|
337
|
+
beforeEach(() => {
|
|
338
|
+
vi.clearAllMocks();
|
|
339
|
+
process.exitCode = undefined;
|
|
340
|
+
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
341
|
+
// No explicit targets -- use auto-discovery
|
|
342
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue({});
|
|
343
|
+
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
344
|
+
vi.mocked(enumerateAllVariants).mockResolvedValue([]);
|
|
345
|
+
vi.mocked(prepareTasks).mockReturnValue([]);
|
|
346
|
+
});
|
|
347
|
+
afterEach(() => {
|
|
348
|
+
process.exitCode = undefined;
|
|
349
|
+
});
|
|
350
|
+
it('sorts auto-discovered targets alphabetically before enumeration', async () => {
|
|
351
|
+
const fileA = '/cwd/src/aaa.ts';
|
|
352
|
+
const fileB = '/cwd/src/bbb.ts';
|
|
353
|
+
const fileC = '/cwd/src/ccc.ts';
|
|
354
|
+
// Return targets in reverse-alphabetical order (simulating non-deterministic fs)
|
|
355
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
356
|
+
targets: [fileC, fileA, fileB],
|
|
357
|
+
testMap: new Map([
|
|
358
|
+
[fileA, new Set([testFile])],
|
|
359
|
+
[fileB, new Set([testFile])],
|
|
360
|
+
[fileC, new Set([testFile])],
|
|
361
|
+
]),
|
|
362
|
+
directTestMap: new Map(),
|
|
363
|
+
});
|
|
364
|
+
await runOrchestrator([], '/cwd');
|
|
365
|
+
const call = vi.mocked(enumerateAllVariants).mock.calls[0][0];
|
|
366
|
+
expect(call.targets).toEqual([fileA, fileB, fileC]);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
337
369
|
describe('runOrchestrator --skip-baseline', () => {
|
|
338
370
|
const targetFile = '/cwd/src/foo.ts';
|
|
339
371
|
const testFile = '/cwd/src/__tests__/foo.spec.ts';
|
|
@@ -85,6 +85,7 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
85
85
|
: (cfg.autoDiscover ?? true)
|
|
86
86
|
? discovered.targets
|
|
87
87
|
: [];
|
|
88
|
+
targets.sort((a, b) => getTargetFile(a).localeCompare(getTargetFile(b)));
|
|
88
89
|
// Collect all test files for baseline run
|
|
89
90
|
const allTestFiles = new Set();
|
|
90
91
|
for (const target of targets) {
|
|
@@ -7,7 +7,6 @@ import { computeSummary, printSummary, buildJsonReport, } from '../utils/summary
|
|
|
7
7
|
import { saveCacheAtomic } from './cache.js';
|
|
8
8
|
import { cleanupMutineerDirs } from './cleanup.js';
|
|
9
9
|
import { PoolSpinner } from '../utils/PoolSpinner.js';
|
|
10
|
-
import { CompileErrors } from '../utils/CompileErrors.js';
|
|
11
10
|
import { createLogger } from '../utils/logger.js';
|
|
12
11
|
const log = createLogger('pool-executor');
|
|
13
12
|
/**
|
|
@@ -24,7 +23,7 @@ export async function executePool(opts) {
|
|
|
24
23
|
const mutationStartTime = Date.now();
|
|
25
24
|
// Ensure we only finish once
|
|
26
25
|
let finished = false;
|
|
27
|
-
const finishOnce = async (
|
|
26
|
+
const finishOnce = async () => {
|
|
28
27
|
if (finished)
|
|
29
28
|
return;
|
|
30
29
|
finished = true;
|
|
@@ -41,15 +40,7 @@ export async function executePool(opts) {
|
|
|
41
40
|
log.info(`JSON report written to ${path.relative(process.cwd(), outPath)}`);
|
|
42
41
|
}
|
|
43
42
|
else {
|
|
44
|
-
|
|
45
|
-
const useInteractive = interactive && process.stdout.isTTY && compileErrorEntries.length > 0;
|
|
46
|
-
printSummary(summary, cache, durationMs, {
|
|
47
|
-
skipCompileErrors: useInteractive,
|
|
48
|
-
});
|
|
49
|
-
if (useInteractive) {
|
|
50
|
-
const { waitUntilExit } = render(createElement(CompileErrors, { entries: compileErrorEntries, cwd }));
|
|
51
|
-
await waitUntilExit();
|
|
52
|
-
}
|
|
43
|
+
printSummary(summary, cache, durationMs);
|
|
53
44
|
}
|
|
54
45
|
if (opts.minKillPercent !== undefined) {
|
|
55
46
|
const killRateString = summary.killRate.toFixed(2);
|
|
@@ -151,6 +142,10 @@ export async function executePool(opts) {
|
|
|
151
142
|
(directTests ?? tests).length > 0 && {
|
|
152
143
|
coveringTests: directTests ?? tests,
|
|
153
144
|
}),
|
|
145
|
+
...(status === 'escaped' &&
|
|
146
|
+
result.passingTests?.length && {
|
|
147
|
+
passingTests: result.passingTests,
|
|
148
|
+
}),
|
|
154
149
|
};
|
|
155
150
|
progress.update(status);
|
|
156
151
|
}
|
|
@@ -170,7 +165,7 @@ export async function executePool(opts) {
|
|
|
170
165
|
return;
|
|
171
166
|
signalCleanedUp = true;
|
|
172
167
|
log.info(`\nReceived ${signal}, cleaning up...`);
|
|
173
|
-
await finishOnce(
|
|
168
|
+
await finishOnce();
|
|
174
169
|
await adapter.shutdown();
|
|
175
170
|
await cleanupMutineerDirs(cwd);
|
|
176
171
|
process.exit(1);
|
|
@@ -56,6 +56,30 @@ describe('Vitest adapter', () => {
|
|
|
56
56
|
const res = await adapter.runMutant({ id: '1', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
|
|
57
57
|
expect(res).toEqual({ status: 'killed', durationMs: 10, error: undefined });
|
|
58
58
|
});
|
|
59
|
+
it('includes passingTests in escaped result', async () => {
|
|
60
|
+
const adapter = makeAdapter();
|
|
61
|
+
await adapter.init();
|
|
62
|
+
poolInstance.run.mockResolvedValueOnce({
|
|
63
|
+
killed: false,
|
|
64
|
+
durationMs: 8,
|
|
65
|
+
passingTests: ['Suite > test one', 'Suite > test two'],
|
|
66
|
+
});
|
|
67
|
+
const res = await adapter.runMutant({ id: '2', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
|
|
68
|
+
expect(res.status).toBe('escaped');
|
|
69
|
+
expect(res.passingTests).toEqual(['Suite > test one', 'Suite > test two']);
|
|
70
|
+
});
|
|
71
|
+
it('omits passingTests for killed mutants', async () => {
|
|
72
|
+
const adapter = makeAdapter();
|
|
73
|
+
await adapter.init();
|
|
74
|
+
poolInstance.run.mockResolvedValueOnce({
|
|
75
|
+
killed: true,
|
|
76
|
+
durationMs: 5,
|
|
77
|
+
passingTests: ['should not appear'],
|
|
78
|
+
});
|
|
79
|
+
const res = await adapter.runMutant({ id: '3', name: 'm', file: 'f', code: 'c', line: 1, col: 1 }, ['t']);
|
|
80
|
+
expect(res.status).toBe('killed');
|
|
81
|
+
expect(res.passingTests).toBeUndefined();
|
|
82
|
+
});
|
|
59
83
|
it('maps pool timeout errors to timeout status', async () => {
|
|
60
84
|
const adapter = makeAdapter();
|
|
61
85
|
await adapter.init();
|
|
@@ -137,6 +161,22 @@ describe('Vitest adapter', () => {
|
|
|
137
161
|
const args = spawnMock.mock.calls[0][1];
|
|
138
162
|
expect(args.join(' ')).not.toContain('--shard');
|
|
139
163
|
});
|
|
164
|
+
it('strips --report flag and value from vitest args', async () => {
|
|
165
|
+
const adapter = makeAdapter({ cliArgs: ['--report', 'json'] });
|
|
166
|
+
spawnMock.mockImplementationOnce(() => ({
|
|
167
|
+
on: (evt, cb) => {
|
|
168
|
+
if (evt === 'exit')
|
|
169
|
+
cb(0);
|
|
170
|
+
},
|
|
171
|
+
}));
|
|
172
|
+
await adapter.runBaseline(['test-a'], {
|
|
173
|
+
collectCoverage: false,
|
|
174
|
+
perTestCoverage: false,
|
|
175
|
+
});
|
|
176
|
+
const args = spawnMock.mock.calls[0][1];
|
|
177
|
+
expect(args.join(' ')).not.toContain('--report');
|
|
178
|
+
expect(args.join(' ')).not.toContain('json');
|
|
179
|
+
});
|
|
140
180
|
it('does not write captured output to stdout/stderr on success', async () => {
|
|
141
181
|
const adapter = makeAdapter({ cliArgs: [] });
|
|
142
182
|
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
@@ -245,4 +245,41 @@ describe('VitestPool', () => {
|
|
|
245
245
|
expect(killSpy).toHaveBeenCalledWith(-42000, 'SIGKILL');
|
|
246
246
|
killSpy.mockRestore();
|
|
247
247
|
});
|
|
248
|
+
it('threads passingTests from WorkerMessage through to MutantRunSummary', async () => {
|
|
249
|
+
const pool = new VitestPool({
|
|
250
|
+
cwd: process.cwd(),
|
|
251
|
+
concurrency: 1,
|
|
252
|
+
timeoutMs: 5000,
|
|
253
|
+
createWorker: (id) => {
|
|
254
|
+
const worker = new EventEmitter();
|
|
255
|
+
worker.id = id;
|
|
256
|
+
worker.start = vi.fn().mockResolvedValue(undefined);
|
|
257
|
+
worker.isReady = vi.fn().mockReturnValue(true);
|
|
258
|
+
worker.isBusy = vi.fn().mockReturnValue(false);
|
|
259
|
+
worker.run = vi.fn().mockResolvedValue({
|
|
260
|
+
killed: false,
|
|
261
|
+
durationMs: 10,
|
|
262
|
+
passingTests: ['Suite > test one', 'Suite > test two'],
|
|
263
|
+
});
|
|
264
|
+
worker.shutdown = vi.fn().mockResolvedValue(undefined);
|
|
265
|
+
worker.kill = vi.fn();
|
|
266
|
+
return worker;
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
await pool.init();
|
|
270
|
+
const mutant = {
|
|
271
|
+
id: 'pt1',
|
|
272
|
+
name: 'mutant',
|
|
273
|
+
file: 'foo.ts',
|
|
274
|
+
code: 'x',
|
|
275
|
+
line: 1,
|
|
276
|
+
col: 1,
|
|
277
|
+
};
|
|
278
|
+
const result = await pool.run(mutant, ['foo.spec.ts']);
|
|
279
|
+
expect(result.passingTests).toEqual([
|
|
280
|
+
'Suite > test one',
|
|
281
|
+
'Suite > test two',
|
|
282
|
+
]);
|
|
283
|
+
await pool.shutdown();
|
|
284
|
+
});
|
|
248
285
|
});
|
|
@@ -125,6 +125,66 @@ describe('VitestWorkerRuntime', () => {
|
|
|
125
125
|
expect(result.error).toBe('string error');
|
|
126
126
|
await runtime.shutdown();
|
|
127
127
|
});
|
|
128
|
+
it('collects passingTests fullNames from modules when mutant escapes', async () => {
|
|
129
|
+
const makeModule = (moduleId, names) => ({
|
|
130
|
+
moduleId,
|
|
131
|
+
ok: () => true,
|
|
132
|
+
children: {
|
|
133
|
+
allTests: (_state) => names.map((n) => ({ fullName: n })),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
runSpecsFn.mockResolvedValue({
|
|
137
|
+
testModules: [
|
|
138
|
+
makeModule(path.join(os.tmpdir(), 'test.ts'), [
|
|
139
|
+
'Math > adds',
|
|
140
|
+
'Math > subtracts',
|
|
141
|
+
]),
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-pt-'));
|
|
145
|
+
tmpFiles.push(tmp);
|
|
146
|
+
const runtime = createVitestWorkerRuntime({ workerId: 'w-pt', cwd: tmp });
|
|
147
|
+
await runtime.init();
|
|
148
|
+
const result = await runtime.run({
|
|
149
|
+
id: 'mut#pt',
|
|
150
|
+
name: 'm',
|
|
151
|
+
file: path.join(tmp, 'src.ts'),
|
|
152
|
+
code: 'export const x=1',
|
|
153
|
+
line: 1,
|
|
154
|
+
col: 1,
|
|
155
|
+
}, [path.join(os.tmpdir(), 'test.ts')]);
|
|
156
|
+
expect(result.killed).toBe(false);
|
|
157
|
+
expect(result.passingTests).toEqual(['Math > adds', 'Math > subtracts']);
|
|
158
|
+
await runtime.shutdown();
|
|
159
|
+
});
|
|
160
|
+
it('omits passingTests when mutant is killed', async () => {
|
|
161
|
+
runSpecsFn.mockResolvedValue({
|
|
162
|
+
testModules: [
|
|
163
|
+
{
|
|
164
|
+
moduleId: path.join(os.tmpdir(), 'test.ts'),
|
|
165
|
+
ok: () => false,
|
|
166
|
+
children: {
|
|
167
|
+
allTests: (_state) => [{ fullName: 'Math > adds' }],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
});
|
|
172
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-kpt-'));
|
|
173
|
+
tmpFiles.push(tmp);
|
|
174
|
+
const runtime = createVitestWorkerRuntime({ workerId: 'w-kpt', cwd: tmp });
|
|
175
|
+
await runtime.init();
|
|
176
|
+
const result = await runtime.run({
|
|
177
|
+
id: 'mut#kpt',
|
|
178
|
+
name: 'm',
|
|
179
|
+
file: path.join(tmp, 'src.ts'),
|
|
180
|
+
code: 'export const x=1',
|
|
181
|
+
line: 1,
|
|
182
|
+
col: 1,
|
|
183
|
+
}, [path.join(os.tmpdir(), 'test.ts')]);
|
|
184
|
+
expect(result.killed).toBe(true);
|
|
185
|
+
expect(result.passingTests).toBeUndefined();
|
|
186
|
+
await runtime.shutdown();
|
|
187
|
+
});
|
|
128
188
|
it('falls back to all testModules when no relevant modules match', async () => {
|
|
129
189
|
runSpecsFn.mockResolvedValue({
|
|
130
190
|
testModules: [{ moduleId: 'unknown-module', ok: () => true }],
|
|
@@ -38,6 +38,7 @@ function stripMutineerArgs(args) {
|
|
|
38
38
|
'-c',
|
|
39
39
|
'--coverage-file',
|
|
40
40
|
'--shard',
|
|
41
|
+
'--report',
|
|
41
42
|
]);
|
|
42
43
|
const dropExact = new Set([
|
|
43
44
|
'-m',
|
|
@@ -190,10 +191,15 @@ export class VitestAdapter {
|
|
|
190
191
|
error: result.error,
|
|
191
192
|
};
|
|
192
193
|
}
|
|
194
|
+
const status = result.killed ? 'killed' : 'escaped';
|
|
193
195
|
return {
|
|
194
|
-
status
|
|
196
|
+
status,
|
|
195
197
|
durationMs: result.durationMs,
|
|
196
198
|
error: result.error,
|
|
199
|
+
...(!result.killed &&
|
|
200
|
+
result.passingTests && {
|
|
201
|
+
passingTests: result.passingTests,
|
|
202
|
+
}),
|
|
197
203
|
};
|
|
198
204
|
}
|
|
199
205
|
catch (err) {
|
|
@@ -105,9 +105,23 @@ export class VitestWorkerRuntime {
|
|
|
105
105
|
? relevantModules
|
|
106
106
|
: results.testModules;
|
|
107
107
|
const killed = modulesForDecision.some((mod) => !mod.ok());
|
|
108
|
+
const passingTests = [];
|
|
109
|
+
if (!killed) {
|
|
110
|
+
for (const mod of modulesForDecision) {
|
|
111
|
+
try {
|
|
112
|
+
for (const tc of mod.children?.allTests('passed') ?? []) {
|
|
113
|
+
passingTests.push(tc.fullName);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// allTests API unavailable in this Vitest version
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
108
121
|
return {
|
|
109
122
|
killed,
|
|
110
123
|
durationMs: Date.now() - start,
|
|
124
|
+
...(passingTests.length > 0 && { passingTests }),
|
|
111
125
|
};
|
|
112
126
|
}
|
|
113
127
|
catch (err) {
|
package/dist/types/mutant.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface MutantCacheEntry extends MutantLocation {
|
|
|
31
31
|
readonly originalSnippet?: string;
|
|
32
32
|
readonly mutatedSnippet?: string;
|
|
33
33
|
readonly coveringTests?: readonly string[];
|
|
34
|
+
readonly passingTests?: readonly string[];
|
|
34
35
|
}
|
|
35
36
|
export interface MutantResult extends MutantCacheEntry {
|
|
36
37
|
readonly id: string;
|
|
@@ -41,10 +42,12 @@ export interface MutantRunSummary {
|
|
|
41
42
|
readonly killed: boolean;
|
|
42
43
|
readonly durationMs: number;
|
|
43
44
|
readonly error?: string;
|
|
45
|
+
readonly passingTests?: readonly string[];
|
|
44
46
|
}
|
|
45
47
|
/** Normalised result returned by adapters/orchestrator. */
|
|
46
48
|
export interface MutantRunResult {
|
|
47
49
|
readonly status: MutantRunStatus;
|
|
48
50
|
readonly durationMs: number;
|
|
49
51
|
readonly error?: string;
|
|
52
|
+
readonly passingTests?: readonly string[];
|
|
50
53
|
}
|
|
@@ -123,6 +123,25 @@ describe('summary', () => {
|
|
|
123
123
|
expect(lines.some((l) => l.includes('foo.spec.ts'))).toBe(true);
|
|
124
124
|
logSpy.mockRestore();
|
|
125
125
|
});
|
|
126
|
+
it('buildJsonReport includes passingTests when present', () => {
|
|
127
|
+
const cache = {
|
|
128
|
+
a: makeEntry({
|
|
129
|
+
status: 'escaped',
|
|
130
|
+
file: '/tmp/a.ts',
|
|
131
|
+
mutator: 'flip',
|
|
132
|
+
passingTests: ['Suite > test one'],
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
const summary = computeSummary(cache);
|
|
136
|
+
const report = buildJsonReport(summary, cache);
|
|
137
|
+
expect(report.mutants[0].passingTests).toEqual(['Suite > test one']);
|
|
138
|
+
});
|
|
139
|
+
it('buildJsonReport omits passingTests when absent', () => {
|
|
140
|
+
const cache = { a: makeEntry({ status: 'escaped' }) };
|
|
141
|
+
const summary = computeSummary(cache);
|
|
142
|
+
const report = buildJsonReport(summary, cache);
|
|
143
|
+
expect('passingTests' in report.mutants[0]).toBe(false);
|
|
144
|
+
});
|
|
126
145
|
it('does not print covering tests when array is absent', () => {
|
|
127
146
|
const cache = {
|
|
128
147
|
a: makeEntry({ status: 'escaped' }),
|
|
@@ -176,49 +195,13 @@ describe('summary', () => {
|
|
|
176
195
|
expect('originalSnippet' in report.mutants[0]).toBe(false);
|
|
177
196
|
expect('coveringTests' in report.mutants[0]).toBe(false);
|
|
178
197
|
});
|
|
179
|
-
it('prints
|
|
180
|
-
const cache = {
|
|
181
|
-
a: makeEntry({
|
|
182
|
-
status: 'compile-error',
|
|
183
|
-
file: '/tmp/a.ts',
|
|
184
|
-
mutator: 'returnToNull',
|
|
185
|
-
}),
|
|
186
|
-
};
|
|
198
|
+
it('prints report hint line', () => {
|
|
199
|
+
const cache = { a: makeEntry({ status: 'killed' }) };
|
|
187
200
|
const summary = computeSummary(cache);
|
|
188
201
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
189
202
|
printSummary(summary, cache);
|
|
190
203
|
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
191
|
-
expect(lines.some((l) => l.includes('
|
|
192
|
-
logSpy.mockRestore();
|
|
193
|
-
});
|
|
194
|
-
it('skips compile error section when skipCompileErrors is true', () => {
|
|
195
|
-
const cache = {
|
|
196
|
-
a: makeEntry({
|
|
197
|
-
status: 'compile-error',
|
|
198
|
-
file: '/tmp/a.ts',
|
|
199
|
-
mutator: 'returnToNull',
|
|
200
|
-
}),
|
|
201
|
-
};
|
|
202
|
-
const summary = computeSummary(cache);
|
|
203
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
204
|
-
printSummary(summary, cache, undefined, { skipCompileErrors: true });
|
|
205
|
-
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
206
|
-
expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(false);
|
|
207
|
-
logSpy.mockRestore();
|
|
208
|
-
});
|
|
209
|
-
it('shows compile error section when skipCompileErrors is false', () => {
|
|
210
|
-
const cache = {
|
|
211
|
-
a: makeEntry({
|
|
212
|
-
status: 'compile-error',
|
|
213
|
-
file: '/tmp/a.ts',
|
|
214
|
-
mutator: 'returnToNull',
|
|
215
|
-
}),
|
|
216
|
-
};
|
|
217
|
-
const summary = computeSummary(cache);
|
|
218
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
219
|
-
printSummary(summary, cache, undefined, { skipCompileErrors: false });
|
|
220
|
-
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
221
|
-
expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(true);
|
|
204
|
+
expect(lines.some((l) => l.includes('Run with --report json to see full mutation details.'))).toBe(true);
|
|
222
205
|
logSpy.mockRestore();
|
|
223
206
|
});
|
|
224
207
|
it('summarise returns summary and prints', () => {
|
package/dist/utils/summary.d.ts
CHANGED
|
@@ -10,9 +10,7 @@ export interface Summary {
|
|
|
10
10
|
readonly killRate: number;
|
|
11
11
|
}
|
|
12
12
|
export declare function computeSummary(cache: Readonly<Record<string, MutantCacheEntry>>): Summary;
|
|
13
|
-
export declare function printSummary(summary: Summary, cache?: Readonly<Record<string, MutantCacheEntry>>, durationMs?: number
|
|
14
|
-
skipCompileErrors?: boolean;
|
|
15
|
-
}): void;
|
|
13
|
+
export declare function printSummary(summary: Summary, cache?: Readonly<Record<string, MutantCacheEntry>>, durationMs?: number): void;
|
|
16
14
|
export interface JsonMutant {
|
|
17
15
|
readonly file: string;
|
|
18
16
|
readonly line: number;
|
|
@@ -22,6 +20,7 @@ export interface JsonMutant {
|
|
|
22
20
|
readonly originalSnippet?: string;
|
|
23
21
|
readonly mutatedSnippet?: string;
|
|
24
22
|
readonly coveringTests?: readonly string[];
|
|
23
|
+
readonly passingTests?: readonly string[];
|
|
25
24
|
}
|
|
26
25
|
export interface JsonReport {
|
|
27
26
|
readonly schemaVersion: 1;
|
package/dist/utils/summary.js
CHANGED
|
@@ -44,7 +44,7 @@ function formatDuration(ms) {
|
|
|
44
44
|
const remainingSeconds = seconds % 60;
|
|
45
45
|
return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
|
|
46
46
|
}
|
|
47
|
-
export function printSummary(summary, cache, durationMs
|
|
47
|
+
export function printSummary(summary, cache, durationMs) {
|
|
48
48
|
console.log('\n' + chalk.dim(SEPARATOR));
|
|
49
49
|
console.log(chalk.bold(' Mutineer Test Suite Summary'));
|
|
50
50
|
console.log(chalk.dim(SEPARATOR));
|
|
@@ -113,11 +113,6 @@ export function printSummary(summary, cache, durationMs, opts) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
-
if (entriesByStatus.compileErrors.length && !opts?.skipCompileErrors) {
|
|
117
|
-
console.log('\n' + chalk.dim('Compile Error Mutants (type-filtered):'));
|
|
118
|
-
for (const entry of entriesByStatus.compileErrors)
|
|
119
|
-
console.log(' ' + formatRow(entry));
|
|
120
|
-
}
|
|
121
116
|
if (entriesByStatus.timeouts.length) {
|
|
122
117
|
console.log('\n' + chalk.yellow.bold('Timed Out Mutants:'));
|
|
123
118
|
for (const entry of entriesByStatus.timeouts)
|
|
@@ -148,6 +143,7 @@ export function printSummary(summary, cache, durationMs, opts) {
|
|
|
148
143
|
if (durationMs !== undefined) {
|
|
149
144
|
console.log(`Duration: ${chalk.cyan(formatDuration(durationMs))}`);
|
|
150
145
|
}
|
|
146
|
+
console.log(chalk.dim('Run with --report json to see full mutation details.'));
|
|
151
147
|
console.log(chalk.dim(SEPARATOR) + '\n');
|
|
152
148
|
}
|
|
153
149
|
export function buildJsonReport(summary, cache, durationMs) {
|
|
@@ -166,6 +162,9 @@ export function buildJsonReport(summary, cache, durationMs) {
|
|
|
166
162
|
...(entry.coveringTests !== undefined && {
|
|
167
163
|
coveringTests: entry.coveringTests,
|
|
168
164
|
}),
|
|
165
|
+
...(entry.passingTests !== undefined && {
|
|
166
|
+
passingTests: entry.passingTests,
|
|
167
|
+
}),
|
|
169
168
|
}));
|
|
170
169
|
return {
|
|
171
170
|
schemaVersion: 1,
|
package/package.json
CHANGED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { Box, Text, useInput, useApp } from 'ink';
|
|
4
|
-
import { useState, useEffect } from 'react';
|
|
5
|
-
export function CompileErrors({ entries, cwd }) {
|
|
6
|
-
const { exit } = useApp();
|
|
7
|
-
const [expanded, setExpanded] = useState(false);
|
|
8
|
-
useEffect(() => {
|
|
9
|
-
if (expanded)
|
|
10
|
-
exit();
|
|
11
|
-
}, [expanded, exit]);
|
|
12
|
-
useInput((input, key) => {
|
|
13
|
-
if (input === 'e') {
|
|
14
|
-
setExpanded(true);
|
|
15
|
-
}
|
|
16
|
-
else if (key.return || input === 'q') {
|
|
17
|
-
exit();
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
if (expanded) {
|
|
21
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Compile Error Mutants (type-filtered):" }), entries.map((entry, i) => (_jsxs(Text, { dimColor: true, children: [' \u2022 ', path.relative(cwd, entry.file), "@", entry.line, ",", entry.col, ' ', entry.mutator] }, i)))] }));
|
|
22
|
-
}
|
|
23
|
-
return (_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Compile Error Mutants (type-filtered): ", entries.length] }), _jsx(Text, { dimColor: true, children: "e expand return skip" })] }));
|
|
24
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
const mockExit = vi.fn();
|
|
3
|
-
const mockSetExpanded = vi.fn();
|
|
4
|
-
let inputHandler;
|
|
5
|
-
let effectCallback;
|
|
6
|
-
vi.mock('ink', () => ({
|
|
7
|
-
Box: ({ children }) => children,
|
|
8
|
-
Text: ({ children }) => children,
|
|
9
|
-
useInput: vi.fn((fn) => {
|
|
10
|
-
inputHandler = fn;
|
|
11
|
-
}),
|
|
12
|
-
useApp: () => ({ exit: mockExit }),
|
|
13
|
-
}));
|
|
14
|
-
vi.mock('react', async (importOriginal) => {
|
|
15
|
-
const actual = await importOriginal();
|
|
16
|
-
return {
|
|
17
|
-
...actual,
|
|
18
|
-
useState: vi.fn((init) => [init, mockSetExpanded]),
|
|
19
|
-
useEffect: vi.fn((fn) => {
|
|
20
|
-
effectCallback = fn;
|
|
21
|
-
}),
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
|
-
import { CompileErrors } from '../CompileErrors.js';
|
|
25
|
-
import { useState } from 'react';
|
|
26
|
-
const entries = [
|
|
27
|
-
{
|
|
28
|
-
status: 'compile-error',
|
|
29
|
-
file: '/cwd/src/foo.ts',
|
|
30
|
-
line: 10,
|
|
31
|
-
col: 5,
|
|
32
|
-
mutator: 'returnToNull',
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
status: 'compile-error',
|
|
36
|
-
file: '/cwd/src/bar.ts',
|
|
37
|
-
line: 20,
|
|
38
|
-
col: 3,
|
|
39
|
-
mutator: 'returnFlipBool',
|
|
40
|
-
},
|
|
41
|
-
];
|
|
42
|
-
describe('CompileErrors', () => {
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
mockExit.mockClear();
|
|
45
|
-
mockSetExpanded.mockClear();
|
|
46
|
-
inputHandler = undefined;
|
|
47
|
-
effectCallback = undefined;
|
|
48
|
-
vi.mocked(useState).mockImplementation(((init) => [
|
|
49
|
-
init,
|
|
50
|
-
mockSetExpanded,
|
|
51
|
-
]));
|
|
52
|
-
});
|
|
53
|
-
it('registers a useInput handler on render', () => {
|
|
54
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
55
|
-
expect(inputHandler).toBeDefined();
|
|
56
|
-
});
|
|
57
|
-
it('calls setExpanded(true) when "e" is pressed', () => {
|
|
58
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
59
|
-
inputHandler('e', { return: false });
|
|
60
|
-
expect(mockSetExpanded).toHaveBeenCalledWith(true);
|
|
61
|
-
});
|
|
62
|
-
it('calls exit() when return is pressed', () => {
|
|
63
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
64
|
-
inputHandler('', { return: true });
|
|
65
|
-
expect(mockExit).toHaveBeenCalled();
|
|
66
|
-
});
|
|
67
|
-
it('calls exit() when "q" is pressed', () => {
|
|
68
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
69
|
-
inputHandler('q', { return: false });
|
|
70
|
-
expect(mockExit).toHaveBeenCalled();
|
|
71
|
-
});
|
|
72
|
-
it('does not call exit() or setExpanded for other keys', () => {
|
|
73
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
74
|
-
inputHandler('x', { return: false });
|
|
75
|
-
expect(mockExit).not.toHaveBeenCalled();
|
|
76
|
-
expect(mockSetExpanded).not.toHaveBeenCalled();
|
|
77
|
-
});
|
|
78
|
-
it('registers a useEffect handler on render', () => {
|
|
79
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
80
|
-
expect(effectCallback).toBeDefined();
|
|
81
|
-
});
|
|
82
|
-
it('useEffect calls exit() when expanded is true', () => {
|
|
83
|
-
vi.mocked(useState).mockReturnValueOnce([
|
|
84
|
-
true,
|
|
85
|
-
mockSetExpanded,
|
|
86
|
-
]);
|
|
87
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
88
|
-
effectCallback();
|
|
89
|
-
expect(mockExit).toHaveBeenCalled();
|
|
90
|
-
});
|
|
91
|
-
it('useEffect does not call exit() when expanded is false', () => {
|
|
92
|
-
CompileErrors({ entries, cwd: '/cwd' });
|
|
93
|
-
effectCallback();
|
|
94
|
-
expect(mockExit).not.toHaveBeenCalled();
|
|
95
|
-
});
|
|
96
|
-
});
|