@mutineerjs/mutineer 0.5.0 → 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.
Files changed (39) hide show
  1. package/README.md +42 -2
  2. package/dist/bin/__tests__/mutineer.spec.d.ts +1 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +43 -0
  4. package/dist/bin/mutineer.d.ts +2 -1
  5. package/dist/bin/mutineer.js +41 -0
  6. package/dist/core/__tests__/variant-utils.spec.js +24 -2
  7. package/dist/core/sfc.js +6 -1
  8. package/dist/core/variant-utils.js +6 -1
  9. package/dist/mutators/__tests__/operator.spec.js +16 -0
  10. package/dist/mutators/__tests__/return-value.spec.js +37 -0
  11. package/dist/mutators/__tests__/utils.spec.js +96 -2
  12. package/dist/mutators/operator.js +11 -5
  13. package/dist/mutators/return-value.js +36 -22
  14. package/dist/mutators/types.d.ts +2 -0
  15. package/dist/mutators/utils.d.ts +67 -0
  16. package/dist/mutators/utils.js +90 -15
  17. package/dist/runner/__tests__/args.spec.js +45 -1
  18. package/dist/runner/__tests__/changed.spec.js +85 -2
  19. package/dist/runner/__tests__/config.spec.js +2 -13
  20. package/dist/runner/__tests__/coverage-resolver.spec.js +7 -2
  21. package/dist/runner/__tests__/discover.spec.js +52 -1
  22. package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
  23. package/dist/runner/__tests__/orchestrator.spec.js +141 -0
  24. package/dist/runner/__tests__/pool-executor.spec.js +40 -0
  25. package/dist/runner/args.d.ts +5 -0
  26. package/dist/runner/args.js +13 -0
  27. package/dist/runner/changed.js +15 -43
  28. package/dist/runner/config.js +1 -1
  29. package/dist/runner/discover.js +21 -12
  30. package/dist/runner/jest/__tests__/pool.spec.js +41 -0
  31. package/dist/runner/jest/pool.js +3 -3
  32. package/dist/runner/orchestrator.js +16 -1
  33. package/dist/runner/pool-executor.js +7 -1
  34. package/dist/runner/vitest/__tests__/adapter.spec.js +19 -0
  35. package/dist/runner/vitest/__tests__/pool.spec.js +57 -0
  36. package/dist/runner/vitest/adapter.js +3 -0
  37. package/dist/runner/vitest/pool.js +3 -3
  38. package/dist/types/config.d.ts +2 -0
  39. package/package.json +1 -1
@@ -122,21 +122,86 @@ export function isBinaryOrLogical(node) {
122
122
  return node.type === 'BinaryExpression' || node.type === 'LogicalExpression';
123
123
  }
124
124
  /**
125
- * Collect the operator tokens for a given operator and return their exact locations.
126
- * Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
127
- * to token positions for accurate column reporting.
128
- *
129
- * @param src - The source code
130
- * @param opValue - The operator to search for (e.g., '&&', '<=')
131
- * @returns Array of target locations for the operator
125
+ * Single traversal that collects all operator targets and return statements.
126
+ * Eliminates per-mutator traversals when using ParseContext.
132
127
  */
