@mutineerjs/mutineer 0.8.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 +44 -38
- package/dist/bin/__tests__/mutineer.spec.js +8 -0
- package/dist/bin/mutineer.d.ts +1 -1
- package/dist/bin/mutineer.js +5 -1
- package/dist/runner/__tests__/args.spec.js +8 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +1 -0
- package/dist/runner/__tests__/orchestrator.spec.js +35 -3
- package/dist/runner/args.d.ts +1 -0
- package/dist/runner/args.js +2 -0
- package/dist/runner/jest/__tests__/adapter.spec.js +49 -0
- package/dist/runner/jest/adapter.js +31 -2
- package/dist/runner/orchestrator.js +19 -5
- package/dist/runner/pool-executor.js +7 -12
- package/dist/runner/vitest/__tests__/adapter.spec.js +110 -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 +19 -2
- 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,22 +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
|
-
| `--
|
|
104
|
-
| `--
|
|
105
|
-
| `--coverage
|
|
106
|
-
| `--
|
|
107
|
-
| `--
|
|
108
|
-
| `--
|
|
109
|
-
| `--
|
|
110
|
-
| `--
|
|
111
|
-
| `--
|
|
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 | -- |
|
|
112
116
|
|
|
113
117
|
### Examples
|
|
114
118
|
|
|
@@ -148,29 +152,31 @@ export default defineMutineerConfig({
|
|
|
148
152
|
|
|
149
153
|
### Config Options
|
|
150
154
|
|
|
151
|
-
| Option | Type
|
|
152
|
-
| ------------------- |
|
|
153
|
-
| `source` | `string \| string[]`
|
|
154
|
-
| `targets` | `MutateTarget[]`
|
|
155
|
-
| `runner` | `'vitest' \| 'jest'`
|
|
156
|
-
| `vitestConfig` | `string`
|
|
157
|
-
| `jestConfig` | `string`
|
|
158
|
-
| `include` | `string[]`
|
|
159
|
-
| `exclude` | `string[]`
|
|
160
|
-
| `excludePaths` | `string[]`
|
|
161
|
-
| `maxMutantsPerFile` | `number`
|
|
162
|
-
| `minKillPercent` | `number`
|
|
163
|
-
| `onlyCoveredLines` | `boolean`
|
|
164
|
-
| `perTestCoverage` | `boolean`
|
|
165
|
-
| `baseRef` | `string`
|
|
166
|
-
| `testPatterns` | `string[]`
|
|
167
|
-
| `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 |
|
|
168
174
|
|
|
169
175
|
## Recommended Workflow
|
|
170
176
|
|
|
171
177
|
Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
|
|
172
178
|
|
|
173
|
-
When you run `mutineer run` without `--changed
|
|
179
|
+
When you run `mutineer run` without `--changed`, `--changed-with-deps`, or `--full` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
|
|
174
180
|
|
|
175
181
|
```
|
|
176
182
|
Warning: Running on the full codebase may take a while.
|
|
@@ -11,6 +11,7 @@ describe('HELP_TEXT', () => {
|
|
|
11
11
|
'--progress',
|
|
12
12
|
'--changed',
|
|
13
13
|
'--changed-with-deps',
|
|
14
|
+
'--full',
|
|
14
15
|
'--only-covered-lines',
|
|
15
16
|
'--per-test-coverage',
|
|
16
17
|
'--coverage-file',
|
|
@@ -61,6 +62,13 @@ describe('confirmFullRun()', () => {
|
|
|
61
62
|
const args = ['--changed-with-deps'];
|
|
62
63
|
expect(await confirmFullRun(args)).toBe(args);
|
|
63
64
|
});
|
|
65
|
+
it('returns args unchanged when --full is present, skipping prompt', async () => {
|
|
66
|
+
mockTTY(true);
|
|
67
|
+
const createSpy = vi.spyOn(readline, 'createInterface');
|
|
68
|
+
const args = ['--full'];
|
|
69
|
+
expect(await confirmFullRun(args)).toBe(args);
|
|
70
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
64
72
|
it('skips prompt and returns args unchanged when stdin is not a TTY', async () => {
|
|
65
73
|
mockTTY(false);
|
|
66
74
|
const createSpy = vi.spyOn(readline, 'createInterface');
|
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 --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
|
@@ -24,6 +24,7 @@ Options (run):
|
|
|
24
24
|
--progress <bar|list|quiet> Progress display (default: bar)
|
|
25
25
|
--changed Mutate only git-changed files
|
|
26
26
|
--changed-with-deps Mutate changed files + their local dependencies
|
|
27
|
+
--full Mutate full codebase, skipping confirmation prompt
|
|
27
28
|
--only-covered-lines Mutate only lines covered by tests
|
|
28
29
|
--per-test-coverage Collect per-test coverage data
|
|
29
30
|
--coverage-file <path> Path to coverage JSON
|
|
@@ -32,6 +33,9 @@ Options (run):
|
|
|
32
33
|
--report <text|json> Output format: text (default) or json (writes mutineer-report.json)
|
|
33
34
|
--shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)
|
|
34
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
|
|
35
39
|
|
|
36
40
|
--help, -h Show this help
|
|
37
41
|
--version, -V Show version
|
|
@@ -62,7 +66,7 @@ Warning: Running on the full codebase may take a while.
|
|
|
62
66
|
*/
|
|
63
67
|
export async function confirmFullRun(args) {
|
|
64
68
|
const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-deps');
|
|
65
|
-
if (!isFullRun || !process.stdin.isTTY)
|
|
69
|
+
if (!isFullRun || !process.stdin.isTTY || args.includes('--full'))
|
|
66
70
|
return args;
|
|
67
71
|
process.stdout.write(FULL_RUN_WARNING);
|
|
68
72
|
const rl = readline.createInterface({
|
|
@@ -131,6 +131,14 @@ describe('parseCliOptions', () => {
|
|
|
131
131
|
const opts = parseCliOptions(['--changed-with-deps'], emptyCfg);
|
|
132
132
|
expect(opts.wantsChangedWithDeps).toBe(true);
|
|
133
133
|
});
|
|
134
|
+
it('parses --full flag', () => {
|
|
135
|
+
const opts = parseCliOptions(['--full'], emptyCfg);
|
|
136
|
+
expect(opts.wantsFull).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
it('wantsFull defaults to false', () => {
|
|
139
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
140
|
+
expect(opts.wantsFull).toBe(false);
|
|
141
|
+
});
|
|
134
142
|
it('parses --only-covered-lines flag', () => {
|
|
135
143
|
const opts = parseCliOptions(['--only-covered-lines'], emptyCfg);
|
|
136
144
|
expect(opts.wantsOnlyCoveredLines).toBe(true);
|
|
@@ -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';
|
package/dist/runner/args.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface ParsedCliOptions {
|
|
|
12
12
|
readonly configPath: string | undefined;
|
|
13
13
|
readonly wantsChanged: boolean;
|
|
14
14
|
readonly wantsChangedWithDeps: boolean;
|
|
15
|
+
readonly wantsFull: boolean;
|
|
15
16
|
readonly wantsOnlyCoveredLines: boolean;
|
|
16
17
|
readonly wantsPerTestCoverage: boolean;
|
|
17
18
|
readonly coverageFilePath: string | undefined;
|
package/dist/runner/args.js
CHANGED
|
@@ -134,6 +134,7 @@ export function parseCliOptions(args, cfg) {
|
|
|
134
134
|
const configPath = readStringFlag(args, '--config', '-c');
|
|
135
135
|
const wantsChanged = args.includes('--changed');
|
|
136
136
|
const wantsChangedWithDeps = args.includes('--changed-with-deps');
|
|
137
|
+
const wantsFull = args.includes('--full');
|
|
137
138
|
const wantsOnlyCoveredLines = args.includes('--only-covered-lines') || cfg.onlyCoveredLines === true;
|
|
138
139
|
const wantsPerTestCoverage = args.includes('--per-test-coverage') || cfg.perTestCoverage === true;
|
|
139
140
|
const coverageFilePath = readStringFlag(args, '--coverage-file') ?? cfg.coverageFile;
|
|
@@ -163,6 +164,7 @@ export function parseCliOptions(args, cfg) {
|
|
|
163
164
|
configPath,
|
|
164
165
|
wantsChanged,
|
|
165
166
|
wantsChangedWithDeps,
|
|
167
|
+
wantsFull,
|
|
166
168
|
wantsOnlyCoveredLines,
|
|
167
169
|
wantsPerTestCoverage,
|
|
168
170
|
coverageFilePath,
|
|
@@ -61,6 +61,55 @@ describe('Jest adapter', () => {
|
|
|
61
61
|
expect(args.coverageProvider).toBe('v8');
|
|
62
62
|
expect(args.config).toBe('jest.config.ts');
|
|
63
63
|
});
|
|
64
|
+
it('does not write captured output to stdout/stderr on success', async () => {
|
|
65
|
+
const adapter = makeAdapter();
|
|
66
|
+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
67
|
+
const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
68
|
+
runCLIMock.mockResolvedValueOnce({ results: { success: true } });
|
|
69
|
+
const ok = await adapter.runBaseline(['test-a'], {
|
|
70
|
+
collectCoverage: false,
|
|
71
|
+
perTestCoverage: false,
|
|
72
|
+
});
|
|
73
|
+
expect(ok).toBe(true);
|
|
74
|
+
// No output should be replayed on success
|
|
75
|
+
expect(stdoutWrite).not.toHaveBeenCalled();
|
|
76
|
+
expect(stderrWrite).not.toHaveBeenCalled();
|
|
77
|
+
stdoutWrite.mockRestore();
|
|
78
|
+
stderrWrite.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
it('replays captured output to stdout/stderr on failure', async () => {
|
|
81
|
+
const adapter = makeAdapter();
|
|
82
|
+
// Spy before runBaseline so adapter's origStdoutWrite/origStderrWrite bind to the spy.
|
|
83
|
+
// runCLI writes during capture are suppressed; the spy sees only the replay after restore.
|
|
84
|
+
const writtenStdout = [];
|
|
85
|
+
const writtenStderr = [];
|
|
86
|
+
const stdoutSpy = vi
|
|
87
|
+
.spyOn(process.stdout, 'write')
|
|
88
|
+
.mockImplementation((chunk) => {
|
|
89
|
+
writtenStdout.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
const stderrSpy = vi
|
|
93
|
+
.spyOn(process.stderr, 'write')
|
|
94
|
+
.mockImplementation((chunk) => {
|
|
95
|
+
writtenStderr.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
runCLIMock.mockImplementationOnce(async () => {
|
|
99
|
+
process.stdout.write('jest stdout output');
|
|
100
|
+
process.stderr.write('jest stderr output');
|
|
101
|
+
return { results: { success: false } };
|
|
102
|
+
});
|
|
103
|
+
const ok = await adapter.runBaseline(['test-a'], {
|
|
104
|
+
collectCoverage: false,
|
|
105
|
+
perTestCoverage: false,
|
|
106
|
+
});
|
|
107
|
+
expect(ok).toBe(false);
|
|
108
|
+
expect(writtenStdout.join('')).toContain('jest stdout output');
|
|
109
|
+
expect(writtenStderr.join('')).toContain('jest stderr output');
|
|
110
|
+
stdoutSpy.mockRestore();
|
|
111
|
+
stderrSpy.mockRestore();
|
|
112
|
+
});
|
|
64
113
|
it('maps pool result to mutant status', async () => {
|
|
65
114
|
const adapter = makeAdapter();
|
|
66
115
|
await adapter.init();
|
|
@@ -27,12 +27,14 @@ function stripMutineerArgs(args) {
|
|
|
27
27
|
'-c',
|
|
28
28
|
'--coverage-file',
|
|
29
29
|
'--runner',
|
|
30
|
+
'--report',
|
|
30
31
|
]);
|
|
31
32
|
const dropExact = new Set([
|
|
32
33
|
'-m',
|
|
33
34
|
'--mutate',
|
|
34
35
|
'--changed',
|
|
35
36
|
'--changed-with-deps',
|
|
37
|
+
'--full',
|
|
36
38
|
'--only-covered-lines',
|
|
37
39
|
'--per-test-coverage',
|
|
38
40
|
'--perTestCoverage',
|
|
@@ -108,16 +110,43 @@ export class JestAdapter {
|
|
|
108
110
|
? 'baseline-with-coverage'
|
|
109
111
|
: 'baseline';
|
|
110
112
|
const cliOptions = buildJestCliOptions(tests, mode, this.jestConfigPath);
|
|
113
|
+
const stdoutChunks = [];
|
|
114
|
+
const stderrChunks = [];
|
|
115
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
116
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
117
|
+
// Temporarily capture stdout/stderr so Jest output is suppressed during baseline;
|
|
118
|
+
// captured output is replayed below on failure.
|
|
119
|
+
const makeCapture = (chunks) => function (chunk, encodingOrCb, cb) {
|
|
120
|
+
chunks.push(Buffer.isBuffer(chunk)
|
|
121
|
+
? Buffer.from(chunk)
|
|
122
|
+
: Buffer.from(chunk));
|
|
123
|
+
const callback = (typeof encodingOrCb === 'function' ? encodingOrCb : cb);
|
|
124
|
+
callback?.();
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
process.stdout.write = makeCapture(stdoutChunks);
|
|
128
|
+
process.stderr.write = makeCapture(stderrChunks);
|
|
129
|
+
let success = false;
|
|
111
130
|
try {
|
|
112
131
|
const { runCLI } = await loadRunCLI(this.requireFromCwd);
|
|
113
132
|
const { results } = await runCLI(cliOptions, [this.options.cwd]);
|
|
114
|
-
|
|
133
|
+
success = results.success;
|
|
115
134
|
}
|
|
116
135
|
catch (err) {
|
|
117
136
|
log.debug('Failed to run Jest baseline: ' +
|
|
118
137
|
(err instanceof Error ? err.message : String(err)));
|
|
119
|
-
return false;
|
|
120
138
|
}
|
|
139
|
+
finally {
|
|
140
|
+
process.stdout.write = origStdoutWrite;
|
|
141
|
+
process.stderr.write = origStderrWrite;
|
|
142
|
+
}
|
|
143
|
+
if (!success) {
|
|
144
|
+
if (stdoutChunks.length)
|
|
145
|
+
process.stdout.write(Buffer.concat(stdoutChunks));
|
|
146
|
+
if (stderrChunks.length)
|
|
147
|
+
process.stderr.write(Buffer.concat(stderrChunks));
|
|
148
|
+
}
|
|
149
|
+
return success;
|
|
121
150
|
}
|
|
122
151
|
async runMutant(mutant, tests) {
|
|
123
152
|
if (!this.pool) {
|
|
@@ -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) {
|
|
@@ -124,11 +125,24 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
124
125
|
log.info('Skipping baseline tests (--skip-baseline)');
|
|
125
126
|
}
|
|
126
127
|
else {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
128
|
+
const baselineMsg = `Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`;
|
|
129
|
+
let baselineSpinner = null;
|
|
130
|
+
if (process.stderr.isTTY) {
|
|
131
|
+
baselineSpinner = render(createElement(PoolSpinner, { message: baselineMsg }), { stdout: process.stderr, stderr: process.stderr });
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
log.info(baselineMsg);
|
|
135
|
+
}
|
|
136
|
+
let baselineOk;
|
|
137
|
+
try {
|
|
138
|
+
baselineOk = await adapter.runBaseline(baselineTests, {
|
|
139
|
+
collectCoverage: coverage.enableCoverageForBaseline,
|
|
140
|
+
perTestCoverage: coverage.wantsPerTestCoverage,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
baselineSpinner?.unmount();
|
|
145
|
+
}
|
|
132
146
|
if (!baselineOk) {
|
|
133
147
|
process.exitCode = 1;
|
|
134
148
|
return;
|
|
@@ -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,92 @@ 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
|
+
});
|
|
180
|
+
it('does not write captured output to stdout/stderr on success', async () => {
|
|
181
|
+
const adapter = makeAdapter({ cliArgs: [] });
|
|
182
|
+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
183
|
+
const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
184
|
+
const listeners = {};
|
|
185
|
+
spawnMock.mockImplementationOnce(() => ({
|
|
186
|
+
stdout: {
|
|
187
|
+
on: (evt, cb) => {
|
|
188
|
+
;
|
|
189
|
+
(listeners[`stdout:${evt}`] ??= []).push(cb);
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
stderr: {
|
|
193
|
+
on: (evt, cb) => {
|
|
194
|
+
;
|
|
195
|
+
(listeners[`stderr:${evt}`] ??= []).push(cb);
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
on: (evt, cb) => {
|
|
199
|
+
if (evt === 'exit')
|
|
200
|
+
cb(0);
|
|
201
|
+
},
|
|
202
|
+
}));
|
|
203
|
+
const ok = await adapter.runBaseline(['test-a'], {
|
|
204
|
+
collectCoverage: false,
|
|
205
|
+
perTestCoverage: false,
|
|
206
|
+
});
|
|
207
|
+
expect(ok).toBe(true);
|
|
208
|
+
expect(stdoutWrite).not.toHaveBeenCalled();
|
|
209
|
+
expect(stderrWrite).not.toHaveBeenCalled();
|
|
210
|
+
stdoutWrite.mockRestore();
|
|
211
|
+
stderrWrite.mockRestore();
|
|
212
|
+
});
|
|
213
|
+
it('replays captured output to stdout/stderr on failure', async () => {
|
|
214
|
+
const adapter = makeAdapter({ cliArgs: [] });
|
|
215
|
+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
216
|
+
const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
217
|
+
const stdoutListeners = [];
|
|
218
|
+
const stderrListeners = [];
|
|
219
|
+
spawnMock.mockImplementationOnce(() => ({
|
|
220
|
+
stdout: {
|
|
221
|
+
on: (evt, cb) => {
|
|
222
|
+
if (evt === 'data')
|
|
223
|
+
stdoutListeners.push(cb);
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
stderr: {
|
|
227
|
+
on: (evt, cb) => {
|
|
228
|
+
if (evt === 'data')
|
|
229
|
+
stderrListeners.push(cb);
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
on: (evt, cb) => {
|
|
233
|
+
if (evt === 'exit') {
|
|
234
|
+
stdoutListeners.forEach((l) => l(Buffer.from('stdout output')));
|
|
235
|
+
stderrListeners.forEach((l) => l(Buffer.from('stderr output')));
|
|
236
|
+
cb(1);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}));
|
|
240
|
+
const ok = await adapter.runBaseline(['test-a'], {
|
|
241
|
+
collectCoverage: false,
|
|
242
|
+
perTestCoverage: false,
|
|
243
|
+
});
|
|
244
|
+
expect(ok).toBe(false);
|
|
245
|
+
expect(stdoutWrite).toHaveBeenCalledWith(Buffer.from('stdout output'));
|
|
246
|
+
expect(stderrWrite).toHaveBeenCalledWith(Buffer.from('stderr output'));
|
|
247
|
+
stdoutWrite.mockRestore();
|
|
248
|
+
stderrWrite.mockRestore();
|
|
249
|
+
});
|
|
140
250
|
it('detects coverage config from vitest config file', async () => {
|
|
141
251
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-vitest-'));
|
|
142
252
|
const cfgPath = path.join(tmp, 'vitest.config.ts');
|
|
@@ -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,12 +38,14 @@ 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',
|
|
44
45
|
'--mutate',
|
|
45
46
|
'--changed',
|
|
46
47
|
'--changed-with-deps',
|
|
48
|
+
'--full',
|
|
47
49
|
'--only-covered-lines',
|
|
48
50
|
'--per-test-coverage',
|
|
49
51
|
'--perTestCoverage',
|
|
@@ -147,14 +149,24 @@ export class VitestAdapter {
|
|
|
147
149
|
env.CI = '1';
|
|
148
150
|
const child = spawn(process.execPath, [this.vitestPath, ...args, ...tests], {
|
|
149
151
|
cwd: this.options.cwd,
|
|
150
|
-
stdio: ['ignore', '
|
|
152
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
153
|
env,
|
|
152
154
|
});
|
|
155
|
+
const stdoutChunks = [];
|
|
156
|
+
const stderrChunks = [];
|
|
157
|
+
child.stdout?.on('data', (chunk) => stdoutChunks.push(chunk));
|
|
158
|
+
child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
|
|
153
159
|
child.on('error', (err) => {
|
|
154
160
|
log.debug('Failed to spawn vitest process: ' + err.message);
|
|
155
161
|
resolve(false);
|
|
156
162
|
});
|
|
157
163
|
child.on('exit', (code) => {
|
|
164
|
+
if (code !== 0) {
|
|
165
|
+
if (stdoutChunks.length)
|
|
166
|
+
process.stdout.write(Buffer.concat(stdoutChunks));
|
|
167
|
+
if (stderrChunks.length)
|
|
168
|
+
process.stderr.write(Buffer.concat(stderrChunks));
|
|
169
|
+
}
|
|
158
170
|
resolve(code === 0);
|
|
159
171
|
});
|
|
160
172
|
});
|
|
@@ -179,10 +191,15 @@ export class VitestAdapter {
|
|
|
179
191
|
error: result.error,
|
|
180
192
|
};
|
|
181
193
|
}
|
|
194
|
+
const status = result.killed ? 'killed' : 'escaped';
|
|
182
195
|
return {
|
|
183
|
-
status
|
|
196
|
+
status,
|
|
184
197
|
durationMs: result.durationMs,
|
|
185
198
|
error: result.error,
|
|
199
|
+
...(!result.killed &&
|
|
200
|
+
result.passingTests && {
|
|
201
|
+
passingTests: result.passingTests,
|
|
202
|
+
}),
|
|
186
203
|
};
|
|
187
204
|
}
|
|
188
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
|
-
});
|