@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 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** -- 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 and surfaced in an interactive UI
21
- 4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
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 | Description | Default |
97
- | ------------------------ | --------------------------------------------------------------- | ------------- |
98
- | `--runner <type>` | Test runner: `vitest` or `jest` | `vitest` |
99
- | `--config`, `-c` | Path to config file | auto-detected |
100
- | `--concurrency <n>` | Parallel workers (min 1) | CPUs - 1 |
101
- | `--changed` | Only mutate files changed vs base branch | -- |
102
- | `--changed-with-deps` | Include dependents of changed files | -- |
103
- | `--only-covered-lines` | Skip mutations on uncovered lines | -- |
104
- | `--per-test-coverage` | Run only tests that cover the mutated line | -- |
105
- | `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
106
- | `--min-kill-percent <n>` | Fail if kill rate is below threshold | -- |
107
- | `--progress <mode>` | Display mode: `bar`, `list`, or `quiet` | `bar` |
108
- | `--timeout <ms>` | Per-mutant test timeout | `30000` |
109
- | `--report <format>` | Output format: `text` or `json` (writes `mutineer-report.json`) | `text` |
110
- | `--shard <n>/<total>` | Run a slice of mutants (e.g. `--shard 1/4`) | -- |
111
- | `--skip-baseline` | Skip the baseline test run | -- |
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 | Description |
152
- | ------------------- | -------------------- | ------------------------------------------------ |
153
- | `source` | `string \| string[]` | Glob patterns for source files to mutate |
154
- | `targets` | `MutateTarget[]` | Explicit list of files to mutate |
155
- | `runner` | `'vitest' \| 'jest'` | Test runner to use |
156
- | `vitestConfig` | `string` | Path to vitest config |
157
- | `jestConfig` | `string` | Path to jest config |
158
- | `include` | `string[]` | Only run these mutators |
159
- | `exclude` | `string[]` | Skip these mutators |
160
- | `excludePaths` | `string[]` | Glob patterns for paths to skip |
161
- | `maxMutantsPerFile` | `number` | Cap mutations per file |
162
- | `minKillPercent` | `number` | Fail if kill rate is below this |
163
- | `onlyCoveredLines` | `boolean` | Only mutate lines covered by tests |
164
- | `perTestCoverage` | `boolean` | Use per-test coverage to select tests |
165
- | `baseRef` | `string` | Git ref for `--changed` (default: `origin/main`) |
166
- | `testPatterns` | `string[]` | Globs for test file discovery |
167
- | `extensions` | `string[]` | File extensions to consider |
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` or `--changed-with-deps` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
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');
@@ -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
@@ -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);
@@ -8,6 +8,7 @@ function makeOpts(overrides = {}) {
8
8
  configPath: undefined,
9
9
  wantsChanged: false,
10
10
  wantsChangedWithDeps: false,
11
+ wantsFull: false,
11
12
  wantsOnlyCoveredLines: false,
12
13
  wantsPerTestCoverage: false,
13
14
  coverageFilePath: undefined,
@@ -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';
@@ -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;
@@ -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
- return results.success;
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
- log.info(`Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
128
- const baselineOk = await adapter.runBaseline(baselineTests, {
129
- collectCoverage: coverage.enableCoverageForBaseline,
130
- perTestCoverage: coverage.wantsPerTestCoverage,
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 (interactive = true) => {
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
- const compileErrorEntries = Object.values(cache).filter((e) => e.status === 'compile-error');
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(false);
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', 'inherit', 'inherit'],
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: result.killed ? 'killed' : 'escaped',
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) {
@@ -150,6 +150,7 @@ class VitestWorker extends EventEmitter {
150
150
  killed: msg.killed ?? true,
151
151
  durationMs: msg.durationMs ?? 0,
152
152
  error: msg.error,
153
+ ...(msg.passingTests && { passingTests: msg.passingTests }),
153
154
  });
154
155
  }
155
156
  return;
@@ -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) {
@@ -82,6 +82,7 @@ async function main() {
82
82
  killed: result.killed,
83
83
  durationMs: result.durationMs,
84
84
  error: result.error,
85
+ ...(result.passingTests && { passingTests: result.passingTests }),
85
86
  });
86
87
  }
87
88
  catch (err) {
@@ -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 compile error mutants section by default', () => {
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('Compile Error Mutants'))).toBe(true);
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', () => {
@@ -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, opts?: {
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;
@@ -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, opts) {
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutineerjs/mutineer",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "A fast, targeted mutation testing framework for JavaScript and TypeScript",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,7 +0,0 @@
1
- import type { MutantCacheEntry } from '../types/mutant.js';
2
- interface Props {
3
- entries: MutantCacheEntry[];
4
- cwd: string;
5
- }
6
- export declare function CompileErrors({ entries, cwd }: Props): import("react/jsx-runtime").JSX.Element;
7
- export {};
@@ -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
- });