@mutineerjs/mutineer 0.8.0 → 0.9.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
@@ -100,6 +100,7 @@ npm run mutineer
100
100
  | `--concurrency <n>` | Parallel workers (min 1) | CPUs - 1 |
101
101
  | `--changed` | Only mutate files changed vs base branch | -- |
102
102
  | `--changed-with-deps` | Include dependents of changed files | -- |
103
+ | `--full` | Mutate full codebase, skipping confirmation prompt | -- |
103
104
  | `--only-covered-lines` | Skip mutations on uncovered lines | -- |
104
105
  | `--per-test-coverage` | Run only tests that cover the mutated line | -- |
105
106
  | `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
@@ -170,7 +171,7 @@ export default defineMutineerConfig({
170
171
 
171
172
  Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
172
173
 
173
- When you run `mutineer run` without `--changed` or `--changed-with-deps` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
174
+ 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
175
 
175
176
  ```
176
177
  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\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
@@ -62,7 +63,7 @@ Warning: Running on the full codebase may take a while.
62
63
  */
63
64
  export async function confirmFullRun(args) {
64
65
  const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-deps');
65
- if (!isFullRun || !process.stdin.isTTY)
66
+ if (!isFullRun || !process.stdin.isTTY || args.includes('--full'))
66
67
  return args;
67
68
  process.stdout.write(FULL_RUN_WARNING);
68
69
  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,
@@ -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();
@@ -33,6 +33,7 @@ function stripMutineerArgs(args) {
33
33
  '--mutate',
34
34
  '--changed',
35
35
  '--changed-with-deps',
36
+ '--full',
36
37
  '--only-covered-lines',
37
38
  '--per-test-coverage',
38
39
  '--perTestCoverage',
@@ -108,16 +109,43 @@ export class JestAdapter {
108
109
  ? 'baseline-with-coverage'
109
110
  : 'baseline';
110
111
  const cliOptions = buildJestCliOptions(tests, mode, this.jestConfigPath);
112
+ const stdoutChunks = [];
113
+ const stderrChunks = [];
114
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
115
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
116
+ // Temporarily capture stdout/stderr so Jest output is suppressed during baseline;
117
+ // captured output is replayed below on failure.
118
+ const makeCapture = (chunks) => function (chunk, encodingOrCb, cb) {
119
+ chunks.push(Buffer.isBuffer(chunk)
120
+ ? Buffer.from(chunk)
121
+ : Buffer.from(chunk));
122
+ const callback = (typeof encodingOrCb === 'function' ? encodingOrCb : cb);
123
+ callback?.();
124
+ return true;
125
+ };
126
+ process.stdout.write = makeCapture(stdoutChunks);
127
+ process.stderr.write = makeCapture(stderrChunks);
128
+ let success = false;
111
129
  try {
112
130
  const { runCLI } = await loadRunCLI(this.requireFromCwd);
113
131
  const { results } = await runCLI(cliOptions, [this.options.cwd]);
114
- return results.success;
132
+ success = results.success;
115
133
  }
116
134
  catch (err) {
117
135
  log.debug('Failed to run Jest baseline: ' +
118
136
  (err instanceof Error ? err.message : String(err)));
119
- return false;
120
137
  }
138
+ finally {
139
+ process.stdout.write = origStdoutWrite;
140
+ process.stderr.write = origStderrWrite;
141
+ }
142
+ if (!success) {
143
+ if (stdoutChunks.length)
144
+ process.stdout.write(Buffer.concat(stdoutChunks));
145
+ if (stderrChunks.length)
146
+ process.stderr.write(Buffer.concat(stderrChunks));
147
+ }
148
+ return success;
121
149
  }
122
150
  async runMutant(mutant, tests) {
123
151
  if (!this.pool) {
@@ -124,11 +124,24 @@ export async function runOrchestrator(cliArgs, cwd) {
124
124
  log.info('Skipping baseline tests (--skip-baseline)');
125
125
  }
126
126
  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
- });
127
+ const baselineMsg = `Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`;
128
+ let baselineSpinner = null;
129
+ if (process.stderr.isTTY) {
130
+ baselineSpinner = render(createElement(PoolSpinner, { message: baselineMsg }), { stdout: process.stderr, stderr: process.stderr });
131
+ }
132
+ else {
133
+ log.info(baselineMsg);
134
+ }
135
+ let baselineOk;
136
+ try {
137
+ baselineOk = await adapter.runBaseline(baselineTests, {
138
+ collectCoverage: coverage.enableCoverageForBaseline,
139
+ perTestCoverage: coverage.wantsPerTestCoverage,
140
+ });
141
+ }
142
+ finally {
143
+ baselineSpinner?.unmount();
144
+ }
132
145
  if (!baselineOk) {
133
146
  process.exitCode = 1;
134
147
  return;
@@ -137,6 +137,76 @@ describe('Vitest adapter', () => {
137
137
  const args = spawnMock.mock.calls[0][1];
138
138
  expect(args.join(' ')).not.toContain('--shard');
139
139
  });
140
+ it('does not write captured output to stdout/stderr on success', async () => {
141
+ const adapter = makeAdapter({ cliArgs: [] });
142
+ const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
143
+ const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
144
+ const listeners = {};
145
+ spawnMock.mockImplementationOnce(() => ({
146
+ stdout: {
147
+ on: (evt, cb) => {
148
+ ;
149
+ (listeners[`stdout:${evt}`] ??= []).push(cb);
150
+ },
151
+ },
152
+ stderr: {
153
+ on: (evt, cb) => {
154
+ ;
155
+ (listeners[`stderr:${evt}`] ??= []).push(cb);
156
+ },
157
+ },
158
+ on: (evt, cb) => {
159
+ if (evt === 'exit')
160
+ cb(0);
161
+ },
162
+ }));
163
+ const ok = await adapter.runBaseline(['test-a'], {
164
+ collectCoverage: false,
165
+ perTestCoverage: false,
166
+ });
167
+ expect(ok).toBe(true);
168
+ expect(stdoutWrite).not.toHaveBeenCalled();
169
+ expect(stderrWrite).not.toHaveBeenCalled();
170
+ stdoutWrite.mockRestore();
171
+ stderrWrite.mockRestore();
172
+ });
173
+ it('replays captured output to stdout/stderr on failure', async () => {
174
+ const adapter = makeAdapter({ cliArgs: [] });
175
+ const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
176
+ const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
177
+ const stdoutListeners = [];
178
+ const stderrListeners = [];
179
+ spawnMock.mockImplementationOnce(() => ({
180
+ stdout: {
181
+ on: (evt, cb) => {
182
+ if (evt === 'data')
183
+ stdoutListeners.push(cb);
184
+ },
185
+ },
186
+ stderr: {
187
+ on: (evt, cb) => {
188
+ if (evt === 'data')
189
+ stderrListeners.push(cb);
190
+ },
191
+ },
192
+ on: (evt, cb) => {
193
+ if (evt === 'exit') {
194
+ stdoutListeners.forEach((l) => l(Buffer.from('stdout output')));
195
+ stderrListeners.forEach((l) => l(Buffer.from('stderr output')));
196
+ cb(1);
197
+ }
198
+ },
199
+ }));
200
+ const ok = await adapter.runBaseline(['test-a'], {
201
+ collectCoverage: false,
202
+ perTestCoverage: false,
203
+ });
204
+ expect(ok).toBe(false);
205
+ expect(stdoutWrite).toHaveBeenCalledWith(Buffer.from('stdout output'));
206
+ expect(stderrWrite).toHaveBeenCalledWith(Buffer.from('stderr output'));
207
+ stdoutWrite.mockRestore();
208
+ stderrWrite.mockRestore();
209
+ });
140
210
  it('detects coverage config from vitest config file', async () => {
141
211
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-vitest-'));
142
212
  const cfgPath = path.join(tmp, 'vitest.config.ts');
@@ -44,6 +44,7 @@ function stripMutineerArgs(args) {
44
44
  '--mutate',
45
45
  '--changed',
46
46
  '--changed-with-deps',
47
+ '--full',
47
48
  '--only-covered-lines',
48
49
  '--per-test-coverage',
49
50
  '--perTestCoverage',
@@ -147,14 +148,24 @@ export class VitestAdapter {
147
148
  env.CI = '1';
148
149
  const child = spawn(process.execPath, [this.vitestPath, ...args, ...tests], {
149
150
  cwd: this.options.cwd,
150
- stdio: ['ignore', 'inherit', 'inherit'],
151
+ stdio: ['ignore', 'pipe', 'pipe'],
151
152
  env,
152
153
  });
154
+ const stdoutChunks = [];
155
+ const stderrChunks = [];
156
+ child.stdout?.on('data', (chunk) => stdoutChunks.push(chunk));
157
+ child.stderr?.on('data', (chunk) => stderrChunks.push(chunk));
153
158
  child.on('error', (err) => {
154
159
  log.debug('Failed to spawn vitest process: ' + err.message);
155
160
  resolve(false);
156
161
  });
157
162
  child.on('exit', (code) => {
163
+ if (code !== 0) {
164
+ if (stdoutChunks.length)
165
+ process.stdout.write(Buffer.concat(stdoutChunks));
166
+ if (stderrChunks.length)
167
+ process.stderr.write(Buffer.concat(stderrChunks));
168
+ }
158
169
  resolve(code === 0);
159
170
  });
160
171
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutineerjs/mutineer",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A fast, targeted mutation testing framework for JavaScript and TypeScript",
5
5
  "type": "module",
6
6
  "private": false,