133
- export function collectOperatorTargets(src, opValue) {
128
+ export function collectAllTargets(src, ast, tokens, ignoreLines) {
129
+ const operatorTargets = new Map();
130
+ const returnStatements = [];
131
+ function handleBinaryOrLogical(n) {
132
+ const nodeStart = n.start ?? 0;
133
+ const nodeEnd = n.end ?? 0;
134
+ const opValue = n.operator;
135
+ const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
136
+ if (tok) {
137
+ const line = tok.loc.start.line;
138
+ if (ignoreLines.has(line))
139
+ return;
140
+ const visualCol = getVisualColumn(src, tok.start);
141
+ let arr = operatorTargets.get(opValue);
142
+ if (!arr) {
143
+ arr = [];
144
+ operatorTargets.set(opValue, arr);
145
+ }
146
+ arr.push({
147
+ start: tok.start,
148
+ end: tok.end,
149
+ line,
150
+ col1: visualCol,
151
+ op: opValue,
152
+ });
153
+ }
154
+ }
155
+ traverse(ast, {
156
+ BinaryExpression(p) {
157
+ handleBinaryOrLogical(p.node);
158
+ },
159
+ LogicalExpression(p) {
160
+ handleBinaryOrLogical(p.node);
161
+ },
162
+ ReturnStatement(p) {
163
+ const node = p.node;
164
+ if (!node.argument)
165
+ return;
166
+ const line = node.loc?.start.line;
167
+ if (line === undefined)
168
+ return;
169
+ if (ignoreLines.has(line))
170
+ return;
171
+ const argStart = node.argument.start;
172
+ const argEnd = node.argument.end;
173
+ if (argStart == null || argEnd == null)
174
+ return;
175
+ const col = getVisualColumn(src, node.start ?? 0);
176
+ returnStatements.push({
177
+ line,
178
+ col,
179
+ argStart,
180
+ argEnd,
181
+ argNode: node.argument,
182
+ });
183
+ },
184
+ });
185
+ return { operatorTargets, returnStatements };
186
+ }
187
+ /**
188
+ * Parse a source file once and build a reusable ParseContext.
189
+ * Pass this to mutators' applyWithContext to avoid redundant parses.
190
+ */
191
+ export function buildParseContext(src) {
134
192
  const ast = parseSource(src);
135
- const fileAst = ast;
136
- const tokens = fileAst.tokens ?? [];
137
- const comments = fileAst.comments ?? [];
193
+ const tokens = ast.tokens ?? [];
194
+ const ignoreLines = buildIgnoreLines(ast.comments ?? []);
195
+ const preCollected = collectAllTargets(src, ast, tokens, ignoreLines);
196
+ return { ast, tokens, ignoreLines, preCollected };
197
+ }
198
+ /**
199
+ * Collect operator targets from a pre-built ParseContext.
200
+ * Avoids re-parsing; use when processing multiple operators on the same source.
201
+ */
202
+ export function collectOperatorTargetsFromContext(src, ctx, opValue) {
203
+ const { ast, tokens, ignoreLines } = ctx;
138
204
  const out = [];
139
- const ignoreLines = buildIgnoreLines(comments);
140
205
  traverse(ast, {
141
206
  enter(p) {
142
207
  if (!isBinaryOrLogical(p.node))
@@ -144,12 +209,10 @@ export function collectOperatorTargets(src, opValue) {
144
209
  const n = p.node;
145
210
  if (n.operator !== opValue)
146
211
  return;
147
- // Find the exact operator token inside the node span
148
212
  const nodeStart = n.start ?? 0;
149
213
  const nodeEnd = n.end ?? 0;
150
214
  const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
151
215
  if (tok) {
152
- // Convert Babel's character-based column to a visual column for accurate reporting
153
216
  const line = tok.loc.start.line;
154
217
  if (ignoreLines.has(line))
155
218
  return;
@@ -158,7 +221,7 @@ export function collectOperatorTargets(src, opValue) {
158
221
  start: tok.start,
159
222
  end: tok.end,
160
223
  line,
161
- col1: visualCol, // convert to 1-based
224
+ col1: visualCol,
162
225
  op: opValue,
163
226
  });
164
227
  }
@@ -166,3 +229,15 @@ export function collectOperatorTargets(src, opValue) {
166
229
  });
167
230
  return out;
168
231
  }
232
+ /**
233
+ * Collect the operator tokens for a given operator and return their exact locations.
234
+ * Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
235
+ * to token positions for accurate column reporting.
236
+ *
237
+ * @param src - The source code
238
+ * @param opValue - The operator to search for (e.g., '&&', '<=')
239
+ * @returns Array of target locations for the operator
240
+ */
241
+ export function collectOperatorTargets(src, opValue) {
242
+ return collectOperatorTargetsFromContext(src, buildParseContext(src), opValue);
243
+ }
@@ -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
  };
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
@@ -34,6 +34,57 @@ vi.mock('vite', async () => {
34
34
  }),
35
35
  };
36
36
  });
