@mutineerjs/mutineer 0.5.1 → 0.6.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
@@ -19,10 +19,10 @@ Built for **Vitest** with first-class **Jest** support. Other test runners can b
19
19
 
20
20
  1. **Baseline** -- runs your test suite to make sure everything passes before mutating
21
21
  2. **Mutate** -- applies AST-safe operator replacements to your source files (not your tests)
22
- 3. **Test** -- re-runs only the tests that import the mutated file, using a fast file-swap mechanism
22
+ 3. **Test** -- re-runs only the tests that import the mutated file, using temp files in `__mutineer__` dirs loaded via Vite plugin / Jest resolver
23
23
  4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
24
24
 
25
- Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is injected at runtime via Vite plugins (Vitest) or custom resolvers (Jest) -- no files on disk are modified.
25
+ 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).
26
26
 
27
27
  ## Supported Mutations (WIP)
28
28
 
@@ -164,6 +164,46 @@ export default defineMutineerConfig({
164
164
  | `testPatterns` | `string[]` | Globs for test file discovery |
165
165
  | `extensions` | `string[]` | File extensions to consider |
166
166
 
167
+ ## Recommended Workflow
168
+
169
+ Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
170
+
171
+ ### 1. PR-scoped runs (CI) — `--changed-with-deps`
172
+
173
+ Run only on files changed in the branch plus their direct dependents:
174
+
175
+ ```bash
176
+ mutineer run --changed-with-deps
177
+ ```
178
+
179
+ - Tune the dependency graph depth with `dependencyDepth` in config (default: `1`)
180
+ - Add `--per-test-coverage` to only run tests that cover the mutated line
181
+ - Recommended `package.json` script:
182
+
183
+ ```json
184
+ "mutineer:ci": "mutineer run --changed-with-deps --per-test-coverage"
185
+ ```
186
+
187
+ ### 2. Split configs by domain
188
+
189
+ Create a `mutineer.config.ts` per domain and run selectively:
190
+
191
+ ```bash
192
+ mutineer run -c src/api/mutineer.config.ts
193
+ ```
194
+
195
+ Each config sets its own `source` glob and `minKillPercent`. Good for monorepos or large modular projects — domains can also be parallelized in CI.
196
+
197
+ ### 3. Combine filters to reduce scope
198
+
199
+ - `--only-covered-lines` — skips lines not covered by any test (requires a coverage file)
200
+ - `maxMutantsPerFile` — caps mutations per file as a safety valve
201
+ - Combine for maximum focus:
202
+
203
+ ```bash
204
+ mutineer run --changed-with-deps --only-covered-lines --per-test-coverage
205
+ ```
206
+
167
207
  ## File Support
168
208
 
169
209
  - TypeScript and JavaScript modules (`.ts`, `.js`, `.tsx`, `.jsx`)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { createRequire } from 'node:module';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { HELP_TEXT, getVersion } from '../mutineer.js';
4
+ describe('HELP_TEXT', () => {
5
+ const flags = [
6
+ '--config',
7
+ '-c',
8
+ '--concurrency',
9
+ '--runner',
10
+ '--progress',
11
+ '--changed',
12
+ '--changed-with-deps',
13
+ '--only-covered-lines',
14
+ '--per-test-coverage',
15
+ '--coverage-file',
16
+ '--min-kill-percent',
17
+ '--help',
18
+ '-h',
19
+ '--version',
20
+ '-V',
21
+ ];
22
+ it.each(flags)('includes %s', (flag) => {
23
+ expect(HELP_TEXT).toContain(flag);
24
+ });
25
+ it('includes all three commands', () => {
26
+ expect(HELP_TEXT).toContain('init');
27
+ expect(HELP_TEXT).toContain('run');
28
+ expect(HELP_TEXT).toContain('clean');
29
+ });
30
+ it('--changed-with-deps description mentions local dependencies', () => {
31
+ expect(HELP_TEXT).toContain('local dependencies');
32
+ });
33
+ });
34
+ describe('getVersion()', () => {
35
+ it('returns a semver string', () => {
36
+ expect(getVersion()).toMatch(/^\d+\.\d+\.\d+/);
37
+ });
38
+ it('matches package.json version', () => {
39
+ const require = createRequire(import.meta.url);
40
+ const pkg = require('../../../package.json');
41
+ expect(getVersion()).toBe(pkg.version);
42
+ });
43
+ });
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
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\n --help, -h Show this help\n --version, -V Show version\n";
3
+ export declare function getVersion(): string;
@@ -1,12 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { createRequire } from 'node:module';
4
5
  import { runOrchestrator } from '../runner/orchestrator.js';
5
6
  import { cleanupMutineerDirs } from '../runner/cleanup.js';
6
7
  // Constants
7
8
  const RUN_COMMAND = 'run';
8
9
  const CLEAN_COMMAND = 'clean';
9
10
  const INIT_COMMAND = 'init';
11
+ export const HELP_TEXT = `\
12
+ Usage: mutineer <command> [options]
13
+
14
+ Commands:
15
+ init Create a mutineer.config.ts template
16
+ run Run mutation testing
17
+ clean Remove __mutineer__ temp directories
18
+
19
+ Options (run):
20
+ --config, -c <path> Config file path
21
+ --concurrency <n> Worker count (default: CPU count - 1)
22
+ --runner <vitest|jest> Test runner (default: vitest)
23
+ --progress <bar|list|quiet> Progress display (default: bar)
24
+ --changed Mutate only git-changed files
25
+ --changed-with-deps Mutate changed files + their local dependencies
26
+ --only-covered-lines Mutate only lines covered by tests
27
+ --per-test-coverage Collect per-test coverage data
28
+ --coverage-file <path> Path to coverage JSON
29
+ --min-kill-percent <n> Minimum kill % threshold (0–100)
30
+ --timeout <ms> Per-mutant test timeout in ms (default: 30000)
31
+
32
+ --help, -h Show this help
33
+ --version, -V Show version
34
+ `;
35
+ export function getVersion() {
36
+ const require = createRequire(import.meta.url);
37
+ return require('../../package.json').version;
38
+ }
10
39
  const CONFIG_TEMPLATE = `\
11
40
  import { defineMutineerConfig } from 'mutineer'
12
41
 
@@ -19,7 +48,19 @@ export default defineMutineerConfig({
19
48
  */
20
49
  async function main() {
21
50
  const args = process.argv.slice(2);
51
+ if (args[0] === '--help' || args[0] === '-h') {
52
+ process.stdout.write(HELP_TEXT);
53
+ process.exit(0);
54
+ }
55
+ if (args[0] === '--version' || args[0] === '-V') {
56
+ console.log(getVersion());
57
+ process.exit(0);
58
+ }
22
59
  if (args[0] === RUN_COMMAND) {
60
+ if (args.includes('--help') || args.includes('-h')) {
61
+ process.stdout.write(HELP_TEXT);
62
+ process.exit(0);
63
+ }
23
64
  await runOrchestrator(args.slice(1), process.cwd());
24
65
  }
25
66
  else if (args[0] === INIT_COMMAND) {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
2
+ import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, validatePositiveMs, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
3
3
  describe('parseFlagNumber', () => {
4
4
  it('parses valid integers', () => {
5
5
  expect(parseFlagNumber('42', '--flag')).toBe(42);
@@ -202,6 +202,50 @@ describe('parseCliOptions', () => {
202
202
  it('rejects invalid --min-kill-percent', () => {
203
203
  expect(() => parseCliOptions(['--min-kill-percent', '150'], emptyCfg)).toThrow('expected value between 0 and 100');
204
204
  });
205
+ it('parses --timeout flag', () => {
206
+ const opts = parseCliOptions(['--timeout', '5000'], emptyCfg);
207
+ expect(opts.timeout).toBe(5000);
208
+ });
209
+ it('parses --timeout with = syntax', () => {
210
+ const opts = parseCliOptions(['--timeout=5000'], emptyCfg);
211
+ expect(opts.timeout).toBe(5000);
212
+ });
213
+ it('returns undefined timeout when flag absent', () => {
214
+ const opts = parseCliOptions([], emptyCfg);
215
+ expect(opts.timeout).toBeUndefined();
216
+ });
217
+ it('config timeout does not affect opts.timeout (resolved in orchestrator)', () => {
218
+ const opts = parseCliOptions([], { timeout: 10000 });
219
+ expect(opts.timeout).toBeUndefined();
220
+ });
221
+ it('rejects --timeout 0', () => {
222
+ expect(() => parseCliOptions(['--timeout', '0'], emptyCfg)).toThrow('expected a positive number');
223
+ });
224
+ it('rejects --timeout -1', () => {
225
+ expect(() => parseCliOptions(['--timeout', '-1'], emptyCfg)).toThrow('expected a positive number');
226
+ });
227
+ it('rejects --timeout abc', () => {
228
+ expect(() => parseCliOptions(['--timeout', 'abc'], emptyCfg)).toThrow('Invalid value for --timeout: abc');
229
+ });
230
+ });
231
+ describe('validatePositiveMs', () => {
232
+ it('returns undefined for undefined', () => {
233
+ expect(validatePositiveMs(undefined, 'test')).toBeUndefined();
234
+ });
235
+ it('returns the value for a positive number', () => {
236
+ expect(validatePositiveMs(5000, '--timeout')).toBe(5000);
237
+ expect(validatePositiveMs(1, '--timeout')).toBe(1);
238
+ });
239
+ it('throws for zero', () => {
240
+ expect(() => validatePositiveMs(0, '--timeout')).toThrow('Invalid --timeout: expected a positive number (received 0)');
241
+ });
242
+ it('throws for negative values', () => {
243
+ expect(() => validatePositiveMs(-1, '--timeout')).toThrow('Invalid --timeout: expected a positive number (received -1)');
244
+ });
245
+ it('throws for non-finite values', () => {
246
+ expect(() => validatePositiveMs(Infinity, '--timeout')).toThrow('Invalid --timeout: expected a positive number');
247
+ expect(() => validatePositiveMs(NaN, '--timeout')).toThrow('Invalid --timeout: expected a positive number');
248
+ });
205
249
  });
206
250
  describe('extractConfigPath', () => {
207
251
  it('extracts --config with separate value', () => {
@@ -145,11 +145,94 @@ describe('listChangedFiles', () => {
145
145
  }
146
146
  return { status: 0, stdout: '' };
147
147
  });
148
- // File with imports - the import resolution will fail (no real files)
149
- // but it exercises the parsing code paths
150
148
  readFileSyncMock.mockReturnValue('import { bar } from "./bar"\nexport { baz } from "./baz"\nconst x = require("./qux")');
149
+ existsSyncMock.mockImplementation((p) => {
150
+ // Changed file and .ts variants of deps exist
151
+ return [
152
+ '/repo/src/foo.ts',
153
+ '/repo/src/bar.ts',
154
+ '/repo/src/baz.ts',
155
+ '/repo/src/qux.ts',
156
+ ].includes(p);
157
+ });
151
158
  const result = listChangedFiles('/repo', { includeDeps: true });
152
159
  expect(result).toContain('/repo/src/foo.ts');
160
+ expect(result).toContain('/repo/src/bar.ts');
161
+ expect(result).toContain('/repo/src/baz.ts');
162
+ expect(result).toContain('/repo/src/qux.ts');
163
+ });
164
+ it('resolves dep with .ts extension when imported without extension', () => {
165
+ spawnSyncMock.mockImplementation((_cmd, args) => {
166
+ if (args.includes('--show-toplevel'))
167
+ return { status: 0, stdout: '/repo\n' };
168
+ if (args.includes('main...HEAD'))
169
+ return { status: 0, stdout: 'src/foo.ts\0' };
170
+ return { status: 0, stdout: '' };
171
+ });
172
+ readFileSyncMock.mockReturnValue('import { x } from "./utils"');
173
+ existsSyncMock.mockImplementation((p) => {
174
+ return ['/repo/src/foo.ts', '/repo/src/utils.ts'].includes(p);
175
+ });
176
+ const result = listChangedFiles('/repo', { includeDeps: true });
177
+ expect(result).toContain('/repo/src/utils.ts');
178
+ });
179
+ it('excludes deps outside cwd', () => {
180
+ spawnSyncMock.mockImplementation((_cmd, args) => {
181
+ if (args.includes('--show-toplevel'))
182
+ return { status: 0, stdout: '/repo\n' };
183
+ if (args.includes('main...HEAD'))
184
+ return { status: 0, stdout: 'src/foo.ts\0' };
185
+ return { status: 0, stdout: '' };
186
+ });
187
+ readFileSyncMock.mockReturnValue('import { x } from "../../outside/dep"');
188
+ existsSyncMock.mockImplementation((p) => {
189
+ return ['/repo/src/foo.ts', '/outside/dep.ts'].includes(p);
190
+ });
191
+ const result = listChangedFiles('/repo', { includeDeps: true });
192
+ expect(result).not.toContain('/outside/dep.ts');
193
+ expect(result).toHaveLength(1);
194
+ });
195
+ it('excludes deps inside node_modules', () => {
196
+ spawnSyncMock.mockImplementation((_cmd, args) => {
197
+ if (args.includes('--show-toplevel'))
198
+ return { status: 0, stdout: '/repo\n' };
199
+ if (args.includes('main...HEAD'))
200
+ return { status: 0, stdout: 'src/foo.ts\0' };
201
+ return { status: 0, stdout: '' };
202
+ });
203
+ readFileSyncMock.mockReturnValue('import { x } from "./node_modules/pkg/index"');
204
+ existsSyncMock.mockImplementation((p) => {
205
+ return p === '/repo/src/foo.ts' || p.includes('node_modules');
206
+ });
207
+ const result = listChangedFiles('/repo', { includeDeps: true });
208
+ expect(result.some((p) => p.includes('node_modules'))).toBe(false);
209
+ });
210
+ it('resolves direct dep but not transitive dep at maxDepth=1', () => {
211
+ spawnSyncMock.mockImplementation((_cmd, args) => {
212
+ if (args.includes('--show-toplevel'))
213
+ return { status: 0, stdout: '/repo\n' };
214
+ if (args.includes('main...HEAD'))
215
+ return { status: 0, stdout: 'src/foo.ts\0' };
216
+ return { status: 0, stdout: '' };
217
+ });
218
+ readFileSyncMock.mockImplementation((p) => {
219
+ if (p === '/repo/src/foo.ts')
220
+ return 'import { x } from "./bar"';
221
+ if (p === '/repo/src/bar.ts')
222
+ return 'import { y } from "./baz"';
223
+ return '';
224
+ });
225
+ existsSyncMock.mockImplementation((p) => {
226
+ return [
227
+ '/repo/src/foo.ts',
228
+ '/repo/src/bar.ts',
229
+ '/repo/src/baz.ts',
230
+ ].includes(p);
231
+ });
232
+ const result = listChangedFiles('/repo', { includeDeps: true, maxDepth: 1 });
233
+ expect(result).toContain('/repo/src/foo.ts');
234
+ expect(result).toContain('/repo/src/bar.ts');
235
+ expect(result).not.toContain('/repo/src/baz.ts');
153
236
  });
154
237
  it('skips non-local imports in dependency resolution', () => {
155
238
  spawnSyncMock.mockImplementation((_cmd, args) => {
@@ -52,20 +52,9 @@ describe('loadMutineerConfig', () => {
52
52
  await fs.writeFile(configFile, '??? not valid javascript ???');
53
53
  await expect(loadMutineerConfig(tmpDir)).rejects.toThrow(/Failed to load config from/);
54
54
  });
55
- // BUG: Two bugs compound here:
56
- // 1. validateConfig uses `&&` instead of `||`: `typeof config !== 'object' && config === null`
57
- // Since typeof null === 'object', this condition is always false, so null passes validation.
58
- // 2. loadModule uses `||` instead of `??`: `mod.default || mod`
59
- // When default export is null (falsy), it falls back to the module namespace object.
60
- // Together: null configs pass validation AND get returned as the module namespace.
61
- it('BUG: null config passes validation due to && vs || logic error', async () => {
55
+ it('throws when config exports null', async () => {
62
56
  const configFile = path.join(tmpDir, 'mutineer.config.mjs');
63
57
  await fs.writeFile(configFile, 'export default null');
64
- // This SHOULD throw but doesn't because of the validateConfig bug.
65
- // Additionally, loadModule returns { default: null } instead of null
66
- // because it uses || (which treats null as falsy) instead of ??
67
- const config = await loadMutineerConfig(tmpDir);
68
- // Bug: returns the module namespace object instead of throwing
69
- expect(config).toHaveProperty('default', null);
58
+ await expect(loadMutineerConfig(tmpDir)).rejects.toThrow('does not export a valid configuration object');
70
59
  });
71
60
  });
@@ -15,6 +15,7 @@ function makeOpts(overrides = {}) {
15
15
  progressMode: 'bar',
16
16
  minKillPercent: undefined,
17
17
  runner: 'vitest',
18
+ timeout: undefined,
18
19
  ...overrides,
19
20
  };
20
21
  }
@@ -62,7 +63,9 @@ describe('resolveCoverageConfig', () => {
62
63
  });
63
64
  it('sets exitCode when onlyCoveredLines is set but no coverage provider', async () => {
64
65
  const opts = makeOpts({ wantsOnlyCoveredLines: true });
65
- const adapter = makeAdapter({ hasCoverageProvider: vi.fn().mockReturnValue(false) });
66
+ const adapter = makeAdapter({
67
+ hasCoverageProvider: vi.fn().mockReturnValue(false),
68
+ });
66
69
  await resolveCoverageConfig(opts, {}, adapter, []);
67
70
  expect(process.exitCode).toBe(1);
68
71
  });
@@ -129,7 +132,9 @@ describe('loadCoverageAfterBaseline', () => {
129
132
  const coverageJson = {
130
133
  '/src/foo.ts': {
131
134
  path: '/src/foo.ts',
132
- statementMap: { '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } } },
135
+ statementMap: {
136
+ '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
137
+ },
133
138
  s: { '0': 1 },
134
139
  },
135
140
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,141 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ // Mock all heavy dependencies before importing orchestrator
3
+ vi.mock('../config.js', () => ({
4
+ loadMutineerConfig: vi.fn(),
5
+ }));
6
+ vi.mock('../cache.js', () => ({
7
+ clearCacheOnStart: vi.fn().mockResolvedValue(undefined),
8
+ readMutantCache: vi.fn().mockResolvedValue({}),
9
+ }));
10
+ vi.mock('../vitest/index.js', () => ({
11
+ createVitestAdapter: vi.fn(),
12
+ }));
13
+ vi.mock('../jest/index.js', () => ({
14
+ createJestAdapter: vi.fn(),
15
+ }));
16
+ vi.mock('../coverage-resolver.js', () => ({
17
+ resolveCoverageConfig: vi.fn().mockResolvedValue({
18
+ enableCoverageForBaseline: false,
19
+ wantsPerTestCoverage: false,
20
+ coverageData: null,
21
+ }),
22
+ loadCoverageAfterBaseline: vi.fn().mockResolvedValue({
23
+ coverageData: null,
24
+ perTestCoverage: null,
25
+ }),
26
+ }));
27
+ vi.mock('../discover.js', () => ({
28
+ autoDiscoverTargetsAndTests: vi.fn().mockResolvedValue({
29
+ targets: [],
30
+ testMap: new Map(),
31
+ directTestMap: new Map(),
32
+ }),
33
+ }));
34
+ vi.mock('../changed.js', () => ({
35
+ listChangedFiles: vi.fn().mockReturnValue([]),
36
+ }));
37
+ import { runOrchestrator } from '../orchestrator.js';
38
+ import { loadMutineerConfig } from '../config.js';
39
+ import { createVitestAdapter } from '../vitest/index.js';
40
+ import { autoDiscoverTargetsAndTests } from '../discover.js';
41
+ import { listChangedFiles } from '../changed.js';
42
+ const mockAdapter = {
43
+ name: 'vitest',
44
+ init: vi.fn().mockResolvedValue(undefined),
45
+ runBaseline: vi.fn().mockResolvedValue(true),
46
+ runMutant: vi.fn().mockResolvedValue({ status: 'killed', durationMs: 10 }),
47
+ shutdown: vi.fn().mockResolvedValue(undefined),
48
+ hasCoverageProvider: vi.fn().mockReturnValue(false),
49
+ detectCoverageConfig: vi
50
+ .fn()
51
+ .mockResolvedValue({ perTestEnabled: false, coverageEnabled: false }),
52
+ };
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
56
+ });
57
+ describe('runOrchestrator --changed-with-deps diagnostic', () => {
58
+ it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
59
+ const cfg = {};
60
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
61
+ const depFile = '/cwd/src/dep.ts';
62
+ vi.mocked(listChangedFiles).mockReturnValue([depFile]);
63
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
64
+ targets: [depFile],
65
+ testMap: new Map(), // dep has no covering tests
66
+ directTestMap: new Map(),
67
+ });
68
+ const consoleSpy = vi.spyOn(console, 'log');
69
+ await runOrchestrator(['--changed-with-deps'], '/cwd');
70
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 target(s) from --changed-with-deps have no covering tests and will be skipped'));
71
+ });
72
+ it('does not log when all changed-with-deps targets have covering tests', async () => {
73
+ const cfg = {};
74
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
75
+ const depFile = '/cwd/src/dep.ts';
76
+ const testFile = '/cwd/src/__tests__/dep.spec.ts';
77
+ vi.mocked(listChangedFiles).mockReturnValue([depFile]);
78
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
79
+ targets: [depFile],
80
+ testMap: new Map([[depFile, new Set([testFile])]]),
81
+ directTestMap: new Map(),
82
+ });
83
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
84
+ const consoleSpy = vi.spyOn(console, 'log');
85
+ await runOrchestrator(['--changed-with-deps'], '/cwd');
86
+ const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
87
+ expect(diagnosticCalls).toHaveLength(0);
88
+ });
89
+ it('does not log diagnostic when wantsChangedWithDeps is false', async () => {
90
+ const cfg = {};
91
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
92
+ const depFile = '/cwd/src/dep.ts';
93
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
94
+ targets: [depFile],
95
+ testMap: new Map(),
96
+ directTestMap: new Map(),
97
+ });
98
+ const consoleSpy = vi.spyOn(console, 'log');
99
+ await runOrchestrator([], '/cwd');
100
+ const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
101
+ expect(diagnosticCalls).toHaveLength(0);
102
+ });
103
+ });
104
+ describe('runOrchestrator timeout precedence', () => {
105
+ it('uses CLI --timeout when provided', async () => {
106
+ const cfg = {};
107
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
108
+ await runOrchestrator(['--timeout', '5000'], '/cwd');
109
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 5000 }));
110
+ });
111
+ it('uses config timeout when CLI flag is absent', async () => {
112
+ const cfg = { timeout: 10000 };
113
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
114
+ await runOrchestrator([], '/cwd');
115
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 10000 }));
116
+ });
117
+ it('CLI --timeout takes precedence over config timeout', async () => {
118
+ const cfg = { timeout: 10000 };
119
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
120
+ await runOrchestrator(['--timeout', '2000'], '/cwd');
121
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2000 }));
122
+ });
123
+ it('falls back to MUTANT_TIMEOUT_MS default when neither CLI nor config timeout set', async () => {
124
+ const cfg = {};
125
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
126
+ await runOrchestrator([], '/cwd');
127
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
128
+ });
129
+ it('env var MUTINEER_MUTANT_TIMEOUT_MS affects default when no CLI/config timeout', async () => {
130
+ const cfg = {};
131
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
132
+ const orig = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
133
+ // env var is read at module load time, so we can only verify the default
134
+ // is used when no cli/config override is present
135
+ process.env.MUTINEER_MUTANT_TIMEOUT_MS = orig;
136
+ await runOrchestrator([], '/cwd');
137
+ const call = vi.mocked(createVitestAdapter).mock.calls[0][0];
138
+ expect(call.timeoutMs).toBeGreaterThan(0);
139
+ expect(Number.isFinite(call.timeoutMs)).toBe(true);
140
+ });
141
+ });
@@ -19,6 +19,7 @@ export interface ParsedCliOptions {
19
19
  readonly progressMode: 'bar' | 'list' | 'quiet';
20
20
  readonly minKillPercent: number | undefined;
21
21
  readonly runner: 'vitest' | 'jest';
22
+ readonly timeout: number | undefined;
22
23
  }
23
24
  /**
24
25
  * Parse a numeric CLI flag value.
@@ -36,6 +37,10 @@ export declare function readStringFlag(args: readonly string[], flag: string, al
36
37
  * Validate a percentage value (0-100).
37
38
  */
38
39
  export declare function validatePercent(value: number | undefined, source: string): number | undefined;
40
+ /**
41
+ * Validate a timeout value (must be a positive finite number).
42
+ */
43
+ export declare function validatePositiveMs(value: number | undefined, source: string): number | undefined;
39
44
  /**
40
45
  * Parse concurrency from CLI args or use default.
41
46
  */
@@ -70,6 +70,17 @@ export function validatePercent(value, source) {
70
70
  }
71
71
  return value;
72
72
  }
73
+ /**
74
+ * Validate a timeout value (must be a positive finite number).
75
+ */
76
+ export function validatePositiveMs(value, source) {
77
+ if (value === undefined)
78
+ return undefined;
79
+ if (!Number.isFinite(value) || value <= 0) {
80
+ throw new Error(`Invalid ${source}: expected a positive number (received ${value})`);
81
+ }
82
+ return value;
83
+ }
73
84
  /**
74
85
  * Parse concurrency from CLI args or use default.
75
86
  */
@@ -115,6 +126,7 @@ export function parseCliOptions(args, cfg) {
115
126
  const cliKillPercent = validatePercent(readNumberFlag(args, '--min-kill-percent'), '--min-kill-percent');
116
127
  const configKillPercent = validatePercent(cfg.minKillPercent, 'mutineer.config minKillPercent');
117
128
  const minKillPercent = cliKillPercent ?? configKillPercent;
129
+ const timeout = validatePositiveMs(readNumberFlag(args, '--timeout'), '--timeout');
118
130
  return {
119
131
  configPath,
120
132
  wantsChanged,
@@ -126,5 +138,6 @@ export function parseCliOptions(args, cfg) {
126
138
  progressMode,
127
139
  minKillPercent,
128
140
  runner,
141
+ timeout,
129
142
  };
130
143
  }
@@ -1,7 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
- import { createRequire } from 'node:module';
5
4
  import { normalizePath } from '../utils/normalizePath.js';
6
5
  import { createLogger } from '../utils/logger.js';
7
6
  const log = createLogger('changed');
@@ -50,52 +49,25 @@ function resolveLocalDependencies(file, cwd, seen = new Set(), maxDepth = 1, cur
50
49
  const dep = match[1];
51
50
  if (!dep.startsWith('.'))
52
51
  continue;
53
- try {
54
- const require = createRequire(file);
55
- let resolvedPath;
56
- // Try direct resolution first
57
- try {
58
- resolvedPath = require.resolve(dep);
59
- }
60
- catch {
61
- // Try different extensions if direct resolution fails
62
- for (const ext of SUPPORTED_EXTENSIONS) {
63
- try {
64
- // Try replacing existing extension
65
- resolvedPath = require.resolve(dep.replace(/\.(js|ts|vue)$/, ext));
66
- break;
67
- }
68
- catch {
69
- try {
70
- // Try adding extension
71
- resolvedPath = require.resolve(dep + ext);
72
- break;
73
- }
74
- catch {
75
- continue;
76
- }
77
- }
78
- }
79
- }
80
- if (!resolvedPath) {
81
- log.warn(`Could not resolve path for dependency ${dep} in ${file}`);
52
+ const dir = path.dirname(file);
53
+ const base = dep.replace(/\.(js|ts|mjs|cjs|vue)$/, '');
54
+ const candidates = [dep, ...SUPPORTED_EXTENSIONS.map((ext) => base + ext)];
55
+ let resolvedPath;
56
+ for (const candidate of candidates) {
57
+ const abs = normalizePath(path.resolve(dir, candidate));
58
+ if (abs.includes('node_modules') || !abs.startsWith(cwd))
82
59
  continue;
60
+ if (fs.existsSync(abs)) {
61
+ resolvedPath = abs;
62
+ break;
83
63
  }
84
- // Skip anything outside the repo/cwd, node_modules, tests, or missing
85
- if (!resolvedPath.startsWith(cwd) ||
86
- resolvedPath.includes('node_modules'))
87
- continue;
88
- if (TEST_FILE_PATTERN.test(resolvedPath))
89
- continue;
90
- if (!fs.existsSync(resolvedPath))
91
- continue;
92
- deps.push(normalizePath(resolvedPath));
93
- deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
94
64
  }
95
- catch (err) {
96
- log.warn(`Failed to resolve dependency ${dep} in ${file}: ${err}`);
65
+ if (!resolvedPath)
97
66
  continue;
98
- }
67
+ if (TEST_FILE_PATTERN.test(resolvedPath))
68
+ continue;
69
+ deps.push(resolvedPath);
70
+ deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
99
71
  }
100
72
  }
101
73
  return [...new Set(deps)];
@@ -19,7 +19,7 @@ const log = createLogger('config');
19
19
  async function loadModule(filePath) {
20
20
  const moduleUrl = pathToFileURL(filePath).href;
21
21
  const mod = await import(moduleUrl);
22
- return mod.default ?? mod;
22
+ return 'default' in mod ? mod.default : mod;
23
23
  }
24
24
  /**
25
25
  * Validate that the loaded configuration has the expected shape.
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { EventEmitter } from 'node:events';
2
3
  import { JestPool, runWithJestPool } from '../pool.js';
3
4
  // We'll use the createWorker option to inject mock workers instead of forking processes
4
5
  function makeMockWorker(id) {
@@ -110,6 +111,46 @@ describe('JestPool', () => {
110
111
  // After shutdown, initialised is set to false, so "not initialised" check fires first
111
112
  await expect(pool.run(dummyMutant, ['test.ts'])).rejects.toThrow('Pool not initialised');
112
113
  });
114
+ it('does not give a dead worker to a waiting task after timeout', async () => {
115
+ let callCount = 0;
116
+ const allWorkers = [];
117
+ const pool = new JestPool({
118
+ cwd: '/tmp',
119
+ concurrency: 1,
120
+ createWorker: (id) => {
121
+ callCount++;
122
+ const workerNum = callCount;
123
+ const worker = new EventEmitter();
124
+ worker.id = id;
125
+ worker._ready = true;
126
+ worker.start = vi.fn().mockResolvedValue(undefined);
127
+ worker.isReady = vi.fn(() => worker._ready);
128
+ worker.isBusy = vi.fn().mockReturnValue(false);
129
+ worker.run = vi.fn().mockImplementation(async () => {
130
+ if (workerNum === 1) {
131
+ worker._ready = false;
132
+ Promise.resolve().then(() => worker.emit('exit'));
133
+ return { killed: false, durationMs: 5000, error: 'timeout' };
134
+ }
135
+ return { killed: true, durationMs: 42 };
136
+ });
137
+ worker.shutdown = vi.fn().mockResolvedValue(undefined);
138
+ worker.kill = vi.fn();
139
+ allWorkers.push(worker);
140
+ return worker;
141
+ },
142
+ });
143
+ await pool.init();
144
+ const [result1, result2] = await Promise.all([
145
+ pool.run(dummyMutant, ['a.spec.ts']),
146
+ pool.run({ ...dummyMutant, id: 'test#2' }, ['b.spec.ts']),
147
+ ]);
148
+ expect(result1).toMatchObject({ error: 'timeout' });
149
+ expect(result2).toMatchObject({ killed: true });
150
+ expect(allWorkers).toHaveLength(2);
151
+ expect(allWorkers[1].run).toHaveBeenCalled();
152
+ await pool.shutdown();
153
+ });
113
154
  it('does not double-shutdown', async () => {
114
155
  const workers = [];
115
156
  const pool = new JestPool({
@@ -239,14 +239,14 @@ export class JestPool {
239
239
  });
240
240
  }
241
241
  releaseWorker(worker) {
242
+ if (!worker.isReady())
243
+ return;
242
244
  const waiting = this.waitingTasks.shift();
243
245
  if (waiting) {
244
246
  waiting(worker);
245
247
  return;
246
248
  }
247
- if (worker.isReady()) {
248
- this.availableWorkers.push(worker);
249
- }
249
+ this.availableWorkers.push(worker);
250
250
  }
251
251
  async run(mutant, tests) {
252
252
  if (!this.initialised) {
@@ -43,7 +43,7 @@ export async function runOrchestrator(cliArgs, cwd) {
43
43
  const adapter = (opts.runner === 'jest' ? createJestAdapter : createVitestAdapter)({
44
44
  cwd,
45
45
  concurrency: opts.concurrency,
46
- timeoutMs: MUTANT_TIMEOUT_MS,
46
+ timeoutMs: opts.timeout ?? cfg.timeout ?? MUTANT_TIMEOUT_MS,
47
47
  config: cfg,
48
48
  cliArgs,
49
49
  });
@@ -88,6 +88,21 @@ export async function runOrchestrator(cliArgs, cwd) {
88
88
  }
89
89
  }
90
90
  const baselineTests = Array.from(allTestFiles);
91
+ if (opts.wantsChangedWithDeps) {
92
+ let uncoveredCount = 0;
93
+ for (const target of targets) {
94
+ const absFile = normalizePath(path.isAbsolute(getTargetFile(target))
95
+ ? getTargetFile(target)
96
+ : path.join(cwd, getTargetFile(target)));
97
+ if (changedAbs?.has(absFile) &&
98
+ !testMap.get(normalizePath(absFile))?.size) {
99
+ uncoveredCount++;
100
+ }
101
+ }
102
+ if (uncoveredCount > 0) {
103
+ log.info(`${uncoveredCount} target(s) from --changed-with-deps have no covering tests and will be skipped`);
104
+ }
105
+ }
91
106
  if (!baselineTests.length) {
92
107
  log.info('No tests found for targets. Exiting.');
93
108
  return;
@@ -103,6 +103,25 @@ describe('Vitest adapter', () => {
103
103
  expect(args.join(' ')).toContain('--coverage.enabled=true');
104
104
  expect(args.join(' ')).toContain('--coverage.perTest=true');
105
105
  });
106
+ it('disables coverage thresholds in baseline-with-coverage to prevent threshold failures', async () => {
107
+ const adapter = makeAdapter({ cliArgs: [] });
108
+ spawnMock.mockImplementationOnce(() => ({
109
+ on: (evt, cb) => {
110
+ if (evt === 'exit')
111
+ cb(0);
112
+ },
113
+ }));
114
+ await adapter.runBaseline(['test-a'], {
115
+ collectCoverage: true,
116
+ perTestCoverage: false,
117
+ });
118
+ const args = spawnMock.mock.calls[0][1];
119
+ const argStr = args.join(' ');
120
+ expect(argStr).toContain('--coverage.thresholds.lines=0');
121
+ expect(argStr).toContain('--coverage.thresholds.functions=0');
122
+ expect(argStr).toContain('--coverage.thresholds.branches=0');
123
+ expect(argStr).toContain('--coverage.thresholds.statements=0');
124
+ });
106
125
  it('detects coverage config from vitest config file', async () => {
107
126
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-vitest-'));
108
127
  const cfgPath = path.join(tmp, 'vitest.config.ts');
@@ -88,6 +88,63 @@ describe('VitestPool', () => {
88
88
  expect(result).toEqual({ status: 'escaped', durationMs: 7 });
89
89
  expect(mockPool.run).toHaveBeenCalledWith(mutant, ['bar.spec.ts']);
90
90
  });
91
+ it('does not give a dead worker to a waiting task after timeout', async () => {
92
+ let callCount = 0;
93
+ const allWorkers = [];
94
+ const pool = new VitestPool({
95
+ cwd: process.cwd(),
96
+ concurrency: 1,
97
+ timeoutMs: 5000,
98
+ createWorker: (id) => {
99
+ callCount++;
100
+ const workerNum = callCount;
101
+ const worker = new EventEmitter();
102
+ worker.id = id;
103
+ worker._ready = true;
104
+ worker.start = vi.fn().mockResolvedValue(undefined);
105
+ worker.isReady = vi.fn(() => worker._ready);
106
+ worker.isBusy = vi.fn().mockReturnValue(false);
107
+ worker.run = vi.fn().mockImplementation(async () => {
108
+ if (workerNum === 1) {
109
+ worker._ready = false;
110
+ Promise.resolve().then(() => worker.emit('exit'));
111
+ return { killed: false, durationMs: 5000, error: 'timeout' };
112
+ }
113
+ return { killed: true, durationMs: 42 };
114
+ });
115
+ worker.shutdown = vi.fn().mockResolvedValue(undefined);
116
+ worker.kill = vi.fn();
117
+ allWorkers.push(worker);
118
+ return worker;
119
+ },
120
+ });
121
+ await pool.init();
122
+ const mutant1 = {
123
+ id: '1',
124
+ name: 'm1',
125
+ file: 'a.ts',
126
+ code: 'x',
127
+ line: 1,
128
+ col: 1,
129
+ };
130
+ const mutant2 = {
131
+ id: '2',
132
+ name: 'm2',
133
+ file: 'b.ts',
134
+ code: 'y',
135
+ line: 1,
136
+ col: 1,
137
+ };
138
+ const [result1, result2] = await Promise.all([
139
+ pool.run(mutant1, ['a.spec.ts']),
140
+ pool.run(mutant2, ['b.spec.ts']),
141
+ ]);
142
+ expect(result1).toMatchObject({ error: 'timeout' });
143
+ expect(result2).toMatchObject({ killed: true });
144
+ expect(allWorkers).toHaveLength(2);
145
+ expect(allWorkers[1].run).toHaveBeenCalled();
146
+ await pool.shutdown();
147
+ });
91
148
  it('maps runWithPool errors to error status', async () => {
92
149
  const mockPool = {
93
150
  run: vi.fn().mockRejectedValue(new Error('boom')),
@@ -96,6 +96,9 @@ function buildVitestArgs(args, mode) {
96
96
  if (!result.some((a) => a.startsWith('--coverage.perTest='))) {
97
97
  result.push('--coverage.perTest=true');
98
98
  }
99
+ // Disable coverage thresholds so baseline doesn't fail when a broader
100
+ // test set (e.g. from --changed-with-deps) lowers aggregate coverage
101
+ result.push('--coverage.thresholds.lines=0', '--coverage.thresholds.functions=0', '--coverage.thresholds.branches=0', '--coverage.thresholds.statements=0');
99
102
  }
100
103
  return result;
101
104
  }
@@ -301,6 +301,8 @@ export class VitestPool {
301
301
  });
302
302
  }
303
303
  releaseWorker(worker) {
304
+ if (!worker.isReady())
305
+ return;
304
306
  // If someone is waiting, give them the worker directly
305
307
  const waiting = this.waitingTasks.shift();
306
308
  if (waiting) {
@@ -308,9 +310,7 @@ export class VitestPool {
308
310
  return;
309
311
  }
310
312
  // Otherwise return to the pool
311
- if (worker.isReady()) {
312
- this.availableWorkers.push(worker);
313
- }
313
+ this.availableWorkers.push(worker);
314
314
  }
315
315
  async run(mutant, tests) {
316
316
  if (!this.initialised) {
@@ -38,4 +38,6 @@ export interface MutineerConfig {
38
38
  * Requires Vitest coverage with perTest support.
39
39
  */
40
40
  readonly perTestCoverage?: boolean;
41
+ /** Per-mutant test timeout in milliseconds (default: 30000) */
42
+ readonly timeout?: number;
41
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutineerjs/mutineer",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "A fast, targeted mutation testing framework for JavaScript and TypeScript",
5
5
  "type": "module",
6
6
  "private": false,