@mutineerjs/mutineer 0.3.2 → 0.4.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.
@@ -193,6 +193,99 @@ describe('executePool', () => {
193
193
  const content = JSON.parse(await fs.readFile(cacheFile, 'utf8'));
194
194
  expect(content['persist-key']).toBeDefined();
195
195
  });
196
+ it('escaped mutant stores snippets when lines differ', async () => {
197
+ const tmpFile = path.join(tmpDir, 'source.ts');
198
+ await fs.writeFile(tmpFile, 'const x = a + b\n');
199
+ const adapter = makeAdapter({
200
+ runMutant: vi
201
+ .fn()
202
+ .mockResolvedValue({ status: 'escaped', durationMs: 1 }),
203
+ });
204
+ const cache = {};
205
+ const task = makeTask({
206
+ key: 'snippet-key',
207
+ v: {
208
+ id: 'source.ts#0',
209
+ name: 'flipArith',
210
+ file: tmpFile,
211
+ code: 'const x = a - b\n',
212
+ line: 1,
213
+ col: 10,
214
+ tests: ['/tests/file.test.ts'],
215
+ },
216
+ });
217
+ await executePool({
218
+ tasks: [task],
219
+ adapter,
220
+ cache,
221
+ concurrency: 1,
222
+ progressMode: 'list',
223
+ cwd: tmpDir,
224
+ });
225
+ expect(cache['snippet-key'].originalSnippet).toBe('const x = a + b');
226
+ expect(cache['snippet-key'].mutatedSnippet).toBe('const x = a - b');
227
+ });
228
+ it('escaped mutant omits snippets when original and mutated lines are identical', async () => {
229
+ const tmpFile = path.join(tmpDir, 'source2.ts');
230
+ await fs.writeFile(tmpFile, 'const x = a + b\n');
231
+ const adapter = makeAdapter({
232
+ runMutant: vi
233
+ .fn()
234
+ .mockResolvedValue({ status: 'escaped', durationMs: 1 }),
235
+ });
236
+ const cache = {};
237
+ const task = makeTask({
238
+ key: 'no-snippet-key',
239
+ v: {
240
+ id: 'source2.ts#0',
241
+ name: 'flipArith',
242
+ file: tmpFile,
243
+ code: 'const x = a + b\n',
244
+ line: 1,
245
+ col: 10,
246
+ tests: ['/tests/file.test.ts'],
247
+ },
248
+ });
249
+ await executePool({
250
+ tasks: [task],
251
+ adapter,
252
+ cache,
253
+ concurrency: 1,
254
+ progressMode: 'list',
255
+ cwd: tmpDir,
256
+ });
257
+ expect(cache['no-snippet-key'].originalSnippet).toBeUndefined();
258
+ });
259
+ it('escaped mutant omits snippets when file read fails', async () => {
260
+ const adapter = makeAdapter({
261
+ runMutant: vi
262
+ .fn()
263
+ .mockResolvedValue({ status: 'escaped', durationMs: 1 }),
264
+ });
265
+ const cache = {};
266
+ const task = makeTask({
267
+ key: 'missing-file-key',
268
+ v: {
269
+ id: 'missing.ts#0',
270
+ name: 'flipArith',
271
+ file: '/nonexistent/path/missing.ts',
272
+ code: 'const x = a - b\n',
273
+ line: 1,
274
+ col: 10,
275
+ tests: ['/tests/file.test.ts'],
276
+ },
277
+ });
278
+ await executePool({
279
+ tasks: [task],
280
+ adapter,
281
+ cache,
282
+ concurrency: 1,
283
+ progressMode: 'list',
284
+ cwd: tmpDir,
285
+ });
286
+ expect(cache['missing-file-key'].status).toBe('escaped');
287
+ expect(cache['missing-file-key'].originalSnippet).toBeUndefined();
288
+ });
196
289
  it('handles adapter errors gracefully and still shuts down', async () => {
197
290
  const adapter = makeAdapter({
198
291
  runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import { render } from 'ink';
2
3
  import { createElement } from 'react';
3
4
  import { Progress } from '../utils/progress.js';
@@ -93,12 +94,31 @@ export async function executePool(opts) {
93
94
  col: v.col,
94
95
  }, tests);
95
96
  const status = result.status;
97
+ let originalSnippet;
98
+ let mutatedSnippet;
99
+ if (status === 'escaped') {
100
+ try {
101
+ const originalLines = fs.readFileSync(v.file, 'utf8').split('\n');
102
+ const mutatedLines = v.code.split('\n');
103
+ const lineIdx = v.line - 1;
104
+ const orig = originalLines[lineIdx]?.trim();
105
+ const mutated = mutatedLines[lineIdx]?.trim();
106
+ if (orig !== undefined && mutated !== undefined && orig !== mutated) {
107
+ originalSnippet = orig;
108
+ mutatedSnippet = mutated;
109
+ }
110
+ }
111
+ catch {
112
+ // best-effort
113
+ }
114
+ }
96
115
  cache[key] = {
97
116
  status,
98
117
  file: v.file,
99
118
  line: v.line,
100
119
  col: v.col,
101
120
  mutator: v.name,
121
+ ...(originalSnippet !== undefined && { originalSnippet, mutatedSnippet }),
102
122
  };
103
123
  progress.update(status);
104
124
  }
@@ -25,6 +25,8 @@ export interface Variant extends MutantDescriptor {
25
25
  export interface MutantCacheEntry extends MutantLocation {
26
26
  readonly status: MutantStatus;
27
27
  readonly mutator: string;
28
+ readonly originalSnippet?: string;
29
+ readonly mutatedSnippet?: string;
28
30
  }
29
31
  export interface MutantResult extends MutantCacheEntry {
30
32
  readonly id: string;
@@ -43,6 +43,34 @@ describe('summary', () => {
43
43
  expect(lines.some((l) => l.includes('Duration: 1.50s'))).toBe(true);
44
44
  logSpy.mockRestore();
45
45
  });
46
+ it('prints diff lines for escaped mutant with snippets', () => {
47
+ const cache = {
48
+ a: makeEntry({
49
+ status: 'escaped',
50
+ originalSnippet: 'return a + b',
51
+ mutatedSnippet: 'return a - b',
52
+ }),
53
+ };
54
+ const summary = computeSummary(cache);
55
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
56
+ printSummary(summary, cache);
57
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
58
+ expect(lines.some((l) => l.includes('- return a + b'))).toBe(true);
59
+ expect(lines.some((l) => l.includes('+ return a - b'))).toBe(true);
60
+ logSpy.mockRestore();
61
+ });
62
+ it('does not print diff lines for escaped mutant without snippets', () => {
63
+ const cache = {
64
+ a: makeEntry({ status: 'escaped' }),
65
+ };
66
+ const summary = computeSummary(cache);
67
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
68
+ printSummary(summary, cache);
69
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
70
+ expect(lines.some((l) => l.trimStart().startsWith('- '))).toBe(false);
71
+ expect(lines.some((l) => l.trimStart().startsWith('+ '))).toBe(false);
72
+ logSpy.mockRestore();
73
+ });
46
74
  it('summarise returns summary and prints', () => {
47
75
  const cache = { a: makeEntry({ status: 'killed' }) };
48
76
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -74,8 +74,14 @@ export function printSummary(summary, cache, durationMs) {
74
74
  }
75
75
  if (entriesByStatus.escaped.length) {
76
76
  console.log('\n' + chalk.red.bold('Escaped Mutants:'));
77
- for (const entry of entriesByStatus.escaped)
77
+ for (const entry of entriesByStatus.escaped) {
78
78
  console.log(' ' + formatRow(entry));
79
+ if (entry.originalSnippet !== undefined &&
80
+ entry.mutatedSnippet !== undefined) {
81
+ console.log(' ' + chalk.red('- ' + entry.originalSnippet));
82
+ console.log(' ' + chalk.green('+ ' + entry.mutatedSnippet));
83
+ }
84
+ }
79
85
  }
80
86
  if (entriesByStatus.skipped.length) {
81
87
  console.log('\n' + chalk.dim('Skipped Mutants:'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutineerjs/mutineer",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "A fast, targeted mutation testing framework for JavaScript and TypeScript",
5
5
  "type": "module",
6
6
  "private": false,