@mutineerjs/mutineer 0.9.0 → 0.11.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 (83) hide show
  1. package/README.md +52 -47
  2. package/dist/__tests__/index.spec.js +8 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +7 -7
  4. package/dist/bin/mutineer.d.ts +1 -1
  5. package/dist/bin/mutineer.js +7 -4
  6. package/dist/core/__tests__/schemata.spec.js +62 -0
  7. package/dist/core/__tests__/sfc.spec.js +41 -1
  8. package/dist/core/schemata.js +15 -21
  9. package/dist/core/sfc.js +0 -4
  10. package/dist/core/variant-utils.js +0 -4
  11. package/dist/mutators/__tests__/utils.spec.js +65 -1
  12. package/dist/mutators/operator.js +13 -27
  13. package/dist/mutators/return-value.js +3 -7
  14. package/dist/mutators/utils.d.ts +2 -2
  15. package/dist/mutators/utils.js +59 -96
  16. package/dist/runner/__tests__/args.spec.js +8 -4
  17. package/dist/runner/__tests__/cache.spec.js +24 -0
  18. package/dist/runner/__tests__/changed.spec.js +75 -0
  19. package/dist/runner/__tests__/config.spec.js +50 -1
  20. package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
  21. package/dist/runner/__tests__/discover.spec.js +179 -0
  22. package/dist/runner/__tests__/orchestrator.spec.js +336 -11
  23. package/dist/runner/__tests__/pool-executor.spec.js +77 -0
  24. package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
  25. package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
  26. package/dist/runner/__tests__/ts-checker.spec.js +89 -2
  27. package/dist/runner/args.d.ts +1 -1
  28. package/dist/runner/args.js +2 -2
  29. package/dist/runner/config.js +3 -4
  30. package/dist/runner/coverage-resolver.js +2 -1
  31. package/dist/runner/discover.js +2 -2
  32. package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
  33. package/dist/runner/jest/__tests__/pool.spec.js +223 -1
  34. package/dist/runner/jest/adapter.js +3 -45
  35. package/dist/runner/jest/pool.js +4 -10
  36. package/dist/runner/jest/worker-runtime.js +2 -1
  37. package/dist/runner/orchestrator.js +8 -7
  38. package/dist/runner/pool-executor.js +7 -12
  39. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.d.ts +1 -0
  40. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.js +104 -0
  41. package/dist/runner/shared/__tests__/worker-script.spec.d.ts +1 -0
  42. package/dist/runner/shared/__tests__/worker-script.spec.js +32 -0
  43. package/dist/runner/shared/index.d.ts +4 -0
  44. package/dist/runner/shared/index.js +2 -0
  45. package/dist/runner/shared/pending-task.d.ts +9 -0
  46. package/dist/runner/shared/pending-task.js +1 -0
  47. package/dist/runner/shared/strip-mutineer-args.d.ts +11 -0
  48. package/dist/runner/shared/strip-mutineer-args.js +47 -0
  49. package/dist/runner/shared/worker-script.d.ts +5 -0
  50. package/dist/runner/shared/worker-script.js +12 -0
  51. package/dist/runner/ts-checker-worker.d.ts +10 -1
  52. package/dist/runner/ts-checker-worker.js +27 -25
  53. package/dist/runner/ts-checker.d.ts +6 -0
  54. package/dist/runner/ts-checker.js +1 -1
  55. package/dist/runner/vitest/__tests__/adapter.spec.js +294 -0
  56. package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
  57. package/dist/runner/vitest/__tests__/pool.spec.js +711 -0
  58. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
  59. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +81 -0
  60. package/dist/runner/vitest/adapter.js +14 -46
  61. package/dist/runner/vitest/plugin.js +1 -7
  62. package/dist/runner/vitest/pool.js +6 -19
  63. package/dist/runner/vitest/redirect-loader.js +3 -1
  64. package/dist/runner/vitest/worker-runtime.js +16 -1
  65. package/dist/runner/vitest/worker.mjs +1 -0
  66. package/dist/types/config.d.ts +2 -2
  67. package/dist/types/mutant.d.ts +3 -0
  68. package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
  69. package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
  70. package/dist/utils/__tests__/coverage.spec.js +89 -0
  71. package/dist/utils/__tests__/logger.spec.js +9 -0
  72. package/dist/utils/__tests__/progress.spec.js +38 -0
  73. package/dist/utils/__tests__/summary.spec.js +70 -31
  74. package/dist/utils/coverage.js +3 -4
  75. package/dist/utils/errors.d.ts +4 -0
  76. package/dist/utils/errors.js +6 -0
  77. package/dist/utils/summary.d.ts +2 -3
  78. package/dist/utils/summary.js +5 -6
  79. package/package.json +1 -1
  80. package/dist/utils/CompileErrors.d.ts +0 -7
  81. package/dist/utils/CompileErrors.js +0 -24
  82. package/dist/utils/__tests__/CompileErrors.spec.js +0 -96
  83. /package/dist/{utils/__tests__/CompileErrors.spec.d.ts → __tests__/index.spec.d.ts} +0 -0