37
+ describe('createViteResolver Vue plugin gating', () => {
38
+ let warnSpy;
39
+ beforeEach(() => {
40
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
41
+ });
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ });
45
+ it('does not warn about @vitejs/plugin-vue when no .vue files exist', async () => {
46
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-novue-'));
47
+ const srcDir = path.join(tmpDir, 'src');
48
+ await fs.mkdir(srcDir, { recursive: true });
49
+ await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
50
+ const importLine = ['im', 'port { a } from "./a"'].join('');
51
+ await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
52
+ try {
53
+ await autoDiscoverTargetsAndTests(tmpDir, {
54
+ testPatterns: ['**/*.test.ts'],
55
+ });
56
+ const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
57
+ expect(pluginVueWarnings).toHaveLength(0);
58
+ }
59
+ finally {
60
+ await fs.rm(tmpDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ it('warns when .vue files exist but @vitejs/plugin-vue fails to load', async () => {
64
+ vi.doMock('@vitejs/plugin-vue', () => {
65
+ throw new Error('Cannot find module @vitejs/plugin-vue');
66
+ });
67
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-vue-'));
68
+ const srcDir = path.join(tmpDir, 'src');
69
+ await fs.mkdir(srcDir, { recursive: true });
70
+ await fs.writeFile(path.join(srcDir, 'Comp.vue'), '<template><div/></template>\n', 'utf8');
71
+ await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
72
+ const importLine = ['im', 'port { a } from "./a"'].join('');
73
+ await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
74
+ try {
75
+ await autoDiscoverTargetsAndTests(tmpDir, {
76
+ testPatterns: ['**/*.test.ts'],
77
+ extensions: ['.vue', '.ts'],
78
+ });
79
+ const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
80
+ expect(pluginVueWarnings.length).toBeGreaterThan(0);
81
+ }
82
+ finally {
83
+ vi.doUnmock('@vitejs/plugin-vue');
84
+ await fs.rm(tmpDir, { recursive: true, force: true });
85
+ }
86
+ });
87
+ });
37
88
  describe('autoDiscoverTargetsAndTests', () => {
38
89
  it('directTestMap only includes direct importers', async () => {
39
90
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct-'));
@@ -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
+ });
@@ -370,6 +370,46 @@ describe('executePool', () => {
370
370
  });
371
371
  expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
372
372
  });
373
+ it('correctly stores snippets for multiple escaped mutants from the same file', async () => {
374
+ const tmpFile = path.join(tmpDir, 'shared.ts');
375
+ await fs.writeFile(tmpFile, 'const x = a + b\n');
376
+ const adapter = makeAdapter({
377
+ runMutant: vi
378
+ .fn()
379
+ .mockResolvedValue({ status: 'escaped', durationMs: 1 }),
380
+ });
381
+ const cache = {};
382
+ const makeFileTask = (key, mutated) => makeTask({
383
+ key,
384
+ v: {
385
+ id: `shared.ts#${key}`,
386
+ name: 'flipArith',
387
+ file: tmpFile,
388
+ code: mutated,
389
+ line: 1,
390
+ col: 10,
391
+ tests: ['/tests/file.test.ts'],
392
+ },
393
+ });
394
+ await executePool({
395
+ tasks: [
396
+ makeFileTask('cache-key-1', 'const x = a - b\n'),
397
+ makeFileTask('cache-key-2', 'const x = a * b\n'),
398
+ makeFileTask('cache-key-3', 'const x = a / b\n'),
399
+ ],
400
+ adapter,
401
+ cache,
402
+ concurrency: 1,
403
+ progressMode: 'list',
404
+ cwd: tmpDir,
405
+ });
406
+ expect(cache['cache-key-1'].originalSnippet).toBe('const x = a + b');
407
+ expect(cache['cache-key-1'].mutatedSnippet).toBe('const x = a - b');
408
+ expect(cache['cache-key-2'].originalSnippet).toBe('const x = a + b');
409
+ expect(cache['cache-key-2'].mutatedSnippet).toBe('const x = a * b');
410
+ expect(cache['cache-key-3'].originalSnippet).toBe('const x = a + b');
411
+ expect(cache['cache-key-3'].mutatedSnippet).toBe('const x = a / b');
412
+ });
373
413
  it('handles adapter errors gracefully and still shuts down', async () => {
374
414
  const adapter = makeAdapter({
375
415
  runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
@@ -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
  }