@boyingliu01/xp-gate 0.14.7 → 0.14.9
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.
- package/bin/xp-gate.js +31 -2
- package/lib/__tests__/next-sprint.test.js +231 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/upgrade-skill-sync.test.js +65 -0
- package/lib/check.js +115 -20
- package/lib/doctor.js +63 -0
- package/lib/gate-runner.js +209 -0
- package/lib/next-sprint.js +129 -0
- package/lib/shared-utils.js +1 -1
- package/lib/sprint-status.js +3 -1
- package/lib/update-hooks.js +1 -1
- package/lib/upgrade.js +8 -0
- package/mock-policy/AGENTS.md +4 -4
- package/mock-policy/__tests__/gate-m3.test.ts +384 -0
- package/mock-policy/__tests__/schema.test.ts +239 -0
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/init-baseline.test.ts +347 -0
- package/mutation/__tests__/mutmut-runner.test.ts +561 -0
- package/mutation/__tests__/runners-index.test.ts +134 -0
- package/mutation/__tests__/stryker-runner.test.ts +455 -0
- package/mutation/__tests__/update-baseline.test.ts +393 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +4 -4
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/SKILL.md +1 -1
- package/skills/sprint-flow/references/phase-2-design.md +34 -8
- package/skills/test-specification-alignment/AGENTS.md +4 -4
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-MUT-007 mutmut mutation runner
|
|
3
|
+
* @intent Verify MutmutRunner registration, availability checks, emoji progress parsing,
|
|
4
|
+
* pyproject.toml config management, and cleanup behavior
|
|
5
|
+
* @covers AC-MUT-007, AC-MUT-008
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
import { spawn, execSync, type ChildProcess } from 'child_process';
|
|
10
|
+
import {
|
|
11
|
+
writeFileSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
} from 'fs';
|
|
16
|
+
import { tmpdir } from 'os';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
|
|
19
|
+
vi.mock('child_process', () => ({
|
|
20
|
+
spawn: vi.fn(),
|
|
21
|
+
execSync: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('fs', () => ({
|
|
25
|
+
writeFileSync: vi.fn(),
|
|
26
|
+
readFileSync: vi.fn(),
|
|
27
|
+
existsSync: vi.fn(),
|
|
28
|
+
unlinkSync: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
describe('MutmutRunner', () => {
|
|
32
|
+
let MutmutRunner: typeof import('../runners/mutmut-runner').MutmutRunner;
|
|
33
|
+
let tmpDir: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
tmpDir = join(tmpdir(), `xp-gate-mutmut-test-${Date.now()}`);
|
|
38
|
+
MutmutRunner = (await import('../runners/mutmut-runner')).MutmutRunner;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('name and extensions', () => {
|
|
46
|
+
it('should have name "mutmut"', () => {
|
|
47
|
+
const runner = new MutmutRunner();
|
|
48
|
+
expect(runner.name).toBe('mutmut');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should handle .py extension', () => {
|
|
52
|
+
const runner = new MutmutRunner();
|
|
53
|
+
expect(runner.extensions).toContain('py');
|
|
54
|
+
expect(runner.extensions).not.toContain('ts');
|
|
55
|
+
expect(runner.extensions).not.toContain('go');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('isAvailable', () => {
|
|
60
|
+
it('should return true when mutmut is installed', async () => {
|
|
61
|
+
vi.mocked(execSync).mockReturnValue('mutmut, version 3.0.0\n');
|
|
62
|
+
|
|
63
|
+
const runner = new MutmutRunner();
|
|
64
|
+
const result = await runner.isAvailable();
|
|
65
|
+
expect(result).toBe(true);
|
|
66
|
+
expect(execSync).toHaveBeenCalledWith('mutmut --version', {
|
|
67
|
+
stdio: 'pipe',
|
|
68
|
+
timeout: 5000,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return false when mutmut is not installed', async () => {
|
|
73
|
+
vi.mocked(execSync).mockImplementation(() => {
|
|
74
|
+
throw new Error('command not found: mutmut');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const runner = new MutmutRunner();
|
|
78
|
+
const result = await runner.isAvailable();
|
|
79
|
+
expect(result).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('run', () => {
|
|
84
|
+
it('should spawn mutmut run and parse emoji progress', async () => {
|
|
85
|
+
let stdoutCb: ((d: Buffer) => void) | null = null;
|
|
86
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
87
|
+
|
|
88
|
+
const mockChild = {
|
|
89
|
+
stdout: {
|
|
90
|
+
on: vi.fn((_event: string, cb: (d: Buffer) => void) => {
|
|
91
|
+
stdoutCb = cb;
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
stderr: { on: vi.fn() },
|
|
95
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
96
|
+
if (event === 'close') closeCb = cb;
|
|
97
|
+
}),
|
|
98
|
+
kill: vi.fn(),
|
|
99
|
+
killed: false,
|
|
100
|
+
};
|
|
101
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
102
|
+
|
|
103
|
+
// No existing pyproject.toml
|
|
104
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
105
|
+
|
|
106
|
+
const runner = new MutmutRunner();
|
|
107
|
+
const promise = runner.run({
|
|
108
|
+
files: ['src/main.py'],
|
|
109
|
+
timeoutMs: 60000,
|
|
110
|
+
cwd: tmpDir,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Simulate stdout with emoji progress
|
|
114
|
+
const progressOutput =
|
|
115
|
+
'38/38 🎉 9 🫥 29 ⏰ 0 🤔 0 🙁 0 🔇 0 🧙 0\n';
|
|
116
|
+
stdoutCb!(Buffer.from(progressOutput));
|
|
117
|
+
closeCb!(0);
|
|
118
|
+
|
|
119
|
+
const result = await promise;
|
|
120
|
+
|
|
121
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
122
|
+
'mutmut',
|
|
123
|
+
['run'],
|
|
124
|
+
expect.objectContaining({ cwd: tmpDir }),
|
|
125
|
+
);
|
|
126
|
+
expect(result.timedOut).toBe(false);
|
|
127
|
+
expect(result.report).not.toBeNull();
|
|
128
|
+
expect(result.report!.mutationScore).toBeCloseTo(23.68, 1); // 9/38 * 100
|
|
129
|
+
expect(result.report!.nrOfMutants).toBe(38);
|
|
130
|
+
expect(result.report!.nrOfKilledMutants).toBe(9);
|
|
131
|
+
expect(result.report!.nrOfSurvivedMutants).toBe(29);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return timedOut=true when process is killed (code=null)', async () => {
|
|
135
|
+
const mockChild = {
|
|
136
|
+
stdout: { on: vi.fn() },
|
|
137
|
+
stderr: { on: vi.fn() },
|
|
138
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
139
|
+
if (event === 'close') setTimeout(() => cb(null), 10);
|
|
140
|
+
}),
|
|
141
|
+
kill: vi.fn(),
|
|
142
|
+
killed: false,
|
|
143
|
+
};
|
|
144
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
145
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
146
|
+
|
|
147
|
+
const runner = new MutmutRunner();
|
|
148
|
+
const result = await runner.run({
|
|
149
|
+
files: ['src/main.py'],
|
|
150
|
+
timeoutMs: 100,
|
|
151
|
+
cwd: tmpDir,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.timedOut).toBe(true);
|
|
155
|
+
expect(result.report).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should return error when spawn fails', async () => {
|
|
159
|
+
const mockChild = {
|
|
160
|
+
stdout: { on: vi.fn() },
|
|
161
|
+
stderr: { on: vi.fn() },
|
|
162
|
+
on: vi.fn((event: string, cb: (err: Error) => void) => {
|
|
163
|
+
if (event === 'error') setTimeout(() => cb(new Error('spawn mutmut ENOENT')), 10);
|
|
164
|
+
}),
|
|
165
|
+
kill: vi.fn(),
|
|
166
|
+
killed: false,
|
|
167
|
+
};
|
|
168
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
169
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
170
|
+
|
|
171
|
+
const runner = new MutmutRunner();
|
|
172
|
+
const result = await runner.run({
|
|
173
|
+
files: ['src/main.py'],
|
|
174
|
+
timeoutMs: 60000,
|
|
175
|
+
cwd: tmpDir,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.report).toBeNull();
|
|
179
|
+
expect(result.timedOut).toBe(false);
|
|
180
|
+
expect(result.error).toBe('spawn mutmut ENOENT');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return error message when exit code is non-zero', async () => {
|
|
184
|
+
let stderrCb: ((d: Buffer) => void) | null = null;
|
|
185
|
+
const mockChild = {
|
|
186
|
+
stdout: { on: vi.fn() },
|
|
187
|
+
stderr: {
|
|
188
|
+
on: vi.fn((_event: string, cb: (d: Buffer) => void) => {
|
|
189
|
+
stderrCb = cb;
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
193
|
+
if (event === 'close') {
|
|
194
|
+
setTimeout(() => {
|
|
195
|
+
if (stderrCb) stderrCb(Buffer.from('mutation failed'));
|
|
196
|
+
cb(1);
|
|
197
|
+
}, 10);
|
|
198
|
+
}
|
|
199
|
+
}),
|
|
200
|
+
kill: vi.fn(),
|
|
201
|
+
killed: false,
|
|
202
|
+
};
|
|
203
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
204
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
205
|
+
|
|
206
|
+
const runner = new MutmutRunner();
|
|
207
|
+
const result = await runner.run({
|
|
208
|
+
files: ['src/main.py'],
|
|
209
|
+
timeoutMs: 60000,
|
|
210
|
+
cwd: tmpDir,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(result.timedOut).toBe(false);
|
|
214
|
+
expect(result.error).toBe('mutation failed');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('emoji progress parsing', () => {
|
|
219
|
+
it('should parse standard progress line with all emojis', async () => {
|
|
220
|
+
let stdoutCb: ((d: Buffer) => void) | null = null;
|
|
221
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
222
|
+
|
|
223
|
+
const mockChild = {
|
|
224
|
+
stdout: {
|
|
225
|
+
on: vi.fn((_event: string, cb: (d: Buffer) => void) => {
|
|
226
|
+
stdoutCb = cb;
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
stderr: { on: vi.fn() },
|
|
230
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
231
|
+
if (event === 'close') closeCb = cb;
|
|
232
|
+
}),
|
|
233
|
+
kill: vi.fn(),
|
|
234
|
+
killed: false,
|
|
235
|
+
};
|
|
236
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
237
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
238
|
+
|
|
239
|
+
const runner = new MutmutRunner();
|
|
240
|
+
const promise = runner.run({
|
|
241
|
+
files: ['src/calc.py'],
|
|
242
|
+
timeoutMs: 60000,
|
|
243
|
+
cwd: tmpDir,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const output = '20/20 🎉 10 🫥 5 ⏰ 2 🤔 1 🙁 2 🔇 0 🧙 0\n';
|
|
247
|
+
stdoutCb!(Buffer.from(output));
|
|
248
|
+
closeCb!(0);
|
|
249
|
+
|
|
250
|
+
const result = await promise;
|
|
251
|
+
expect(result.report).not.toBeNull();
|
|
252
|
+
// killed=10, survived=5+2+1+2+0+0=10, total=20
|
|
253
|
+
expect(result.report!.nrOfKilledMutants).toBe(10);
|
|
254
|
+
expect(result.report!.nrOfSurvivedMutants).toBe(10);
|
|
255
|
+
expect(result.report!.nrOfMutants).toBe(20);
|
|
256
|
+
expect(result.report!.mutationScore).toBe(50);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should return null report when no progress line found', async () => {
|
|
260
|
+
let stdoutCb: ((d: Buffer) => void) | null = null;
|
|
261
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
262
|
+
|
|
263
|
+
const mockChild = {
|
|
264
|
+
stdout: {
|
|
265
|
+
on: vi.fn((_event: string, cb: (d: Buffer) => void) => {
|
|
266
|
+
stdoutCb = cb;
|
|
267
|
+
}),
|
|
268
|
+
},
|
|
269
|
+
stderr: { on: vi.fn() },
|
|
270
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
271
|
+
if (event === 'close') closeCb = cb;
|
|
272
|
+
}),
|
|
273
|
+
kill: vi.fn(),
|
|
274
|
+
killed: false,
|
|
275
|
+
};
|
|
276
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
277
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
278
|
+
|
|
279
|
+
const runner = new MutmutRunner();
|
|
280
|
+
const promise = runner.run({
|
|
281
|
+
files: ['src/main.py'],
|
|
282
|
+
timeoutMs: 60000,
|
|
283
|
+
cwd: tmpDir,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
stdoutCb!(Buffer.from('Some random output without emoji\n'));
|
|
287
|
+
closeCb!(0);
|
|
288
|
+
|
|
289
|
+
const result = await promise;
|
|
290
|
+
expect(result.report).toBeNull();
|
|
291
|
+
expect(result.timedOut).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should return null report when all mutant counts are zero', async () => {
|
|
295
|
+
let stdoutCb: ((d: Buffer) => void) | null = null;
|
|
296
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
297
|
+
|
|
298
|
+
const mockChild = {
|
|
299
|
+
stdout: {
|
|
300
|
+
on: vi.fn((_event: string, cb: (d: Buffer) => void) => {
|
|
301
|
+
stdoutCb = cb;
|
|
302
|
+
}),
|
|
303
|
+
},
|
|
304
|
+
stderr: { on: vi.fn() },
|
|
305
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
306
|
+
if (event === 'close') closeCb = cb;
|
|
307
|
+
}),
|
|
308
|
+
kill: vi.fn(),
|
|
309
|
+
killed: false,
|
|
310
|
+
};
|
|
311
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
312
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
313
|
+
|
|
314
|
+
const runner = new MutmutRunner();
|
|
315
|
+
const promise = runner.run({
|
|
316
|
+
files: ['src/main.py'],
|
|
317
|
+
timeoutMs: 60000,
|
|
318
|
+
cwd: tmpDir,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const output = '0/0 🎉 0 🫥 0 ⏰ 0 🤔 0 🙁 0 🔇 0 🧙 0\n';
|
|
322
|
+
stdoutCb!(Buffer.from(output));
|
|
323
|
+
closeCb!(0);
|
|
324
|
+
|
|
325
|
+
const result = await promise;
|
|
326
|
+
expect(result.report).toBeNull();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('pyproject.toml config writing', () => {
|
|
331
|
+
it('should create new pyproject.toml when file does not exist', async () => {
|
|
332
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
333
|
+
const mockChild = {
|
|
334
|
+
stdout: { on: vi.fn() },
|
|
335
|
+
stderr: { on: vi.fn() },
|
|
336
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
337
|
+
if (event === 'close') closeCb = cb;
|
|
338
|
+
}),
|
|
339
|
+
kill: vi.fn(),
|
|
340
|
+
killed: false,
|
|
341
|
+
};
|
|
342
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
343
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
|
344
|
+
|
|
345
|
+
const runner = new MutmutRunner();
|
|
346
|
+
const promise = runner.run({
|
|
347
|
+
files: ['src/main.py', 'src/utils.py'],
|
|
348
|
+
timeoutMs: 60000,
|
|
349
|
+
cwd: tmpDir,
|
|
350
|
+
});
|
|
351
|
+
closeCb!(0);
|
|
352
|
+
await promise;
|
|
353
|
+
|
|
354
|
+
// Should have written pyproject.toml with source_paths
|
|
355
|
+
expect(writeFileSync).toHaveBeenCalledWith(
|
|
356
|
+
join(tmpDir, 'pyproject.toml'),
|
|
357
|
+
expect.stringContaining('[tool.mutmut]'),
|
|
358
|
+
{ encoding: 'utf-8' },
|
|
359
|
+
);
|
|
360
|
+
expect(writeFileSync).toHaveBeenCalledWith(
|
|
361
|
+
join(tmpDir, 'pyproject.toml'),
|
|
362
|
+
expect.stringContaining('source_paths = ["src"]'),
|
|
363
|
+
{ encoding: 'utf-8' },
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('should update existing pyproject.toml with [tool.mutmut] section', async () => {
|
|
368
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
369
|
+
const mockChild = {
|
|
370
|
+
stdout: { on: vi.fn() },
|
|
371
|
+
stderr: { on: vi.fn() },
|
|
372
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
373
|
+
if (event === 'close') closeCb = cb;
|
|
374
|
+
}),
|
|
375
|
+
kill: vi.fn(),
|
|
376
|
+
killed: false,
|
|
377
|
+
};
|
|
378
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
379
|
+
|
|
380
|
+
// First call for pyproject.toml exists check, second for backup
|
|
381
|
+
vi.mocked(existsSync).mockImplementation((path) => {
|
|
382
|
+
const p = path.toString();
|
|
383
|
+
if (p.endsWith('pyproject.toml')) return true;
|
|
384
|
+
if (p.endsWith('.xp-gate-backup')) return false;
|
|
385
|
+
return false;
|
|
386
|
+
});
|
|
387
|
+
vi.mocked(readFileSync).mockImplementation((path) => {
|
|
388
|
+
const p = path.toString();
|
|
389
|
+
if (p.endsWith('pyproject.toml')) {
|
|
390
|
+
return '[tool.mutmut]\nsource_paths = ["old_path"]\n';
|
|
391
|
+
}
|
|
392
|
+
return '';
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const runner = new MutmutRunner();
|
|
396
|
+
const promise = runner.run({
|
|
397
|
+
files: ['src/main.py'],
|
|
398
|
+
timeoutMs: 60000,
|
|
399
|
+
cwd: tmpDir,
|
|
400
|
+
});
|
|
401
|
+
closeCb!(0);
|
|
402
|
+
await promise;
|
|
403
|
+
|
|
404
|
+
// Should have backed up and then written updated config
|
|
405
|
+
expect(writeFileSync).toHaveBeenCalledWith(
|
|
406
|
+
join(tmpDir, 'pyproject.toml.xp-gate-backup'),
|
|
407
|
+
'[tool.mutmut]\nsource_paths = ["old_path"]\n',
|
|
408
|
+
{ encoding: 'utf-8' },
|
|
409
|
+
);
|
|
410
|
+
expect(writeFileSync).toHaveBeenCalledWith(
|
|
411
|
+
join(tmpDir, 'pyproject.toml'),
|
|
412
|
+
expect.stringContaining('source_paths = ["src"]'),
|
|
413
|
+
{ encoding: 'utf-8' },
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should append [tool.mutmut] section to existing file without it', async () => {
|
|
418
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
419
|
+
const mockChild = {
|
|
420
|
+
stdout: { on: vi.fn() },
|
|
421
|
+
stderr: { on: vi.fn() },
|
|
422
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
423
|
+
if (event === 'close') closeCb = cb;
|
|
424
|
+
}),
|
|
425
|
+
kill: vi.fn(),
|
|
426
|
+
killed: false,
|
|
427
|
+
};
|
|
428
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
429
|
+
|
|
430
|
+
vi.mocked(existsSync).mockImplementation((path) => {
|
|
431
|
+
const p = path.toString();
|
|
432
|
+
if (p.endsWith('pyproject.toml')) return true;
|
|
433
|
+
if (p.endsWith('.xp-gate-backup')) return false;
|
|
434
|
+
return false;
|
|
435
|
+
});
|
|
436
|
+
vi.mocked(readFileSync).mockImplementation((path) => {
|
|
437
|
+
const p = path.toString();
|
|
438
|
+
if (p.endsWith('pyproject.toml')) {
|
|
439
|
+
return '[tool.pytest]\ntestpaths = ["tests"]\n';
|
|
440
|
+
}
|
|
441
|
+
return '';
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const runner = new MutmutRunner();
|
|
445
|
+
const promise = runner.run({
|
|
446
|
+
files: ['src/main.py'],
|
|
447
|
+
timeoutMs: 60000,
|
|
448
|
+
cwd: tmpDir,
|
|
449
|
+
});
|
|
450
|
+
closeCb!(0);
|
|
451
|
+
await promise;
|
|
452
|
+
|
|
453
|
+
// Should have appended [tool.mutmut] section
|
|
454
|
+
const writeCalls = vi.mocked(writeFileSync).mock.calls;
|
|
455
|
+
const configWrite = writeCalls.find(
|
|
456
|
+
(c) => c[0] === join(tmpDir, 'pyproject.toml') && c[1].toString().includes('[tool.mutmut]'),
|
|
457
|
+
);
|
|
458
|
+
expect(configWrite).toBeDefined();
|
|
459
|
+
expect(configWrite![1].toString()).toContain('[tool.pytest]');
|
|
460
|
+
expect(configWrite![1].toString()).toContain('[tool.mutmut]');
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('cleanup', () => {
|
|
465
|
+
it('should restore backup after run completes', async () => {
|
|
466
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
467
|
+
const mockChild = {
|
|
468
|
+
stdout: { on: vi.fn() },
|
|
469
|
+
stderr: { on: vi.fn() },
|
|
470
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
471
|
+
if (event === 'close') closeCb = cb;
|
|
472
|
+
}),
|
|
473
|
+
kill: vi.fn(),
|
|
474
|
+
killed: false,
|
|
475
|
+
};
|
|
476
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
477
|
+
|
|
478
|
+
vi.mocked(existsSync).mockImplementation((path) => {
|
|
479
|
+
const p = path.toString();
|
|
480
|
+
if (p.endsWith('pyproject.toml') && !p.endsWith('.xp-gate-backup')) {
|
|
481
|
+
return true; // exists both initially and during cleanup
|
|
482
|
+
}
|
|
483
|
+
if (p.endsWith('.xp-gate-backup')) return true; // backup exists for cleanup
|
|
484
|
+
return false;
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const originalContent = '[tool.mutmut]\nsource_paths = ["original"]\n';
|
|
488
|
+
vi.mocked(readFileSync).mockImplementation((path) => {
|
|
489
|
+
const p = path.toString();
|
|
490
|
+
if (p.endsWith('pyproject.toml.xp-gate-backup')) return originalContent;
|
|
491
|
+
if (p.endsWith('pyproject.toml')) return originalContent;
|
|
492
|
+
return '';
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const runner = new MutmutRunner();
|
|
496
|
+
const promise = runner.run({
|
|
497
|
+
files: ['src/main.py'],
|
|
498
|
+
timeoutMs: 60000,
|
|
499
|
+
cwd: tmpDir,
|
|
500
|
+
});
|
|
501
|
+
closeCb!(0);
|
|
502
|
+
await promise;
|
|
503
|
+
|
|
504
|
+
// Should have restored the backup
|
|
505
|
+
expect(readFileSync).toHaveBeenCalledWith(
|
|
506
|
+
join(tmpDir, 'pyproject.toml.xp-gate-backup'),
|
|
507
|
+
'utf-8',
|
|
508
|
+
);
|
|
509
|
+
expect(writeFileSync).toHaveBeenCalledWith(
|
|
510
|
+
join(tmpDir, 'pyproject.toml'),
|
|
511
|
+
originalContent,
|
|
512
|
+
{ encoding: 'utf-8' },
|
|
513
|
+
);
|
|
514
|
+
expect(unlinkSync).toHaveBeenCalledWith(
|
|
515
|
+
join(tmpDir, 'pyproject.toml.xp-gate-backup'),
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('should remove pyproject.toml when no backup existed', async () => {
|
|
520
|
+
let closeCb: ((code: number | null) => void) | null = null;
|
|
521
|
+
const mockChild = {
|
|
522
|
+
stdout: { on: vi.fn() },
|
|
523
|
+
stderr: { on: vi.fn() },
|
|
524
|
+
on: vi.fn((event: string, cb: (code: number | null) => void) => {
|
|
525
|
+
if (event === 'close') closeCb = cb;
|
|
526
|
+
}),
|
|
527
|
+
kill: vi.fn(),
|
|
528
|
+
killed: false,
|
|
529
|
+
};
|
|
530
|
+
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
|
|
531
|
+
|
|
532
|
+
let pyprojectExistsCallCount = 0;
|
|
533
|
+
vi.mocked(existsSync).mockImplementation((path) => {
|
|
534
|
+
const p = path.toString();
|
|
535
|
+
if (p.endsWith('.xp-gate-backup')) return false;
|
|
536
|
+
if (p.endsWith('pyproject.toml')) {
|
|
537
|
+
pyprojectExistsCallCount++;
|
|
538
|
+
// Calls 1-2: run() + writeMutmutConfig() → false (no pre-existing file)
|
|
539
|
+
// Call 3: cleanup finally block → true (code created it via mocked writeFileSync)
|
|
540
|
+
return pyprojectExistsCallCount > 2;
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
const runner = new MutmutRunner();
|
|
546
|
+
const promise = runner.run({
|
|
547
|
+
files: ['src/main.py'],
|
|
548
|
+
timeoutMs: 60000,
|
|
549
|
+
cwd: tmpDir,
|
|
550
|
+
});
|
|
551
|
+
closeCb!(0);
|
|
552
|
+
await promise;
|
|
553
|
+
|
|
554
|
+
expect(unlinkSync).toHaveBeenCalledWith(join(tmpDir, 'pyproject.toml'));
|
|
555
|
+
expect(readFileSync).not.toHaveBeenCalledWith(
|
|
556
|
+
join(tmpDir, 'pyproject.toml.xp-gate-backup'),
|
|
557
|
+
expect.anything(),
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-MUT-010 Mutation runner registry and resolution
|
|
3
|
+
* @intent Verify registerAllRunners populates the registry, resolveRunner routes by extension,
|
|
4
|
+
* and re-exports expose the expected types and classes.
|
|
5
|
+
* @covers AC-MUT-010, AC-MUT-011
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
9
|
+
import {
|
|
10
|
+
runnerRegistry,
|
|
11
|
+
registerAllRunners,
|
|
12
|
+
resolveRunner,
|
|
13
|
+
StrykerRunner,
|
|
14
|
+
MutmutRunner,
|
|
15
|
+
GoMutantRunner,
|
|
16
|
+
PitestRunner,
|
|
17
|
+
} from '../runners/index';
|
|
18
|
+
import type {
|
|
19
|
+
MutationFileReport,
|
|
20
|
+
MutationRunResult,
|
|
21
|
+
MutationRunOutcome,
|
|
22
|
+
RunMutationOptions,
|
|
23
|
+
MutationRunner,
|
|
24
|
+
} from '../runners/index';
|
|
25
|
+
|
|
26
|
+
describe('runners/index', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
runnerRegistry.clear();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('exposes runnerRegistry as a Map instance', () => {
|
|
32
|
+
expect(runnerRegistry).toBeInstanceOf(Map);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('registerAllRunners() registers all 4 runners', () => {
|
|
36
|
+
registerAllRunners();
|
|
37
|
+
|
|
38
|
+
expect(runnerRegistry.size).toBe(4);
|
|
39
|
+
expect(runnerRegistry.has('Stryker')).toBe(true);
|
|
40
|
+
expect(runnerRegistry.has('mutmut')).toBe(true);
|
|
41
|
+
expect(runnerRegistry.has('gomutants')).toBe(true);
|
|
42
|
+
expect(runnerRegistry.has('pitest')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('registerAllRunners() stores correct runner class instances', () => {
|
|
46
|
+
registerAllRunners();
|
|
47
|
+
|
|
48
|
+
expect(runnerRegistry.get('Stryker')).toBeInstanceOf(StrykerRunner);
|
|
49
|
+
expect(runnerRegistry.get('mutmut')).toBeInstanceOf(MutmutRunner);
|
|
50
|
+
expect(runnerRegistry.get('gomutants')).toBeInstanceOf(GoMutantRunner);
|
|
51
|
+
expect(runnerRegistry.get('pitest')).toBeInstanceOf(PitestRunner);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('resolveRunner()', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
registerAllRunners();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
['ts', 'Stryker'],
|
|
61
|
+
['tsx', 'Stryker'],
|
|
62
|
+
['py', 'mutmut'],
|
|
63
|
+
['go', 'gomutants'],
|
|
64
|
+
['java', 'pitest'],
|
|
65
|
+
['kt', 'pitest'],
|
|
66
|
+
['kts', 'pitest'],
|
|
67
|
+
])('returns %s runner for extension "%s"', (ext, expectedName) => {
|
|
68
|
+
const runner = resolveRunner(ext);
|
|
69
|
+
expect(runner).toBeDefined();
|
|
70
|
+
expect(runner!.name).toBe(expectedName);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('handles dot-prefixed extensions', () => {
|
|
74
|
+
const runner = resolveRunner('.ts');
|
|
75
|
+
expect(runner).toBeDefined();
|
|
76
|
+
expect(runner!.name).toBe('Stryker');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns undefined for unknown extensions', () => {
|
|
80
|
+
expect(resolveRunner('rb')).toBeUndefined();
|
|
81
|
+
expect(resolveRunner('swift')).toBeUndefined();
|
|
82
|
+
expect(resolveRunner('')).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('re-exports', () => {
|
|
87
|
+
it('re-exports all runner classes', () => {
|
|
88
|
+
expect(StrykerRunner).toBeDefined();
|
|
89
|
+
expect(MutmutRunner).toBeDefined();
|
|
90
|
+
expect(GoMutantRunner).toBeDefined();
|
|
91
|
+
expect(PitestRunner).toBeDefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('re-exports types as usable type aliases', () => {
|
|
95
|
+
// Type-only imports are verified at compile time.
|
|
96
|
+
// This test ensures the module shape is importable without runtime error.
|
|
97
|
+
const report: MutationFileReport = {
|
|
98
|
+
mutationScore: 80,
|
|
99
|
+
nrOfMutants: 10,
|
|
100
|
+
nrOfKilledMutants: 8,
|
|
101
|
+
nrOfSurvivedMutants: 2,
|
|
102
|
+
};
|
|
103
|
+
const result: MutationRunResult = {
|
|
104
|
+
mutationScore: 80,
|
|
105
|
+
nrOfMutants: 10,
|
|
106
|
+
nrOfKilledMutants: 8,
|
|
107
|
+
nrOfSurvivedMutants: 2,
|
|
108
|
+
};
|
|
109
|
+
const outcome: MutationRunOutcome = {
|
|
110
|
+
report: result,
|
|
111
|
+
timedOut: false,
|
|
112
|
+
};
|
|
113
|
+
const options: RunMutationOptions = {
|
|
114
|
+
files: ['src/foo.ts'],
|
|
115
|
+
timeoutMs: 30000,
|
|
116
|
+
cwd: '/tmp',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
expect(report.mutationScore).toBe(80);
|
|
120
|
+
expect(result.nrOfMutants).toBe(10);
|
|
121
|
+
expect(outcome.timedOut).toBe(false);
|
|
122
|
+
expect(options.files).toEqual(['src/foo.ts']);
|
|
123
|
+
|
|
124
|
+
// MutationRunner is an interface — verify it can be used as a type constraint
|
|
125
|
+
const fakeRunner: MutationRunner = {
|
|
126
|
+
name: 'fake',
|
|
127
|
+
extensions: ['fake'],
|
|
128
|
+
isAvailable: async () => true,
|
|
129
|
+
run: async () => ({ report: null, timedOut: false }),
|
|
130
|
+
};
|
|
131
|
+
expect(fakeRunner.name).toBe('fake');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|