@mutineerjs/mutineer 0.10.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 (75) hide show
  1. package/README.md +10 -10
  2. package/dist/__tests__/index.spec.d.ts +1 -0
  3. package/dist/__tests__/index.spec.js +8 -0
  4. package/dist/bin/__tests__/mutineer.spec.js +7 -7
  5. package/dist/bin/mutineer.d.ts +1 -1
  6. package/dist/bin/mutineer.js +4 -4
  7. package/dist/core/__tests__/schemata.spec.js +62 -0
  8. package/dist/core/__tests__/sfc.spec.js +41 -1
  9. package/dist/core/schemata.js +15 -21
  10. package/dist/core/sfc.js +0 -4
  11. package/dist/core/variant-utils.js +0 -4
  12. package/dist/mutators/__tests__/utils.spec.js +65 -1
  13. package/dist/mutators/operator.js +13 -27
  14. package/dist/mutators/return-value.js +3 -7
  15. package/dist/mutators/utils.d.ts +2 -2
  16. package/dist/mutators/utils.js +59 -96
  17. package/dist/runner/__tests__/args.spec.js +8 -4
  18. package/dist/runner/__tests__/cache.spec.js +24 -0
  19. package/dist/runner/__tests__/changed.spec.js +75 -0
  20. package/dist/runner/__tests__/config.spec.js +50 -1
  21. package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
  22. package/dist/runner/__tests__/discover.spec.js +179 -0
  23. package/dist/runner/__tests__/orchestrator.spec.js +301 -8
  24. package/dist/runner/__tests__/pool-executor.spec.js +77 -0
  25. package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
  26. package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
  27. package/dist/runner/__tests__/ts-checker.spec.js +89 -2
  28. package/dist/runner/args.d.ts +1 -1
  29. package/dist/runner/args.js +2 -2
  30. package/dist/runner/config.js +3 -4
  31. package/dist/runner/coverage-resolver.js +2 -1
  32. package/dist/runner/discover.js +2 -2
  33. package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
  34. package/dist/runner/jest/__tests__/pool.spec.js +223 -1
  35. package/dist/runner/jest/adapter.js +3 -46
  36. package/dist/runner/jest/pool.js +4 -10
  37. package/dist/runner/jest/worker-runtime.js +2 -1
  38. package/dist/runner/orchestrator.js +7 -7
  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 +254 -0
  56. package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
  57. package/dist/runner/vitest/__tests__/pool.spec.js +674 -0
  58. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
  59. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +21 -0
  60. package/dist/runner/vitest/adapter.js +8 -46
  61. package/dist/runner/vitest/plugin.js +1 -7
  62. package/dist/runner/vitest/pool.js +5 -19
  63. package/dist/runner/vitest/redirect-loader.js +3 -1
  64. package/dist/runner/vitest/worker-runtime.js +2 -1
  65. package/dist/types/config.d.ts +2 -2
  66. package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
  67. package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
  68. package/dist/utils/__tests__/coverage.spec.js +89 -0
  69. package/dist/utils/__tests__/logger.spec.js +9 -0
  70. package/dist/utils/__tests__/progress.spec.js +38 -0
  71. package/dist/utils/__tests__/summary.spec.js +56 -0
  72. package/dist/utils/coverage.js +3 -4
  73. package/dist/utils/errors.d.ts +4 -0
  74. package/dist/utils/errors.js +6 -0
  75. package/package.json +1 -1
@@ -131,77 +131,71 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
131
131
  const updateTargets = new Map();
132
132
  const assignmentTargets = new Map();