@@ -7,7 +7,7 @@ vi.mock('node:module', async (importOriginal) => {
7
7
  const actual = await importOriginal();
8
8
  return { ...actual, register: vi.fn() };
9
9
  });
10
- import { resolve as poolResolve, initialise } from '../redirect-loader.js';
10
+ import { resolve as poolResolve, initialise, load } from '../redirect-loader.js';
11
11
  describe('pool-redirect-loader resolve', () => {
12
12
  afterEach(() => {
13
13
  ;
@@ -170,4 +170,119 @@ describe('pool-redirect-loader resolve', () => {
170
170
  // Falls through to nextResolve
171
171
  expect(nextResolve).toHaveBeenCalled();
172
172
  });
173
+ it('returns null from tryResolveTsExtension when parentURL is absent', async () => {
174
+ const nextResolve = vi.fn().mockResolvedValue({
175
+ url: 'file:///fallback.js',
176
+ shortCircuit: false,
177
+ });
178
+ await poolResolve('./foo.js', {}, nextResolve);
179
+ // Falls through to nextResolve
180
+ expect(nextResolve).toHaveBeenCalled();
181
+ });
182
+ it('falls through to nextResolve when neither .ts nor .tsx exists', async () => {
183
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
184
+ const parentFile = path.join(tmpDir, 'src', 'index.ts');
185
+ await fs.mkdir(path.dirname(parentFile), { recursive: true });
186
+ await fs.writeFile(parentFile, 'export {}', 'utf8');
187
+ // foo.ts and foo.tsx do NOT exist — tryResolveTsExtension returns null at end of loop
188
+ const nextResolve = vi.fn().mockResolvedValue({
189
+ url: 'file:///fallback.js',
190
+ shortCircuit: false,
191
+ });
192
+ try {
193
+ await poolResolve('./foo.js', { parentURL: pathToFileURL(parentFile).href }, nextResolve);
194
+ expect(nextResolve).toHaveBeenCalled();
195
+ }
196
+ finally {
197
+ await fs.rm(tmpDir, { recursive: true, force: true });
198
+ }
199
+ });
200
+ it('logs debug info when DEBUG is enabled', async () => {
201
+ initialise({ debug: true });
202
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
203
+ const nextResolve = vi.fn().mockResolvedValue({
204
+ url: 'file:///some.ts',
205
+ shortCircuit: false,
206
+ });
207
+ await poolResolve('./something', { parentURL: 'file:///src/index.ts' }, nextResolve);
208
+ expect(errSpy).toHaveBeenCalled();
209
+ errSpy.mockRestore();
210
+ });
211
+ it('logs active redirect and REDIRECTING when DEBUG is enabled and redirect matches after nextResolve', async () => {
212
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
213
+ const fromPath = path.join(tmpDir, 'target.ts');
214
+ const mutatedPath = path.join(tmpDir, 'mutated.ts');
215
+ await fs.writeFile(fromPath, 'export {}', 'utf8');
216
+ await fs.writeFile(mutatedPath, 'export {}', 'utf8');
217
+ initialise({ debug: true });
218
+ globalThis.__mutineer_redirect__ = {
219
+ from: fromPath,
220
+ to: mutatedPath,
221
+ };
222
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
223
+ const nextResolve = vi.fn().mockResolvedValue({
224
+ url: pathToFileURL(fromPath).href,
225
+ shortCircuit: false,
226
+ });
227
+ try {
228
+ const result = await poolResolve('./target', { parentURL: 'file:///src/index.ts' }, nextResolve);
229
+ expect(result.url).toBe(pathToFileURL(mutatedPath).href);
230
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('REDIRECTING'));
231
+ }
232
+ finally {
233
+ errSpy.mockRestore();
234
+ await fs.rm(tmpDir, { recursive: true, force: true });
235
+ }
236
+ });
237
+ it('logs .js -> .ts redirect info when DEBUG is enabled and ts file exists', async () => {
238
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
239
+ const parentFile = path.join(tmpDir, 'src', 'index.ts');
240
+ const tsFile = path.join(tmpDir, 'src', 'foo.ts');
241
+ await fs.mkdir(path.dirname(parentFile), { recursive: true });
242
+ await fs.writeFile(parentFile, 'export {}', 'utf8');
243
+ await fs.writeFile(tsFile, 'export const foo = 1', 'utf8');
244
+ initialise({ debug: true });
245
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
246
+ const nextResolve = vi.fn();
247
+ try {
248
+ await poolResolve('./foo.js', { parentURL: pathToFileURL(parentFile).href }, nextResolve);
249
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('.js -> .ts'));
250
+ }
251
+ finally {
252
+ errSpy.mockRestore();
253
+ await fs.rm(tmpDir, { recursive: true, force: true });
254
+ }
255
+ });
256
+ it('logs REDIRECTING when DEBUG is enabled and .ts resolved file matches redirect', async () => {
257
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-pool-loader-'));
258
+ const parentFile = path.join(tmpDir, 'src', 'index.ts');
259
+ const fromPath = path.join(tmpDir, 'src', 'target.ts');
260
+ const mutatedPath = path.join(tmpDir, 'mutated.ts');
261
+ await fs.mkdir(path.dirname(parentFile), { recursive: true });
262
+ await fs.writeFile(parentFile, 'export {}', 'utf8');
263
+ await fs.writeFile(fromPath, 'export {}', 'utf8');
264
+ await fs.writeFile(mutatedPath, 'export {}', 'utf8');
265
+ initialise({ debug: true });
266
+ globalThis.__mutineer_redirect__ = {
267
+ from: fromPath,
268
+ to: mutatedPath,
269
+ };
270
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
271
+ const nextResolve = vi.fn();
272
+ try {
273
+ const result = await poolResolve('./target.js', { parentURL: pathToFileURL(parentFile).href }, nextResolve);
274
+ expect(result.url).toBe(pathToFileURL(mutatedPath).href);
275
+ expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('REDIRECTING'));
276
+ }
277
+ finally {
278
+ errSpy.mockRestore();
279
+ await fs.rm(tmpDir, { recursive: true, force: true });
280
+ }
281
+ });
282
+ it('load() passes through to nextLoad', async () => {
283
+ const nextLoad = vi.fn().mockResolvedValue({ source: '// code' });
284
+ const result = await load('file:///foo.ts', {}, nextLoad);
285
+ expect(nextLoad).toHaveBeenCalledWith('file:///foo.ts', {});
286
+ expect(result).toEqual({ source: '// code' });
287
+ });
173
288
  });
