@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
@@ -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.
@@ -84,15 +84,24 @@ async function createViteResolver(rootAbs, exts) {
84
84
  // Load Vue plugin if needed
85
85
  let plugins = [];
86
86
  if (exts.has('.vue')) {
87
- try {
88
- const mod = await import(
89
- /* @vite-ignore */ '@vitejs/plugin-vue');
90
- const vue = mod.default ?? mod;
91
- plugins = typeof vue === 'function' ? [vue()] : [];
92
- }
93
- catch (err) {
94
- const detail = err instanceof Error ? err.message : String(err);
95
- log.warn(`Unable to load @vitejs/plugin-vue; Vue SFC imports may fail to resolve (${detail})`);
87
+ const vueFiles = await fg(['**/*.vue'], {
88
+ cwd: rootAbs,
89
+ onlyFiles: true,
90
+ ignore: ['**/node_modules/**'],
91
+ deep: 5,
92
+ });
93
+ if (vueFiles.length > 0) {
94
+ try {
95
+ const mod = await import(
96
+ /* @vite-ignore */ '@vitejs/plugin-vue');
97
+ const vue = mod.default ?? mod;
98
+ plugins =
99
+ typeof vue === 'function' ? [vue()] : [];
100
+ }
101
+ catch (err) {
102
+ const detail = err instanceof Error ? err.message : String(err);
103
+ log.warn(`Unable to load @vitejs/plugin-vue; Vue SFC imports may fail to resolve (${detail})`);
104
+ }
96
105
  }
97
106
  }
98
107
  const quietLogger = {
@@ -280,12 +289,12 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
280
289
  }
281
290
  }
282
291
  try {
283
- for (const testAbs of tests) {
292
+ await Promise.all(tests.map(async (testAbs) => {
284
293
  const seen = new Set();
285
294
  // prime with the test's own direct imports
286
295
  const code = safeRead(testAbs);
287
296
  if (!code)
288
- continue;
297
+ return;
289
298
  const firstHop = [];
290
299
  for (const spec of extractImportSpecs(code)) {
291
300
  if (!spec)
@@ -302,7 +311,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
302
311
  for (const abs of firstHop) {
303
312
  await crawl(abs, 0, seen, testAbs);
304
313
  }
305
- }
314
+ }));
306
315
  return { targets: Array.from(targets.values()), testMap, directTestMap };
307
316
  }
308
317
  finally {
@@ -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;
@@ -64,6 +64,7 @@ export async function executePool(opts) {
64
64
  const poolDurationMs = Date.now() - poolStart;
65
65
  log.info(`\u2713 Worker pool ready (${poolDurationMs}ms)`);
66
66
  progress.start();
67
+ const fileCache = new Map();
67
68
  let nextIdx = 0;
68
69
  async function processTask(task) {
69
70
  const { v, tests, key, directTests } = task;
@@ -98,7 +99,12 @@ export async function executePool(opts) {
98
99
  let mutatedSnippet;
99
100
  if (status === 'escaped') {
100
101
  try {
101
- const originalLines = fs.readFileSync(v.file, 'utf8').split('\n');
102
+ let fileContent = fileCache.get(v.file);
103
+ if (fileContent === undefined) {
104
+ fileContent = fs.readFileSync(v.file, 'utf8');
105
+ fileCache.set(v.file, fileContent);
106
+ }
107
+ const originalLines = fileContent.split('\n');
102
108
  const mutatedLines = v.code.split('\n');
103
109
  const lineIdx = v.line - 1;
104
110
  const orig = originalLines[lineIdx]?.trim();
@@ -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.0",
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,