133
133
  function handleBinaryOrLogical(n) {
134
- const nodeStart = n.start ?? 0;
135
- const nodeEnd = n.end ?? 0;
134
+ const nodeStart = n.start;
135
+ const nodeEnd = n.end;
136
136
  const opValue = n.operator;
137
137
  const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
138
- if (tok) {
139
- const line = tok.loc.start.line;
140
- if (ignoreLines.has(line))
141
- return;
142
- const visualCol = getVisualColumn(src, tok.start);
143
- let arr = operatorTargets.get(opValue);
144
- if (!arr) {
145
- arr = [];
146
- operatorTargets.set(opValue, arr);
147
- }
148
- arr.push({
149
- start: tok.start,
150
- end: tok.end,
151
- line,
152
- col1: visualCol,
153
- op: opValue,
154
- });
138
+ const line = tok.loc.start.line;
139
+ if (ignoreLines.has(line))
140
+ return;
141
+ const visualCol = getVisualColumn(src, tok.start);
142
+ let arr = operatorTargets.get(opValue);
143
+ if (!arr) {
144
+ arr = [];
145
+ operatorTargets.set(opValue, arr);
155
146
  }
147
+ arr.push({
148
+ start: tok.start,
149
+ end: tok.end,
150
+ line,
151
+ col1: visualCol,
152
+ op: opValue,
153
+ });
156
154
  }
157
155
  function handleUpdate(n) {
158
- const nodeStart = n.start ?? 0;
159
- const nodeEnd = n.end ?? 0;
156
+ const nodeStart = n.start;
157
+ const nodeEnd = n.end;
160
158
  const opValue = n.operator;
161
159
  const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
162
- if (tok) {
163
- const line = tok.loc.start.line;
164
- if (ignoreLines.has(line))
165
- return;
166
- const visualCol = getVisualColumn(src, tok.start);
167
- const mapKey = (n.prefix ? 'pre' : 'post') + opValue;
168
- let arr = updateTargets.get(mapKey);
169
- if (!arr) {
170
- arr = [];
171
- updateTargets.set(mapKey, arr);
172
- }
173
- arr.push({
174
- start: tok.start,
175
- end: tok.end,
176
- line,
177
- col1: visualCol,
178
- op: opValue,
179
- });
160
+ const line = tok.loc.start.line;
161
+ if (ignoreLines.has(line))
162
+ return;
163
+ const visualCol = getVisualColumn(src, tok.start);
164
+ const mapKey = (n.prefix ? 'pre' : 'post') + opValue;
165
+ let arr = updateTargets.get(mapKey);
166
+ if (!arr) {
167
+ arr = [];
168
+ updateTargets.set(mapKey, arr);
180
169
  }
170
+ arr.push({
171
+ start: tok.start,
172
+ end: tok.end,
173
+ line,
174
+ col1: visualCol,
175
+ op: opValue,
176
+ });
181
177
  }
182
178
  function handleAssignment(n) {
183
- const nodeStart = n.start ?? 0;
184
- const nodeEnd = n.end ?? 0;
179
+ const nodeStart = n.start;
180
+ const nodeEnd = n.end;
185
181
  const opValue = n.operator;
186
182
  const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
187
- if (tok) {
188
- const line = tok.loc.start.line;
189
- if (ignoreLines.has(line))
190
- return;
191
- const visualCol = getVisualColumn(src, tok.start);
192
- let arr = assignmentTargets.get(opValue);
193
- if (!arr) {
194
- arr = [];
195
- assignmentTargets.set(opValue, arr);
196
- }
197
- arr.push({
198
- start: tok.start,
199
- end: tok.end,
200
- line,
201
- col1: visualCol,
202
- op: opValue,
203
- });
183
+ const line = tok.loc.start.line;
184
+ if (ignoreLines.has(line))
185
+ return;
186
+ const visualCol = getVisualColumn(src, tok.start);
187
+ let arr = assignmentTargets.get(opValue);
188
+ if (!arr) {
189
+ arr = [];
190
+ assignmentTargets.set(opValue, arr);
204
191
  }
192
+ arr.push({
193
+ start: tok.start,
194
+ end: tok.end,
195
+ line,
196
+ col1: visualCol,
197
+ op: opValue,
198
+ });
205
199
  }
206
200
  traverse(ast, {
207
201
  BinaryExpression(p) {
@@ -220,16 +214,12 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
220
214
  const node = p.node;
221
215
  if (!node.argument)
222
216
  return;
223
- const line = node.loc?.start.line;
224
- if (line === undefined)
225
- return;
217
+ const line = node.loc.start.line;
226
218
  if (ignoreLines.has(line))
227
219
  return;
228
220
  const argStart = node.argument.start;
229
221
  const argEnd = node.argument.end;
230
- if (argStart == null || argEnd == null)
231
- return;
232
- const col = getVisualColumn(src, node.start ?? 0);
222
+ const col = getVisualColumn(src, node.start);
233
223
  returnStatements.push({
234
224
  line,
235
225
  col,
@@ -247,44 +237,17 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
247
237
  */
248
238
  export function buildParseContext(src) {
249
239
  const ast = parseSource(src);
250
- const tokens = ast.tokens ?? [];
251
- const ignoreLines = buildIgnoreLines(ast.comments ?? []);
240
+ const tokens = ast.tokens;
241
+ const ignoreLines = buildIgnoreLines(ast.comments);
252
242
  const preCollected = collectAllTargets(src, ast, tokens, ignoreLines);
253
243
  return { ast, tokens, ignoreLines, preCollected };
254
244
  }
255
245
  /**
256
246
  * Collect operator targets from a pre-built ParseContext.
257
- * Avoids re-parsing; use when processing multiple operators on the same source.
247
+ * Reads directly from preCollected targets; no additional traversal needed.
258
248
  */
259
- export function collectOperatorTargetsFromContext(src, ctx, opValue) {
260
- const { ast, tokens, ignoreLines } = ctx;
261
- const out = [];
262
- traverse(ast, {
263
- enter(p) {
264
- if (!isBinaryOrLogical(p.node))
265
- return;
266
- const n = p.node;
267
- if (n.operator !== opValue)
268
- return;
269
- const nodeStart = n.start ?? 0;
270
- const nodeEnd = n.end ?? 0;
271
- const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
272
- if (tok) {
273
- const line = tok.loc.start.line;
274
- if (ignoreLines.has(line))
275
- return;
276
- const visualCol = getVisualColumn(src, tok.start);
277
- out.push({
278
- start: tok.start,
279
- end: tok.end,
280
- line,
281
- col1: visualCol,
282
- op: opValue,
283
- });
284
- }
285
- },
286
- });
287
- return out;
249
+ export function collectOperatorTargetsFromContext(_src, ctx, opValue) {
250
+ return ctx.preCollected.operatorTargets.get(opValue) ?? [];
288
251
  }
289
252
  /**
290
253
  * Collect the operator tokens for a given operator and return their exact locations.
@@ -102,6 +102,10 @@ describe('parseConcurrency', () => {
102
102
  const result = parseConcurrency(['--concurrency', 'abc']);
103
103
  expect(result).toBeGreaterThanOrEqual(1);
104
104
  });
105
+ it('handles --concurrency flag with no following value', () => {
106
+ const result = parseConcurrency(['--concurrency']);
107
+ expect(result).toBeGreaterThanOrEqual(1);
108
+ });
105
109
  });
106
110
  describe('parseProgressMode', () => {
107
111
  it('returns bar by default', () => {
@@ -125,11 +129,11 @@ describe('parseCliOptions', () => {
125
129
  it('parses --changed flag', () => {
126
130
  const opts = parseCliOptions(['--changed'], emptyCfg);
127
131
  expect(opts.wantsChanged).toBe(true);
128
- expect(opts.wantsChangedWithDeps).toBe(false);
132
+ expect(opts.wantsChangedWithImports).toBe(false);
129
133
  });
130
- it('parses --changed-with-deps flag', () => {
131
- const opts = parseCliOptions(['--changed-with-deps'], emptyCfg);
132
- expect(opts.wantsChangedWithDeps).toBe(true);
134
+ it('parses --changed-with-imports flag', () => {
135
+ const opts = parseCliOptions(['--changed-with-imports'], emptyCfg);
136
+ expect(opts.wantsChangedWithImports).toBe(true);
133
137
  });
134
138
  it('parses --full flag', () => {
135
139
  const opts = parseCliOptions(['--full'], emptyCfg);
@@ -137,6 +137,13 @@ describe('decodeCacheKey', () => {
137
137
  expect(decoded.line).toBe(10);
138
138
  expect(decoded.col).toBe(5);
139
139
  });
140
+ it('falls back to full key when file segment is empty', () => {
141
+ // a:b::1,0:mutator → after parsing, restAfterFirst.slice(secondColon+1) = '' → falls back to key
142
+ const key = 'a:b::1,0:mutator';
143
+ const decoded = decodeCacheKey(key);
144
+ expect(decoded.file).toBe(key);
145
+ expect(decoded.mutator).toBe('mutator');
146
+ });
140
147
  });
141
148
  describe('keyForTests', () => {
142
149
  it('produces deterministic keys regardless of input order', () => {
@@ -220,6 +227,23 @@ describe('readMutantCache', () => {
220
227
  const result = await readMutantCache(tmpDir);
221
228
  expect(result).toEqual({});
222
229
  });
230
+ it('skips object-format entries without a status field', async () => {
231
+ const cache = {
232
+ 'testsig:codesig:file.ts:1,0:flip': { someOtherField: 'foo' },
233
+ };
234
+ await fs.writeFile(path.join(tmpDir, '.mutineer-cache.json'), JSON.stringify(cache));
235
+ const result = await readMutantCache(tmpDir);
236
+ // Entry has no status, so it is skipped entirely
237
+ expect(result['testsig:codesig:file.ts:1,0:flip']).toBeUndefined();
238
+ });
239
+ it('defaults status to skipped when status is null in object entry', async () => {
240
+ const cache = {
241
+ 'testsig:codesig:file.ts:1,0:flip': { status: null },
242
+ };
243
+ await fs.writeFile(path.join(tmpDir, '.mutineer-cache.json'), JSON.stringify(cache));
244
+ const result = await readMutantCache(tmpDir);
245
+ expect(result['testsig:codesig:file.ts:1,0:flip'].status).toBe('skipped');
246
+ });
223
247
  it('normalizes partial object entries with decoded fallbacks', async () => {
224
248
  const cache = {
225
249
  'testsig:codesig:file.ts:5,3:mut': {
@@ -274,6 +274,24 @@ describe('listChangedFiles', () => {
274
274
  const result = listChangedFiles('/repo', { includeDeps: true });
275
275
  expect(result).toContain('/repo/src/foo.ts');
276
276
  });
277
+ it('handles readFileSync throwing during dep resolution', () => {
278
+ spawnSyncMock.mockImplementation((_cmd, args) => {
279
+ if (args.includes('--show-toplevel')) {
280
+ return { status: 0, stdout: '/repo\n' };
281
+ }
282
+ if (args.includes('main...HEAD')) {
283
+ return { status: 0, stdout: 'src/foo.ts\0' };
284
+ }
285
+ return { status: 0, stdout: '' };
286
+ });
287
+ // existsSync always returns true so we get past the existsSync check in resolveLocalDeps
288
+ existsSyncMock.mockReturnValue(true);
289
+ readFileSyncMock.mockImplementation(() => {
290
+ throw new Error('ENOENT');
291
+ });
292
+ const result = listChangedFiles('/repo', { includeDeps: true });
293
+ expect(result).toContain('/repo/src/foo.ts');
294
+ });
277
295
  it('respects maxDepth option', () => {
278
296
  spawnSyncMock.mockImplementation((_cmd, args) => {
279
297
  if (args.includes('--show-toplevel')) {
@@ -292,6 +310,63 @@ describe('listChangedFiles', () => {
292
310
  // maxDepth=0 means no recursion into deps
293
311
  expect(result).toContain('/repo/src/foo.ts');
294
312
  });
313
+ it('logs warning when no git repo found and quiet is not set', () => {
314
+ spawnSyncMock.mockReturnValue({ status: 1, stdout: '' });
315
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
316
+ const result = listChangedFiles('/not-a-repo');
317
+ expect(result).toEqual([]);
318
+ expect(warnSpy).toHaveBeenCalled();
319
+ warnSpy.mockRestore();
320
+ });
321
+ it('handles circular dependencies without infinite recursion', () => {
322
+ spawnSyncMock.mockImplementation((_cmd, args) => {
323
+ if (args.includes('--show-toplevel'))
324
+ return { status: 0, stdout: '/repo\n' };
325
+ if (args.includes('main...HEAD'))
326
+ return { status: 0, stdout: 'src/a.ts\0' };
327
+ return { status: 0, stdout: '' };
328
+ });
329
+ readFileSyncMock.mockImplementation((p) => {
330
+ if (p === '/repo/src/a.ts')
331
+ return 'import { b } from "./b"';
332
+ if (p === '/repo/src/b.ts')
333
+ return 'import { a } from "./a"';
334
+ return '';
335
+ });
336
+ existsSyncMock.mockImplementation((p) => ['/repo/src/a.ts', '/repo/src/b.ts'].includes(p));
337
+ const result = listChangedFiles('/repo', { includeDeps: true, maxDepth: 3 });
338
+ expect(result).toContain('/repo/src/a.ts');
339
+ expect(result).toContain('/repo/src/b.ts');
340
+ });
341
+ it('skips test file dependencies', () => {
342
+ spawnSyncMock.mockImplementation((_cmd, args) => {
343
+ if (args.includes('--show-toplevel'))
344
+ return { status: 0, stdout: '/repo\n' };
345
+ if (args.includes('main...HEAD'))
346
+ return { status: 0, stdout: 'src/a.ts\0' };
347
+ return { status: 0, stdout: '' };
348
+ });
349
+ readFileSyncMock.mockReturnValue('import { x } from "./a.spec"');
350
+ existsSyncMock.mockImplementation((p) => ['/repo/src/a.ts', '/repo/src/a.spec.ts'].includes(p));
351
+ const result = listChangedFiles('/repo', { includeDeps: true });
352
+ expect(result).not.toContain('/repo/src/a.spec.ts');
353
+ });
354
+ it('handles absolute paths returned by git', () => {
355
+ // The code strips one leading slash via replace(/^\.?\//,'').
356
+ // A double-slash path like '//repo/src/abs.ts' still resolves as absolute after that strip.
357
+ spawnSyncMock.mockImplementation((_cmd, args) => {
358
+ if (args.includes('--show-toplevel')) {
359
+ return { status: 0, stdout: '/repo\n' };
360
+ }
361
+ if (args.includes('main...HEAD')) {
362
+ return { status: 0, stdout: '//repo/src/abs.ts\0' };
363
+ }
364
+ return { status: 0, stdout: '' };
365
+ });
366
+ existsSyncMock.mockImplementation((p) => p === '/repo/src/abs.ts');
367
+ const result = listChangedFiles('/repo');
368
+ expect(result).toContain('/repo/src/abs.ts');
369
+ });
295
370
  it('only processes source files for dependency resolution', () => {
296
371
  spawnSyncMock.mockImplementation((_cmd, args) => {
297
372
  if (args.includes('--show-toplevel')) {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
@@ -57,4 +57,53 @@ describe('loadMutineerConfig', () => {
57
57
  await fs.writeFile(configFile, 'export default null');
58
58
  await expect(loadMutineerConfig(tmpDir)).rejects.toThrow('does not export a valid configuration object');
59
59
  });
60
+ it('loads a .ts config file via vite', async () => {
61
+ const configFile = path.join(tmpDir, 'mutineer.config.ts');
62
+ await fs.writeFile(configFile, 'export default { runner: "vitest" }\n');
63
+ const config = await loadMutineerConfig(tmpDir);
64
+ expect(config).toEqual({ runner: 'vitest' });
65
+ });
66
+ it('throws with helpful message when TypeScript config fails to load', async () => {
67
+ vi.doMock('vite', () => ({
68
+ loadConfigFromFile: vi
69
+ .fn()
70
+ .mockRejectedValue(new Error('vite not available')),
71
+ }));
72
+ vi.resetModules();
73
+ const { loadMutineerConfig: loadConfig } = await import('../config.js');
74
+ const configFile = path.join(tmpDir, 'mutineer.config.ts');
75
+ await fs.writeFile(configFile, 'export default { runner: "vitest" }');
76
+ await expect(loadConfig(tmpDir)).rejects.toThrow('Cannot load TypeScript config');
77
+ vi.doUnmock('vite');
78
+ });
79
+ it('loads a module with named exports only (no default export)', async () => {
80
+ const configFile = path.join(tmpDir, 'mutineer.config.mjs');
81
+ // No `export default` — exercises the `mod` fallback in loadModule
82
+ await fs.writeFile(configFile, 'export const runner = "vitest"');
83
+ const config = await loadMutineerConfig(tmpDir);
84
+ expect(config.runner).toBe('vitest');
85
+ });
86
+ it('returns empty config when Vite loadConfigFromFile returns null', async () => {
87
+ vi.doMock('vite', () => ({
88
+ loadConfigFromFile: vi.fn().mockResolvedValue(null),
89
+ }));
90
+ vi.resetModules();
91
+ const { loadMutineerConfig: loadConfig } = await import('../config.js');
92
+ const configFile = path.join(tmpDir, 'mutineer.config.ts');
93
+ await fs.writeFile(configFile, 'export default {}');
94
+ const config = await loadConfig(tmpDir);
95
+ expect(config).toEqual({});
96
+ vi.doUnmock('vite');
97
+ });
98
+ it('stringifies non-Error thrown by loadConfigFromFile', async () => {
99
+ vi.doMock('vite', () => ({
100
+ loadConfigFromFile: vi.fn().mockRejectedValue('plain string error'),
101
+ }));
102
+ vi.resetModules();
103
+ const { loadMutineerConfig: loadConfig } = await import('../config.js');
104
+ const configFile = path.join(tmpDir, 'mutineer.config.ts');
105
+ await fs.writeFile(configFile, 'export default {}');
106
+ await expect(loadConfig(tmpDir)).rejects.toThrow('plain string error');
107
+ vi.doUnmock('vite');
108
+ });
60
109
  });
@@ -7,7 +7,7 @@ function makeOpts(overrides = {}) {
7
7
  return {
8
8
  configPath: undefined,
9
9
  wantsChanged: false,
10
- wantsChangedWithDeps: false,
10
+ wantsChangedWithImports: false,
11
11
  wantsFull: false,
12
12
  wantsOnlyCoveredLines: false,
13
13
  wantsPerTestCoverage: false,
@@ -112,6 +112,59 @@ describe('resolveCoverageConfig', () => {
112
112
  expect(result.needsCoverageFromBaseline).toBe(true);
113
113
  expect(result.enableCoverageForBaseline).toBe(true);
114
114
  });
115
+ it('loads coverage data when coverageFilePath is provided', async () => {
116
+ const coverageJson = {
117
+ '/src/foo.ts': {
118
+ path: '/src/foo.ts',
119
+ statementMap: {
120
+ '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
121
+ },
122
+ s: { '0': 1 },
123
+ },
124
+ };
125
+ const covDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-cov-file-'));
126
+ const covFile = path.join(covDir, 'coverage-final.json');
127
+ await fs.writeFile(covFile, JSON.stringify(coverageJson));
128
+ try {
129
+ const opts = makeOpts({ coverageFilePath: covFile });
130
+ const result = await resolveCoverageConfig(opts, {}, makeAdapter(), []);
131
+ expect(result.coverageData).not.toBeNull();
132
+ expect(result.coverageData.coveredLines.size).toBeGreaterThan(0);
133
+ }
134
+ finally {
135
+ await fs.rm(covDir, { recursive: true, force: true });
136
+ }
137
+ });
138
+ it('logs warning when onlyCoveredLines + coverageData + no coverage provider', async () => {
139
+ const coverageJson = {
140
+ '/src/foo.ts': {
141
+ path: '/src/foo.ts',
142
+ statementMap: {
143
+ '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
144
+ },
145
+ s: { '0': 1 },
146
+ },
147
+ };
148
+ const covDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-cov-warn-'));
149
+ const covFile = path.join(covDir, 'coverage-final.json');
150
+ await fs.writeFile(covFile, JSON.stringify(coverageJson));
151
+ try {
152
+ const opts = makeOpts({
153
+ wantsOnlyCoveredLines: true,
154
+ coverageFilePath: covFile,
155
+ });
156
+ const adapter = makeAdapter({
157
+ hasCoverageProvider: vi.fn().mockReturnValue(false),
158
+ });
159
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
160
+ await resolveCoverageConfig(opts, {}, adapter, []);
161
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('onlyCoveredLines'));
162
+ warnSpy.mockRestore();
163
+ }
164
+ finally {
165
+ await fs.rm(covDir, { recursive: true, force: true });
166
+ }
167
+ });
115
168
  });
116
169
  describe('loadCoverageAfterBaseline', () => {
117
170
  let tmpDir;
@@ -179,4 +232,38 @@ describe('loadCoverageAfterBaseline', () => {
179
232
  const result = await loadCoverageAfterBaseline(resolution, tmpDir);
180
233
  expect(result.perTestCoverage).toBeNull();
181
234
  });
235
+ it('loads per-test coverage when wantsPerTestCoverage is true', async () => {
236
+ const coverageDir = path.join(tmpDir, 'coverage');
237
+ await fs.mkdir(coverageDir, { recursive: true });
238
+ const testFile = '/test/foo.spec.ts';
239
+ const srcFile = '/src/foo.ts';
240
+ const perTestData = {
241
+ [testFile]: { [srcFile]: [1, 2, 3] },
242
+ };
243
+ await fs.writeFile(path.join(coverageDir, 'per-test-coverage.json'), JSON.stringify(perTestData));
244
+ const resolution = {
245
+ coverageData: null,
246
+ perTestCoverage: null,
247
+ enableCoverageForBaseline: true,
248
+ wantsPerTestCoverage: true,
249
+ needsCoverageFromBaseline: false,
250
+ };
251
+ const result = await loadCoverageAfterBaseline(resolution, tmpDir);
252
+ expect(result.perTestCoverage).not.toBeNull();
253
+ expect(result.perTestCoverage.size).toBeGreaterThan(0);
254
+ });
255
+ it('logs warning when per-test coverage data is not found', async () => {
256
+ const resolution = {
257
+ coverageData: null,
258
+ perTestCoverage: null,
259
+ enableCoverageForBaseline: true,
260
+ wantsPerTestCoverage: true,
261
+ needsCoverageFromBaseline: false,
262
+ };
263
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
264
+ const result = await loadCoverageAfterBaseline(resolution, tmpDir);
265
+ expect(result.perTestCoverage).toBeNull();
266
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Per-test coverage data not found'));
267
+ warnSpy.mockRestore();
268
+ });
182
269
  });