@@ -125,6 +125,66 @@ describe('VitestWorkerRuntime', () => {
125
125
  expect(result.error).toBe('string error');
126
126
  await runtime.shutdown();
127
127
  });
128
+ it('collects passingTests fullNames from modules when mutant escapes', async () => {
129
+ const makeModule = (moduleId, names) => ({
130
+ moduleId,
131
+ ok: () => true,
132
+ children: {
133
+ allTests: (_state) => names.map((n) => ({ fullName: n })),
134
+ },
135
+ });
136
+ runSpecsFn.mockResolvedValue({
137
+ testModules: [
138
+ makeModule(path.join(os.tmpdir(), 'test.ts'), [
139
+ 'Math > adds',
140
+ 'Math > subtracts',
141
+ ]),
142
+ ],
143
+ });
144
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-pt-'));
145
+ tmpFiles.push(tmp);
146
+ const runtime = createVitestWorkerRuntime({ workerId: 'w-pt', cwd: tmp });
147
+ await runtime.init();
148
+ const result = await runtime.run({
149
+ id: 'mut#pt',
150
+ name: 'm',
151
+ file: path.join(tmp, 'src.ts'),
152
+ code: 'export const x=1',
153
+ line: 1,
154
+ col: 1,
155
+ }, [path.join(os.tmpdir(), 'test.ts')]);
156
+ expect(result.killed).toBe(false);
157
+ expect(result.passingTests).toEqual(['Math > adds', 'Math > subtracts']);
158
+ await runtime.shutdown();
159
+ });
160
+ it('omits passingTests when mutant is killed', async () => {
161
+ runSpecsFn.mockResolvedValue({
162
+ testModules: [
163
+ {
164
+ moduleId: path.join(os.tmpdir(), 'test.ts'),
165
+ ok: () => false,
166
+ children: {
167
+ allTests: (_state) => [{ fullName: 'Math > adds' }],
168
+ },
169
+ },
170
+ ],
171
+ });
172
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-kpt-'));
173
+ tmpFiles.push(tmp);
174
+ const runtime = createVitestWorkerRuntime({ workerId: 'w-kpt', cwd: tmp });
175
+ await runtime.init();
176
+ const result = await runtime.run({
177
+ id: 'mut#kpt',
178
+ name: 'm',
179
+ file: path.join(tmp, 'src.ts'),
180
+ code: 'export const x=1',
181
+ line: 1,
182
+ col: 1,
183
+ }, [path.join(os.tmpdir(), 'test.ts')]);
184
+ expect(result.killed).toBe(true);
185
+ expect(result.passingTests).toBeUndefined();
186
+ await runtime.shutdown();
187
+ });
128
188
  it('falls back to all testModules when no relevant modules match', async () => {
129
189
  runSpecsFn.mockResolvedValue({
130
190
  testModules: [{ moduleId: 'unknown-module', ok: () => true }],
@@ -252,6 +312,27 @@ describe('VitestWorkerRuntime', () => {
252
312
  }
253
313
  }
254
314
  });
315
+ it('logs error and rethrows when vitest.init() throws', async () => {
316
+ const { createVitest } = await import('vitest/node');
317
+ const initError = new Error('init failed');
318
+ vi.mocked(createVitest).mockResolvedValueOnce({
319
+ init: vi.fn().mockRejectedValue(initError),
320
+ close: closeFn,
321
+ runTestSpecifications: runSpecsFn,
322
+ invalidateFile: invalidateFn,
323
+ getProjectByName: getProjectByNameFn,
324
+ });
325
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
326
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-worker-initerr-'));
327
+ tmpFiles.push(tmp);
328
+ const runtime = createVitestWorkerRuntime({
329
+ workerId: 'w-initerr',
330
+ cwd: tmp,
331
+ });
332
+ await expect(runtime.init()).rejects.toThrow('init failed');
333
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining('Failed to initialise Vitest'));
334
+ spy.mockRestore();
335
+ });
255
336
  it('clears state.filesMap before each run to prevent memory accumulation', async () => {
256
337
  const { createVitest } = await import('vitest/node');
257
338
  const clearFn = vi.fn();
@@ -11,6 +11,8 @@ import { spawn } from 'node:child_process';
11
11
  import { createRequire } from 'node:module';
12
12
  import { VitestPool } from './pool.js';
13
13
  import { createLogger } from '../../utils/logger.js';
14
+ import { stripMutineerArgs } from '../shared/index.js';
15
+ import { toErrorMessage } from '../../utils/errors.js';
14
16
  const require = createRequire(import.meta.url);
15
17
  const log = createLogger('vitest-adapter');
16
18
  /**
@@ -25,48 +27,6 @@ function resolveVitestPath() {
25
27
  return path.join(path.dirname(pkgJson), 'vitest.mjs');
26
28
  }
27
29
  }
28
- /**
29
- * Strip mutineer-specific CLI args that shouldn't be passed to Vitest.
30
- */
31
- function stripMutineerArgs(args) {
32
- const out = [];
33
- const consumeNext = new Set([
34
- '--concurrency',
35
- '--progress',
36
- '--min-kill-percent',
37
- '--config',
38
- '-c',
39
- '--coverage-file',
40
- '--shard',
41
- ]);
42
- const dropExact = new Set([
43
- '-m',
44
- '--mutate',
45
- '--changed',
46
- '--changed-with-deps',
47
- '--full',
48
- '--only-covered-lines',
49
- '--per-test-coverage',
50
- '--perTestCoverage',
51
- ]);
52
- for (let i = 0; i < args.length; i++) {
53
- const a = args[i];
54
- if (dropExact.has(a))
55
- continue;
56
- if (consumeNext.has(a)) {
57
- i++;
58
- continue;
59
- }
60
- if (a.startsWith('--min-kill-percent='))
61
- continue;
62
- if (a.startsWith('--config=') || a.startsWith('-c='))
63
- continue;
64
- if (a.startsWith('--shard='))
65
- continue;
66
- out.push(a);
67
- }
68
- return out;
69
- }
70
30
  /**
71
31
  * Ensure the Vitest config arg is included if specified.
72
32
  */
@@ -101,7 +61,7 @@ function buildVitestArgs(args, mode) {
101
61
  result.push('--coverage.perTest=true');
102
62
  }
103
63
  // Disable coverage thresholds so baseline doesn't fail when a broader
104
- // test set (e.g. from --changed-with-deps) lowers aggregate coverage
64
+ // test set (e.g. from --changed-with-imports) lowers aggregate coverage
105
65
  result.push('--coverage.thresholds.lines=0', '--coverage.thresholds.functions=0', '--coverage.thresholds.branches=0', '--coverage.thresholds.statements=0');
106
66
  }
107
67
  return result;
@@ -122,7 +82,10 @@ export class VitestAdapter {
122
82
  throw new Error("Cannot find 'vitest'. Install it with: npm i -D vitest");
123
83
  }
124
84
  // Prepare base args by stripping mutineer-specific flags
125
- const stripped = stripMutineerArgs(options.cliArgs);
85
+ const stripped = stripMutineerArgs(options.cliArgs, {
86
+ extraConsumeNext: ['--shard'],
87
+ extraPrefixes: ['--shard='],
88
+ });
126
89
  this.baseArgs = ensureConfigArg(stripped, options.config.vitestConfig, options.cwd);
127
90
  }
128
91
  async init(concurrencyOverride) {
@@ -190,17 +153,22 @@ export class VitestAdapter {
190
153
  error: result.error,
191
154
  };
192
155
  }
156
+ const status = result.killed ? 'killed' : 'escaped';
193
157
  return {
194
- status: result.killed ? 'killed' : 'escaped',
158
+ status,
195
159
  durationMs: result.durationMs,
196
160
  error: result.error,
161
+ ...(!result.killed &&
162
+ result.passingTests && {
163
+ passingTests: result.passingTests,
164
+ }),
197
165
  };
198
166
  }
