@mutineerjs/mutineer 0.4.0 → 0.5.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.
@@ -35,6 +35,34 @@ vi.mock('vite', async () => {
35
35
  };
36
36
  });
37
37
  describe('autoDiscoverTargetsAndTests', () => {
38
+ it('directTestMap only includes direct importers', async () => {
39
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct-'));
40
+ const srcDir = path.join(tmpDir, 'src');
41
+ const moduleX = path.join(srcDir, 'x.ts');
42
+ const moduleY = path.join(srcDir, 'y.ts');
43
+ const testFile = path.join(srcDir, 'a.test.ts');
44
+ await fs.mkdir(srcDir, { recursive: true });
45
+ await fs.writeFile(moduleY, 'export const y = 2\n', 'utf8');
46
+ const importY = ['im', 'port { y } from "./y"'].join('');
47
+ await fs.writeFile(moduleX, `${importY}\nexport const x = 1\n`, 'utf8');
48
+ const importX = ['im', 'port { x } from "./x"'].join('');
49
+ await fs.writeFile(testFile, `${importX}\nconsole.log(x)\n`, 'utf8');
50
+ try {
51
+ const { directTestMap } = await autoDiscoverTargetsAndTests(tmpDir, {
52
+ testPatterns: ['**/*.test.ts'],
53
+ });
54
+ const testAbs = normalizePath(testFile);
55
+ const xAbs = normalizePath(moduleX);
56
+ const yAbs = normalizePath(moduleY);
57
+ // x is directly imported by the test
58
+ expect(directTestMap.get(xAbs)?.has(testAbs)).toBe(true);
59
+ // y is only transitively imported (x -> y), not directly by the test
60
+ expect(directTestMap.get(yAbs)?.has(testAbs)).toBeFalsy();
61
+ }
62
+ finally {
63
+ await fs.rm(tmpDir, { recursive: true, force: true });
64
+ }
65
+ });
38
66
  it('ignores test files when collecting mutate targets', async () => {
39
67
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-'));
40
68
  const srcDir = path.join(tmpDir, 'src');
@@ -286,6 +286,90 @@ describe('executePool', () => {
286
286
  expect(cache['missing-file-key'].status).toBe('escaped');
287
287
  expect(cache['missing-file-key'].originalSnippet).toBeUndefined();
288
288
  });
289
+ it('escaped mutant stores coveringTests', async () => {
290
+ const tmpFile = path.join(tmpDir, 'covering.ts');
291
+ await fs.writeFile(tmpFile, 'const x = a + b\n');
292
+ const adapter = makeAdapter({
293
+ runMutant: vi
294
+ .fn()
295
+ .mockResolvedValue({ status: 'escaped', durationMs: 1 }),
296
+ });
297
+ const cache = {};
298
+ const tests = ['/tests/foo.spec.ts', '/tests/bar.spec.ts'];
299
+ const task = makeTask({
300
+ key: 'covering-key',
301
+ tests,
302
+ v: {
303
+ id: 'covering.ts#0',
304
+ name: 'flipArith',
305
+ file: tmpFile,
306
+ code: 'const x = a - b\n',
307
+ line: 1,
308
+ col: 10,
309
+ tests,
310
+ },
311
+ });
312
+ await executePool({
313
+ tasks: [task],
314
+ adapter,
315
+ cache,
316
+ concurrency: 1,
317
+ progressMode: 'list',
318
+ cwd: tmpDir,
319
+ });
320
+ expect(cache['covering-key'].coveringTests).toEqual(tests);
321
+ });
322
+ it('escaped mutant uses directTests for coveringTests when available', async () => {
323
+ const tmpFile = path.join(tmpDir, 'direct-covering.ts');
324
+ await fs.writeFile(tmpFile, 'const x = a + b\n');
325
+ const adapter = makeAdapter({
326
+ runMutant: vi
327
+ .fn()
328
+ .mockResolvedValue({ status: 'escaped', durationMs: 1 }),
329
+ });
330
+ const cache = {};
331
+ const directTests = ['/direct.spec.ts'];
332
+ const allTests = ['/direct.spec.ts', '/transitive.spec.ts'];
333
+ const task = makeTask({
334
+ key: 'direct-covering-key',
335
+ tests: allTests,
336
+ directTests,
337
+ v: {
338
+ id: 'direct-covering.ts#0',
339
+ name: 'flipArith',
340
+ file: tmpFile,
341
+ code: 'const x = a - b\n',
342
+ line: 1,
343
+ col: 10,
344
+ tests: allTests,
345
+ },
346
+ });
347
+ await executePool({
348
+ tasks: [task],
349
+ adapter,
350
+ cache,
351
+ concurrency: 1,
352
+ progressMode: 'list',
353
+ cwd: tmpDir,
354
+ });
355
+ expect(cache['direct-covering-key'].coveringTests).toEqual(directTests);
356
+ });
357
+ it('killed mutant does not store coveringTests', async () => {
358
+ const adapter = makeAdapter({
359
+ runMutant: vi.fn().mockResolvedValue({ status: 'killed', durationMs: 1 }),
360
+ });
361
+ const cache = {};
362
+ const task = makeTask({ key: 'killed-covering-key' });
363
+ await executePool({
364
+ tasks: [task],
365
+ adapter,
366
+ cache,
367
+ concurrency: 1,
368
+ progressMode: 'list',
369
+ cwd: tmpDir,
370
+ });
371
+ expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
372
+ });
289
373
  it('handles adapter errors gracefully and still shuts down', async () => {
290
374
  const adapter = makeAdapter({
291
375
  runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
@@ -80,6 +80,20 @@ describe('prepareTasks', () => {
80
80
  const tasks = prepareTasks([v], null);
81
81
  expect(tasks[0].tests).toHaveLength(2);
82
82
  });
83
+ it('populates directTests from directTestMap', () => {
84
+ const v = makeVariant({ file: '/src/file.ts', tests: ['/tests/a.test.ts'] });
85
+ const directTestMap = new Map([
86
+ ['/src/file.ts', new Set(['/tests/direct.test.ts'])],
87
+ ]);
88
+ const tasks = prepareTasks([v], null, directTestMap);
89
+ expect(tasks[0].directTests).toEqual(['/tests/direct.test.ts']);
90
+ });
91
+ it('directTests absent when not in directTestMap', () => {
92
+ const v = makeVariant({ file: '/src/file.ts', tests: ['/tests/a.test.ts'] });
93
+ const directTestMap = new Map();
94
+ const tasks = prepareTasks([v], null, directTestMap);
95
+ expect(tasks[0].directTests).toBeUndefined();
96
+ });
83
97
  it('handles multiple variants', () => {
84
98
  const variants = [
85
99
  makeVariant({ id: 'file.ts#0', code: 'code1' }),
@@ -3,5 +3,6 @@ export type TestMap = Map<string, Set<string>>;
3
3
  export interface DiscoveryResult {
4
4
  readonly targets: MutateTarget[];
5
5
  readonly testMap: TestMap;
6
+ readonly directTestMap: TestMap;
6
7
  }
7
8
  export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig): Promise<DiscoveryResult>;
@@ -208,12 +208,13 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
208
208
  ignore,
209
209
  });
210
210
  if (!tests.length)
211
- return { targets: [], testMap: new Map() };
211
+ return { targets: [], testMap: new Map(), directTestMap: new Map() };
212
212
  const testSet = new Set(tests.map((t) => normalizePath(t)));
213
213
  // 2) Create resolver (Vite if available, otherwise Node-based fallback)
214
214
  const { resolve, cleanup } = await createResolver(rootAbs, exts);
215
215
  const targets = new Map();
216
216
  const testMap = new Map();
217
+ const directTestMap = new Map();
217
218
  const contentCache = new Map();
218
219
  const resolveCache = new Map(); // key: importer\0spec -> resolved id
219
220
  async function crawl(absFile, depth, seen, currentTestAbs) {
@@ -242,6 +243,11 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
242
243
  if (!testMap.has(key))
243
244
  testMap.set(key, new Set());
244
245
  testMap.get(key).add(currentTestAbs);
246
+ if (depth === 0) {
247
+ if (!directTestMap.has(key))
248
+ directTestMap.set(key, new Set());
249
+ directTestMap.get(key).add(currentTestAbs);
250
+ }
245
251
  }
246
252
  // read file content to find further imports (works for .vue too; imports are inside <script>)
247
253
  let code = contentCache.get(absFile);
@@ -297,7 +303,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
297
303
  await crawl(abs, 0, seen, testAbs);
298
304
  }
299
305
  }
300
- return { targets: Array.from(targets.values()), testMap };
306
+ return { targets: Array.from(targets.values()), testMap, directTestMap };
301
307
  }
302
308
  finally {
303
309
  await cleanup();
@@ -68,7 +68,7 @@ export async function runOrchestrator(cliArgs, cwd) {
68
68
  // 4. Discover targets and tests
69
69
  const cache = await readMutantCache(cwd);
70
70
  const discovered = await autoDiscoverTargetsAndTests(cwd, cfg);
71
- const testMap = discovered.testMap;
71
+ const { testMap, directTestMap } = discovered;
72
72
  const targets = cfg.targets?.length
73
73
  ? [...cfg.targets]
74
74
  : (cfg.autoDiscover ?? true)
@@ -122,7 +122,7 @@ export async function runOrchestrator(cliArgs, cwd) {
122
122
  return;
123
123
  }
124
124
  // 8. Prepare tasks and execute via worker pool
125
- const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage);
125
+ const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage, directTestMap);
126
126
  await executePool({
127
127
  tasks,
128
128
  adapter,
@@ -66,7 +66,7 @@ export async function executePool(opts) {
66
66
  progress.start();
67
67
  let nextIdx = 0;
68
68
  async function processTask(task) {
69
- const { v, tests, key } = task;
69
+ const { v, tests, key, directTests } = task;
70
70
  log.debug('Cache ' + JSON.stringify(cache));
71
71
  const cached = cache[key];
72
72
  if (cached) {
@@ -119,6 +119,10 @@ export async function executePool(opts) {
119
119
  col: v.col,
120
120
  mutator: v.name,
121
121
  ...(originalSnippet !== undefined && { originalSnippet, mutatedSnippet }),
122
+ ...(status === 'escaped' &&
123
+ (directTests ?? tests).length > 0 && {
124
+ coveringTests: directTests ?? tests,
125
+ }),
122
126
  };
123
127
  progress.update(status);
124
128
  }
@@ -1,12 +1,14 @@
1
1
  import type { Variant } from '../types/mutant.js';
2
2
  import type { PerTestCoverageMap } from '../utils/coverage.js';
3
+ import type { TestMap } from './discover.js';
3
4
  export interface MutantTask {
4
5
  v: Variant;
5
6
  tests: string[];
6
7
  key: string;
8
+ directTests?: readonly string[];
7
9
  }
8
10
  /**
9
11
  * Prepare mutant tasks from variants by pruning tests via per-test coverage,
10
12
  * sorting tests deterministically, and computing cache keys.
11
13
  */
12
- export declare function prepareTasks(variants: readonly Variant[], perTestCoverage: PerTestCoverageMap | null): MutantTask[];
14
+ export declare function prepareTasks(variants: readonly Variant[], perTestCoverage: PerTestCoverageMap | null, directTestMap?: TestMap): MutantTask[];
@@ -1,12 +1,13 @@
1
1
  import { filterTestsByCoverage } from './variants.js';
2
2
  import { hash, keyForTests } from './cache.js';
3
3
  import { createLogger } from '../utils/logger.js';
4
+ import { normalizePath } from '../utils/normalizePath.js';
4
5
  const log = createLogger('tasks');
5
6
  /**
6
7
  * Prepare mutant tasks from variants by pruning tests via per-test coverage,
7
8
  * sorting tests deterministically, and computing cache keys.
8
9
  */
9
- export function prepareTasks(variants, perTestCoverage) {
10
+ export function prepareTasks(variants, perTestCoverage, directTestMap) {
10
11
  return variants.map((v) => {
11
12
  let tests = Array.from(v.tests);
12
13
  if (perTestCoverage && tests.length) {
@@ -20,6 +21,13 @@ export function prepareTasks(variants, perTestCoverage) {
20
21
  const testSig = hash(keyForTests(tests));
21
22
  const codeSig = hash(v.code);
22
23
  const key = `${testSig}:${codeSig}`;
23
- return { v, tests, key };
24
+ const direct = directTestMap?.get(normalizePath(v.file));
25
+ return {
26
+ v,
27
+ tests,
28
+ key,
29
+ ...(direct &&
30
+ direct.size > 0 && { directTests: Array.from(direct).sort() }),
31
+ };
24
32
  });
25
33
  }
@@ -27,6 +27,7 @@ export interface MutantCacheEntry extends MutantLocation {
27
27
  readonly mutator: string;
28
28
  readonly originalSnippet?: string;
29
29
  readonly mutatedSnippet?: string;
30
+ readonly coveringTests?: readonly string[];
30
31
  }
31
32
  export interface MutantResult extends MutantCacheEntry {
32
33
  readonly id: string;
@@ -71,6 +71,32 @@ describe('summary', () => {
71
71
  expect(lines.some((l) => l.trimStart().startsWith('+ '))).toBe(false);
72
72
  logSpy.mockRestore();
73
73
  });
74
+ it('prints covering test path for escaped mutant', () => {
75
+ const cache = {
76
+ a: makeEntry({
77
+ status: 'escaped',
78
+ coveringTests: ['/abs/foo.spec.ts'],
79
+ }),
80
+ };
81
+ const summary = computeSummary(cache);
82
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
83
+ printSummary(summary, cache);
84
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
85
+ expect(lines.some((l) => l.includes('↳'))).toBe(true);
86
+ expect(lines.some((l) => l.includes('foo.spec.ts'))).toBe(true);
87
+ logSpy.mockRestore();
88
+ });
89
+ it('does not print covering tests when array is absent', () => {
90
+ const cache = {
91
+ a: makeEntry({ status: 'escaped' }),
92
+ };
93
+ const summary = computeSummary(cache);
94
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
95
+ printSummary(summary, cache);
96
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
97
+ expect(lines.some((l) => l.includes('↳'))).toBe(false);
98
+ logSpy.mockRestore();
99
+ });
74
100
  it('summarise returns summary and prints', () => {
75
101
  const cache = { a: makeEntry({ status: 'killed' }) };
76
102
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -81,6 +81,15 @@ export function printSummary(summary, cache, durationMs) {
81
81
  console.log(' ' + chalk.red('- ' + entry.originalSnippet));
82
82
  console.log(' ' + chalk.green('+ ' + entry.mutatedSnippet));
83
83
  }
84
+ if (entry.coveringTests?.length) {
85
+ const shown = entry.coveringTests.slice(0, 2);
86
+ for (const t of shown) {
87
+ console.log(' ' + chalk.dim('↳ ' + path.relative(cwd, t)));
88
+ }
89
+ if (entry.coveringTests.length > 2) {
90
+ console.log(' ' + chalk.dim(` +${entry.coveringTests.length - 2} more`));
91
+ }
92
+ }
84
93
  }
85
94
  }
86
95
  if (entriesByStatus.skipped.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutineerjs/mutineer",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A fast, targeted mutation testing framework for JavaScript and TypeScript",
5
5
  "type": "module",
6
6
  "private": false,