199
167
  catch (err) {
200
168
  return {
201
169
  status: 'error',
202
170
  durationMs: 0,
203
- error: err instanceof Error ? err.message : String(err),
171
+ error: toErrorMessage(err),
204
172
  };
205
173
  }
206
174
  }
@@ -43,13 +43,7 @@ export function poolMutineerPlugin() {
43
43
  },
44
44
  load(id) {
45
45
  const cleanId = id.split('?')[0];
46
- let normalizedId;
47
- try {
48
- normalizedId = path.resolve(cleanId);
49
- }
50
- catch {
51
- return null;
52
- }
46
+ const normalizedId = path.resolve(cleanId);
53
47
  // Redirect takes priority: fallback mutations use setRedirect + invalidateFile.
54
48
  // Must check redirect first so the schema file (which exists for this source)
55
49
  // does not shadow the mutant code during fallback runs.
@@ -14,11 +14,11 @@
14
14
  import { spawn } from 'node:child_process';
15
15
  import * as path from 'node:path';
16
16
  import * as readline from 'node:readline';
17
- import * as fs from 'node:fs';
18
17
  import { fileURLToPath } from 'node:url';
19
18
  import { EventEmitter } from 'node:events';
20
- import { getActiveIdFilePath } from '../shared/index.js';
19
+ import { getActiveIdFilePath, resolveWorkerScript, } from '../shared/index.js';
21
20
  import { createLogger, DEBUG } from '../../utils/logger.js';
21
+ import { toErrorMessage } from '../../utils/errors.js';
22
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
23
  const workerLog = createLogger('VitestWorker');
24
24
  const poolLog = createLogger('VitestPool');
@@ -36,22 +36,8 @@ class VitestWorker extends EventEmitter {
36
36
  this.id = id;
37
37
  }
38
38
  async start() {
39
- const workerJs = path.join(__dirname, 'worker.js');
40
- const workerMts = path.join(__dirname, 'worker.mjs');
41
- const workerTs = path.join(__dirname, 'worker.mts');
42
- const workerScript = fs.existsSync(workerJs)
43
- ? workerJs
44
- : fs.existsSync(workerMts)
45
- ? workerMts
46
- : workerTs;
47
- const loaderJs = path.join(__dirname, 'redirect-loader.js');
48
- const loaderMjs = path.join(__dirname, 'redirect-loader.mjs');
49
- const loaderTs = path.join(__dirname, 'redirect-loader.ts');
50
- const loaderScript = fs.existsSync(loaderJs)
51
- ? loaderJs
52
- : fs.existsSync(loaderMjs)
53
- ? loaderMjs
54
- : loaderTs;
39
+ const workerScript = resolveWorkerScript(__dirname, 'worker');
40
+ const loaderScript = resolveWorkerScript(__dirname, 'redirect-loader');
55
41
  const env = {
56
42
  ...process.env,
57
43
  MUTINEER_WORKER_ID: this.id,
@@ -150,6 +136,7 @@ class VitestWorker extends EventEmitter {
150
136
  killed: msg.killed ?? true,
151
137
  durationMs: msg.durationMs ?? 0,
152
138
  error: msg.error,
139
+ ...(msg.passingTests && { passingTests: msg.passingTests }),
153
140
  });
154
141
  }
155
142
  return;
@@ -391,7 +378,7 @@ export async function runWithPool(pool, mutant, tests) {
391
378
  return {
392
379
  status: 'error',
393
380
  durationMs: 0,
394
- error: err instanceof Error ? err.message : String(err),
381
+ error: toErrorMessage(err),
395
382
  };
396
383
  }
397
384
  }
@@ -29,9 +29,11 @@ function tryResolveTsExtension(specifier, parentURL) {
29
29
  if (!specifier.endsWith('.js') || !specifier.startsWith('.')) {
30
30
  return null;
31
31
  }
32
+ if (!parentURL)
33
+ return null;
32
34
  let parentPath;
33
35
  try {
34
- parentPath = fileURLToPath(parentURL ?? '');
36
+ parentPath = fileURLToPath(parentURL);
35
37
  }
36
38
  catch {
37
39
  return null;
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { poolMutineerPlugin } from './plugin.js';
5
5
  import { getMutantFilePath, setRedirect, clearRedirect, } from '../shared/index.js';
6
6
  import { createLogger } from '../../utils/logger.js';
7
+ import { toErrorMessage } from '../../utils/errors.js';
7
8
  const log = createLogger('vitest-runtime');
8
9
  const SETUP_MJS_CONTENT = `import { beforeAll } from 'vitest'
9
10
  import { readFileSync } from 'node:fs'
@@ -105,16 +106,30 @@ export class VitestWorkerRuntime {
105
106
  ? relevantModules
106
107
  : results.testModules;
107
108
  const killed = modulesForDecision.some((mod) => !mod.ok());
109
+ const passingTests = [];
110
+ if (!killed) {
111
+ for (const mod of modulesForDecision) {
112
+ try {
113
+ for (const tc of mod.children?.allTests('passed') ?? []) {
114
+ passingTests.push(tc.fullName);
115
+ }
116
+ }
117
+ catch {
118
+ // allTests API unavailable in this Vitest version
119
+ }
120
+ }
121
+ }
108
122
  return {
109
123
  killed,
110
124
  durationMs: Date.now() - start,
125
+ ...(passingTests.length > 0 && { passingTests }),
111
126
  };
112
127
  }
113
128
  catch (err) {
114
129
  return {
115
130
  killed: true,
116
131
  durationMs: Date.now() - start,
117
- error: err instanceof Error ? err.message : String(err),
132
+ error: toErrorMessage(err),
118
133
  };
119
134
  }
120
135
  finally {
@@ -82,6 +82,7 @@ async function main() {
82
82
  killed: result.killed,
83
83
  durationMs: result.durationMs,
84
84
  error: result.error,
85
+ ...(result.passingTests && { passingTests: result.passingTests }),
85
86
  });
86
87
  }
87
88
  catch (err) {
@@ -24,8 +24,8 @@ export interface MutineerConfig {
24
24
  readonly runner?: 'vitest' | 'jest';
25
25
  readonly vitestConfig?: string;
26
26
  readonly jestConfig?: string;
27
- /** Max depth for dependency resolution with --changed-with-deps (default: 1) */
28
- readonly dependencyDepth?: number;
27
+ /** Max depth for import resolution with --changed-with-imports (default: 1) */
28
+ readonly importDepth?: number;
29
29
  /** Path to coverage JSON file (Istanbul format, e.g., coverage/coverage-final.json) */
30
30
  readonly coverageFile?: string;
31
31
  /** Only mutate lines that are covered by tests (requires coverageFile) */
@@ -31,6 +31,7 @@ export interface MutantCacheEntry extends MutantLocation {
31
31
  readonly originalSnippet?: string;
32
32
  readonly mutatedSnippet?: string;
33
33
  readonly coveringTests?: readonly string[];
34
+ readonly passingTests?: readonly string[];
34
35
  }
35
36
  export interface MutantResult extends MutantCacheEntry {
36
37
  readonly id: string;
@@ -41,10 +42,12 @@ export interface MutantRunSummary {
41
42
  readonly killed: boolean;
42
43
  readonly durationMs: number;
43
44
  readonly error?: string;
45
+ readonly passingTests?: readonly string[];
44
46
  }
45
47
  /** Normalised result returned by adapters/orchestrator. */
46
48
  export interface MutantRunResult {
47
49
  readonly status: MutantRunStatus;
48
50
  readonly durationMs: number;
49
51
  readonly error?: string;
52
+ readonly passingTests?: readonly string[];
50
53
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ vi.mock('ink', () => ({
3
+ Box: ({ children }) => children,
4
+ Text: ({ children }) => children,
5
+ }));
6
+ vi.mock('ink-spinner', () => ({
7
+ default: () => null,
8
+ }));
9
+ describe('PoolSpinner', () => {
10
+ it('renders without throwing', async () => {
11
+ const { PoolSpinner } = await import('../PoolSpinner.js');
12
+ const result = PoolSpinner({ message: 'initializing...' });
13
+ expect(result).toBeDefined();
14
+ });
15
+ });
@@ -188,6 +188,95 @@ describe('coverage utilities', () => {
188
188
  await fs.rm(tmpDir, { recursive: true, force: true });
189
189
  }
190
190
  });
191
+ it('skips non-JSON files in coverage tmp dir', async () => {
192
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
193
+ const covDir = path.join(tmpDir, 'coverage');
194
+ const tmpSubDir = path.join(covDir, 'tmp');
195
+ await fs.mkdir(tmpSubDir, { recursive: true });
196
+ await fs.writeFile(path.join(tmpSubDir, 'data.txt'), 'some text', 'utf8');
197
+ try {
198
+ const map = await loadPerTestCoverageData(covDir, tmpDir);
199
+ expect(map).toBeNull();
200
+ }
201
+ finally {
202
+ await fs.rm(tmpDir, { recursive: true, force: true });
203
+ }
204
+ });
205
+ it('skips invalid JSON files in coverage tmp dir', async () => {
206
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
207
+ const covDir = path.join(tmpDir, 'coverage');
208
+ const tmpSubDir = path.join(covDir, 'tmp');
209
+ await fs.mkdir(tmpSubDir, { recursive: true });
210
+ await fs.writeFile(path.join(tmpSubDir, 'bad.json'), 'not valid json', 'utf8');
211
+ try {
212
+ const map = await loadPerTestCoverageData(covDir, tmpDir);
213
+ expect(map).toBeNull();
214
+ }
215
+ finally {
216
+ await fs.rm(tmpDir, { recursive: true, force: true });
217
+ }
218
+ });
219
+ it('skips format A entries with malformed detail (no lines key)', async () => {
220
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
221
+ const reportsDir = path.join(tmpDir, 'coverage');
222
+ await fs.mkdir(reportsDir, { recursive: true });
223
+ const data = {
224
+ tests: {
225
+ '/test/a.spec.ts': {
226
+ files: {
227
+ '/src/a.ts': { noLines: 'wrong' }, // missing 'lines' key
228
+ },
229
+ },
230
+ },
231
+ };
232
+ await fs.writeFile(path.join(reportsDir, 'per-test-coverage.json'), JSON.stringify(data), 'utf8');
233
+ try {
234
+ const map = await loadPerTestCoverageData(reportsDir, tmpDir);
235
+ // Test entry exists but no file lines were added for the malformed detail
236
+ expect(map).not.toBeNull();
237
+ const fileMap = map.get('/test/a.spec.ts');
238
+ expect(fileMap).toBeDefined();
239
+ expect(fileMap.has('/src/a.ts')).toBe(false);
240
+ }
241
+ finally {
242
+ await fs.rm(tmpDir, { recursive: true, force: true });
243
+ }
244
+ });
245
+ it('skips format B entries with non-array lines value', async () => {
246
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
247
+ const reportsDir = path.join(tmpDir, 'coverage');
248
+ await fs.mkdir(reportsDir, { recursive: true });
249
+ const data = {
250
+ '/test/a.spec.ts': {
251
+ '/src/a.ts': 'not-an-array', // non-array value
252
+ },
253
+ };
254
+ await fs.writeFile(path.join(reportsDir, 'per-test-coverage.json'), JSON.stringify(data), 'utf8');
255
+ try {
256
+ const map = await loadPerTestCoverageData(reportsDir, tmpDir);
257
+ // Test entry exists but no file lines were added for the non-array value
258
+ expect(map).not.toBeNull();
259
+ const fileMap = map.get('/test/a.spec.ts');
260
+ expect(fileMap).toBeDefined();
261
+ expect(fileMap.has('/src/a.ts')).toBe(false);
262
+ }
263
+ finally {
264
+ await fs.rm(tmpDir, { recursive: true, force: true });
265
+ }
266
+ });
267
+ it('gracefully handles null JSON in per-test coverage file', async () => {
268
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
269
+ const covDir = path.join(tmpDir, 'coverage');
270
+ await fs.mkdir(covDir, { recursive: true });
271
+ await fs.writeFile(path.join(covDir, 'per-test-coverage.json'), 'null', 'utf8');
272
+ try {
273
+ const map = await loadPerTestCoverageData(covDir, tmpDir);
274
+ expect(map).toBeNull();
275
+ }
276
+ finally {
277
+ await fs.rm(tmpDir, { recursive: true, force: true });
278
+ }
279
+ });
191
280
  it('uses relative reportsDir when not absolute', async () => {
192
281
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-coverage-'));
193
282
  const reportsDir = path.join(tmpDir, 'reports');
@@ -58,4 +58,13 @@ describe('logger', () => {
58
58
  expect(spy).toHaveBeenCalledWith(expect.stringContaining('[custom-tag]'));
59
59
  }
60
60
  });
61
+ it('debug calls console.error when MUTINEER_DEBUG=1', async () => {
62
+ process.env.MUTINEER_DEBUG = '1';
63
+ vi.resetModules();
64
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
65
+ const { createLogger } = await import('../logger.js');
66
+ const log = createLogger('dbg-tag');
67
+ log.debug('debug msg');
68
+ expect(spy).toHaveBeenCalledWith('[dbg-tag] debug msg');
69
+ });
61
70
  });
@@ -114,6 +114,44 @@ describe('Progress', () => {
114
114
  });
115
115
  }
116
116
  });
117
+ it('defaults to bar mode when no opts provided', () => {
118
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
119
+ const progress = new Progress(2);
120
+ progress.start();
121
+ // Non-TTY path: logs via console
122
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('running 2 mutants'));
123
+ progress.finish();
124
+ logSpy.mockRestore();
125
+ });
126
+ it('uses 80 column fallback when stream.columns is undefined', () => {
127
+ const writeSpy = vi.fn();
128
+ const fakeStream = {
129
+ isTTY: true,
130
+ write: writeSpy,
131
+ columns: undefined,
132
+ };
133
+ const origStderr = process.stderr;
134
+ Object.defineProperty(process, 'stderr', {
135
+ value: fakeStream,
136
+ writable: true,
137
+ configurable: true,
138
+ });
139
+ try {
140
+ vi.spyOn(console, 'log').mockImplementation(() => { });
141
+ const progress = new Progress(3, { mode: 'bar' });
142
+ progress.start();
143
+ // Should not throw; bar rendered with 80 column fallback
144
+ expect(writeSpy).toHaveBeenCalled();
145
+ progress.finish();
146
+ }
147
+ finally {
148
+ Object.defineProperty(process, 'stderr', {
149
+ value: origStderr,
150
+ writable: true,
151
+ configurable: true,
152
+ });
153
+ }
154
+ });
117
155
  it('handles zero total in bar mode', () => {
118
156
  const writeSpy = vi.fn();
119
157
  const fakeStream = {