@mutineerjs/mutineer 0.5.1 → 0.6.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.
- package/README.md +42 -2
- package/dist/bin/__tests__/mutineer.spec.d.ts +1 -0
- package/dist/bin/__tests__/mutineer.spec.js +43 -0
- package/dist/bin/mutineer.d.ts +2 -1
- package/dist/bin/mutineer.js +41 -0
- package/dist/runner/__tests__/args.spec.js +45 -1
- package/dist/runner/__tests__/changed.spec.js +85 -2
- package/dist/runner/__tests__/config.spec.js +2 -13
- package/dist/runner/__tests__/coverage-resolver.spec.js +7 -2
- package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
- package/dist/runner/__tests__/orchestrator.spec.js +141 -0
- package/dist/runner/args.d.ts +5 -0
- package/dist/runner/args.js +13 -0
- package/dist/runner/changed.js +15 -43
- package/dist/runner/config.js +1 -1
- package/dist/runner/jest/__tests__/pool.spec.js +41 -0
- package/dist/runner/jest/pool.js +3 -3
- package/dist/runner/orchestrator.js +16 -1
- package/dist/runner/vitest/__tests__/adapter.spec.js +19 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +57 -0
- package/dist/runner/vitest/adapter.js +3 -0
- package/dist/runner/vitest/pool.js +3 -3
- package/dist/types/config.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,10 +19,10 @@ Built for **Vitest** with first-class **Jest** support. Other test runners can b
|
|
|
19
19
|
|
|
20
20
|
1. **Baseline** -- runs your test suite to make sure everything passes before mutating
|
|
21
21
|
2. **Mutate** -- applies AST-safe operator replacements to your source files (not your tests)
|
|
22
|
-
3. **Test** -- re-runs only the tests that import the mutated file, using
|
|
22
|
+
3. **Test** -- re-runs only the tests that import the mutated file, using temp files in `__mutineer__` dirs loaded via Vite plugin / Jest resolver
|
|
23
23
|
4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
|
|
24
24
|
|
|
25
|
-
Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is
|
|
25
|
+
Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is written to a temporary `__mutineer__` directory next to each source file, then loaded at runtime via Vite plugins (Vitest) or custom resolvers (Jest).
|
|
26
26
|
|
|
27
27
|
## Supported Mutations (WIP)
|
|
28
28
|
|
|
@@ -164,6 +164,46 @@ export default defineMutineerConfig({
|
|
|
164
164
|
| `testPatterns` | `string[]` | Globs for test file discovery |
|
|
165
165
|
| `extensions` | `string[]` | File extensions to consider |
|
|
166
166
|
|
|
167
|
+
## Recommended Workflow
|
|
168
|
+
|
|
169
|
+
Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
|
|
170
|
+
|
|
171
|
+
### 1. PR-scoped runs (CI) — `--changed-with-deps`
|
|
172
|
+
|
|
173
|
+
Run only on files changed in the branch plus their direct dependents:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
mutineer run --changed-with-deps
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- Tune the dependency graph depth with `dependencyDepth` in config (default: `1`)
|
|
180
|
+
- Add `--per-test-coverage` to only run tests that cover the mutated line
|
|
181
|
+
- Recommended `package.json` script:
|
|
182
|
+
|
|
183
|
+
```json
|
|
184
|
+
"mutineer:ci": "mutineer run --changed-with-deps --per-test-coverage"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 2. Split configs by domain
|
|
188
|
+
|
|
189
|
+
Create a `mutineer.config.ts` per domain and run selectively:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
mutineer run -c src/api/mutineer.config.ts
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Each config sets its own `source` glob and `minKillPercent`. Good for monorepos or large modular projects — domains can also be parallelized in CI.
|
|
196
|
+
|
|
197
|
+
### 3. Combine filters to reduce scope
|
|
198
|
+
|
|
199
|
+
- `--only-covered-lines` — skips lines not covered by any test (requires a coverage file)
|
|
200
|
+
- `maxMutantsPerFile` — caps mutations per file as a safety valve
|
|
201
|
+
- Combine for maximum focus:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
mutineer run --changed-with-deps --only-covered-lines --per-test-coverage
|
|
205
|
+
```
|
|
206
|
+
|
|
167
207
|
## File Support
|
|
168
208
|
|
|
169
209
|
- TypeScript and JavaScript modules (`.ts`, `.js`, `.tsx`, `.jsx`)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { HELP_TEXT, getVersion } from '../mutineer.js';
|
|
4
|
+
describe('HELP_TEXT', () => {
|
|
5
|
+
const flags = [
|
|
6
|
+
'--config',
|
|
7
|
+
'-c',
|
|
8
|
+
'--concurrency',
|
|
9
|
+
'--runner',
|
|
10
|
+
'--progress',
|
|
11
|
+
'--changed',
|
|
12
|
+
'--changed-with-deps',
|
|
13
|
+
'--only-covered-lines',
|
|
14
|
+
'--per-test-coverage',
|
|
15
|
+
'--coverage-file',
|
|
16
|
+
'--min-kill-percent',
|
|
17
|
+
'--help',
|
|
18
|
+
'-h',
|
|
19
|
+
'--version',
|
|
20
|
+
'-V',
|
|
21
|
+
];
|
|
22
|
+
it.each(flags)('includes %s', (flag) => {
|
|
23
|
+
expect(HELP_TEXT).toContain(flag);
|
|
24
|
+
});
|
|
25
|
+
it('includes all three commands', () => {
|
|
26
|
+
expect(HELP_TEXT).toContain('init');
|
|
27
|
+
expect(HELP_TEXT).toContain('run');
|
|
28
|
+
expect(HELP_TEXT).toContain('clean');
|
|
29
|
+
});
|
|
30
|
+
it('--changed-with-deps description mentions local dependencies', () => {
|
|
31
|
+
expect(HELP_TEXT).toContain('local dependencies');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('getVersion()', () => {
|
|
35
|
+
it('returns a semver string', () => {
|
|
36
|
+
expect(getVersion()).toMatch(/^\d+\.\d+\.\d+/);
|
|
37
|
+
});
|
|
38
|
+
it('matches package.json version', () => {
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const pkg = require('../../../package.json');
|
|
41
|
+
expect(getVersion()).toBe(pkg.version);
|
|
42
|
+
});
|
|
43
|
+
});
|
package/dist/bin/mutineer.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
export
|
|
2
|
+
export declare const HELP_TEXT = "Usage: mutineer <command> [options]\n\nCommands:\n init Create a mutineer.config.ts template\n run Run mutation testing\n clean Remove __mutineer__ temp directories\n\nOptions (run):\n --config, -c <path> Config file path\n --concurrency <n> Worker count (default: CPU count - 1)\n --runner <vitest|jest> Test runner (default: vitest)\n --progress <bar|list|quiet> Progress display (default: bar)\n --changed Mutate only git-changed files\n --changed-with-deps Mutate changed files + their local dependencies\n --only-covered-lines Mutate only lines covered by tests\n --per-test-coverage Collect per-test coverage data\n --coverage-file <path> Path to coverage JSON\n --min-kill-percent <n> Minimum kill % threshold (0\u2013100)\n --timeout <ms> Per-mutant test timeout in ms (default: 30000)\n\n --help, -h Show this help\n --version, -V Show version\n";
|
|
3
|
+
export declare function getVersion(): string;
|
package/dist/bin/mutineer.js
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
4
5
|
import { runOrchestrator } from '../runner/orchestrator.js';
|
|
5
6
|
import { cleanupMutineerDirs } from '../runner/cleanup.js';
|
|
6
7
|
// Constants
|
|
7
8
|
const RUN_COMMAND = 'run';
|
|
8
9
|
const CLEAN_COMMAND = 'clean';
|
|
9
10
|
const INIT_COMMAND = 'init';
|
|
11
|
+
export const HELP_TEXT = `\
|
|
12
|
+
Usage: mutineer <command> [options]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
init Create a mutineer.config.ts template
|
|
16
|
+
run Run mutation testing
|
|
17
|
+
clean Remove __mutineer__ temp directories
|
|
18
|
+
|
|
19
|
+
Options (run):
|
|
20
|
+
--config, -c <path> Config file path
|
|
21
|
+
--concurrency <n> Worker count (default: CPU count - 1)
|
|
22
|
+
--runner <vitest|jest> Test runner (default: vitest)
|
|
23
|
+
--progress <bar|list|quiet> Progress display (default: bar)
|
|
24
|
+
--changed Mutate only git-changed files
|
|
25
|
+
--changed-with-deps Mutate changed files + their local dependencies
|
|
26
|
+
--only-covered-lines Mutate only lines covered by tests
|
|
27
|
+
--per-test-coverage Collect per-test coverage data
|
|
28
|
+
--coverage-file <path> Path to coverage JSON
|
|
29
|
+
--min-kill-percent <n> Minimum kill % threshold (0–100)
|
|
30
|
+
--timeout <ms> Per-mutant test timeout in ms (default: 30000)
|
|
31
|
+
|
|
32
|
+
--help, -h Show this help
|
|
33
|
+
--version, -V Show version
|
|
34
|
+
`;
|
|
35
|
+
export function getVersion() {
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
return require('../../package.json').version;
|
|
38
|
+
}
|
|
10
39
|
const CONFIG_TEMPLATE = `\
|
|
11
40
|
import { defineMutineerConfig } from 'mutineer'
|
|
12
41
|
|
|
@@ -19,7 +48,19 @@ export default defineMutineerConfig({
|
|
|
19
48
|
*/
|
|
20
49
|
async function main() {
|
|
21
50
|
const args = process.argv.slice(2);
|
|
51
|
+
if (args[0] === '--help' || args[0] === '-h') {
|
|
52
|
+
process.stdout.write(HELP_TEXT);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
if (args[0] === '--version' || args[0] === '-V') {
|
|
56
|
+
console.log(getVersion());
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
22
59
|
if (args[0] === RUN_COMMAND) {
|
|
60
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
61
|
+
process.stdout.write(HELP_TEXT);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
23
64
|
await runOrchestrator(args.slice(1), process.cwd());
|
|
24
65
|
}
|
|
25
66
|
else if (args[0] === INIT_COMMAND) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
|
|
2
|
+
import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, validatePositiveMs, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
|
|
3
3
|
describe('parseFlagNumber', () => {
|
|
4
4
|
it('parses valid integers', () => {
|
|
5
5
|
expect(parseFlagNumber('42', '--flag')).toBe(42);
|
|
@@ -202,6 +202,50 @@ describe('parseCliOptions', () => {
|
|
|
202
202
|
it('rejects invalid --min-kill-percent', () => {
|
|
203
203
|
expect(() => parseCliOptions(['--min-kill-percent', '150'], emptyCfg)).toThrow('expected value between 0 and 100');
|
|
204
204
|
});
|
|
205
|
+
it('parses --timeout flag', () => {
|
|
206
|
+
const opts = parseCliOptions(['--timeout', '5000'], emptyCfg);
|
|
207
|
+
expect(opts.timeout).toBe(5000);
|
|
208
|
+
});
|
|
209
|
+
it('parses --timeout with = syntax', () => {
|
|
210
|
+
const opts = parseCliOptions(['--timeout=5000'], emptyCfg);
|
|
211
|
+
expect(opts.timeout).toBe(5000);
|
|
212
|
+
});
|
|
213
|
+
it('returns undefined timeout when flag absent', () => {
|
|
214
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
215
|
+
expect(opts.timeout).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
it('config timeout does not affect opts.timeout (resolved in orchestrator)', () => {
|
|
218
|
+
const opts = parseCliOptions([], { timeout: 10000 });
|
|
219
|
+
expect(opts.timeout).toBeUndefined();
|
|
220
|
+
});
|
|
221
|
+
it('rejects --timeout 0', () => {
|
|
222
|
+
expect(() => parseCliOptions(['--timeout', '0'], emptyCfg)).toThrow('expected a positive number');
|
|
223
|
+
});
|
|
224
|
+
it('rejects --timeout -1', () => {
|
|
225
|
+
expect(() => parseCliOptions(['--timeout', '-1'], emptyCfg)).toThrow('expected a positive number');
|
|
226
|
+
});
|
|
227
|
+
it('rejects --timeout abc', () => {
|
|
228
|
+
expect(() => parseCliOptions(['--timeout', 'abc'], emptyCfg)).toThrow('Invalid value for --timeout: abc');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('validatePositiveMs', () => {
|
|
232
|
+
it('returns undefined for undefined', () => {
|
|
233
|
+
expect(validatePositiveMs(undefined, 'test')).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
it('returns the value for a positive number', () => {
|
|
236
|
+
expect(validatePositiveMs(5000, '--timeout')).toBe(5000);
|
|
237
|
+
expect(validatePositiveMs(1, '--timeout')).toBe(1);
|
|
238
|
+
});
|
|
239
|
+
it('throws for zero', () => {
|
|
240
|
+
expect(() => validatePositiveMs(0, '--timeout')).toThrow('Invalid --timeout: expected a positive number (received 0)');
|
|
241
|
+
});
|
|
242
|
+
it('throws for negative values', () => {
|
|
243
|
+
expect(() => validatePositiveMs(-1, '--timeout')).toThrow('Invalid --timeout: expected a positive number (received -1)');
|
|
244
|
+
});
|
|
245
|
+
it('throws for non-finite values', () => {
|
|
246
|
+
expect(() => validatePositiveMs(Infinity, '--timeout')).toThrow('Invalid --timeout: expected a positive number');
|
|
247
|
+
expect(() => validatePositiveMs(NaN, '--timeout')).toThrow('Invalid --timeout: expected a positive number');
|
|
248
|
+
});
|
|
205
249
|
});
|
|
206
250
|
describe('extractConfigPath', () => {
|
|
207
251
|
it('extracts --config with separate value', () => {
|
|
@@ -145,11 +145,94 @@ describe('listChangedFiles', () => {
|
|
|
145
145
|
}
|
|
146
146
|
return { status: 0, stdout: '' };
|
|
147
147
|
});
|
|
148
|
-
// File with imports - the import resolution will fail (no real files)
|
|
149
|
-
// but it exercises the parsing code paths
|
|
150
148
|
readFileSyncMock.mockReturnValue('import { bar } from "./bar"\nexport { baz } from "./baz"\nconst x = require("./qux")');
|
|
149
|
+
existsSyncMock.mockImplementation((p) => {
|
|
150
|
+
// Changed file and .ts variants of deps exist
|
|
151
|
+
return [
|
|
152
|
+
'/repo/src/foo.ts',
|
|
153
|
+
'/repo/src/bar.ts',
|
|
154
|
+
'/repo/src/baz.ts',
|
|
155
|
+
'/repo/src/qux.ts',
|
|
156
|
+
].includes(p);
|
|
157
|
+
});
|
|
151
158
|
const result = listChangedFiles('/repo', { includeDeps: true });
|
|
152
159
|
expect(result).toContain('/repo/src/foo.ts');
|
|
160
|
+
expect(result).toContain('/repo/src/bar.ts');
|
|
161
|
+
expect(result).toContain('/repo/src/baz.ts');
|
|
162
|
+
expect(result).toContain('/repo/src/qux.ts');
|
|
163
|
+
});
|
|
164
|
+
it('resolves dep with .ts extension when imported without extension', () => {
|
|
165
|
+
spawnSyncMock.mockImplementation((_cmd, args) => {
|
|
166
|
+
if (args.includes('--show-toplevel'))
|
|
167
|
+
return { status: 0, stdout: '/repo\n' };
|
|
168
|
+
if (args.includes('main...HEAD'))
|
|
169
|
+
return { status: 0, stdout: 'src/foo.ts\0' };
|
|
170
|
+
return { status: 0, stdout: '' };
|
|
171
|
+
});
|
|
172
|
+
readFileSyncMock.mockReturnValue('import { x } from "./utils"');
|
|
173
|
+
existsSyncMock.mockImplementation((p) => {
|
|
174
|
+
return ['/repo/src/foo.ts', '/repo/src/utils.ts'].includes(p);
|
|
175
|
+
});
|
|
176
|
+
const result = listChangedFiles('/repo', { includeDeps: true });
|
|
177
|
+
expect(result).toContain('/repo/src/utils.ts');
|
|
178
|
+
});
|
|
179
|
+
it('excludes deps outside cwd', () => {
|
|
180
|
+
spawnSyncMock.mockImplementation((_cmd, args) => {
|
|
181
|
+
if (args.includes('--show-toplevel'))
|
|
182
|
+
return { status: 0, stdout: '/repo\n' };
|
|
183
|
+
if (args.includes('main...HEAD'))
|
|
184
|
+
return { status: 0, stdout: 'src/foo.ts\0' };
|
|
185
|
+
return { status: 0, stdout: '' };
|
|
186
|
+
});
|
|
187
|
+
readFileSyncMock.mockReturnValue('import { x } from "../../outside/dep"');
|
|
188
|
+
existsSyncMock.mockImplementation((p) => {
|
|
189
|
+
return ['/repo/src/foo.ts', '/outside/dep.ts'].includes(p);
|
|
190
|
+
});
|
|
191
|
+
const result = listChangedFiles('/repo', { includeDeps: true });
|
|
192
|
+
expect(result).not.toContain('/outside/dep.ts');
|
|
193
|
+
expect(result).toHaveLength(1);
|
|
194
|
+
});
|
|
195
|
+
it('excludes deps inside node_modules', () => {
|
|
196
|
+
spawnSyncMock.mockImplementation((_cmd, args) => {
|
|
197
|
+
if (args.includes('--show-toplevel'))
|
|
198
|
+
return { status: 0, stdout: '/repo\n' };
|
|
199
|
+
if (args.includes('main...HEAD'))
|
|
200
|
+
return { status: 0, stdout: 'src/foo.ts\0' };
|
|
201
|
+
return { status: 0, stdout: '' };
|
|
202
|
+
});
|
|
203
|
+
readFileSyncMock.mockReturnValue('import { x } from "./node_modules/pkg/index"');
|
|
204
|
+
existsSyncMock.mockImplementation((p) => {
|
|
205
|
+
return p === '/repo/src/foo.ts' || p.includes('node_modules');
|
|
206
|
+
});
|
|
207
|
+
const result = listChangedFiles('/repo', { includeDeps: true });
|
|
208
|
+
expect(result.some((p) => p.includes('node_modules'))).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
it('resolves direct dep but not transitive dep at maxDepth=1', () => {
|
|
211
|
+
spawnSyncMock.mockImplementation((_cmd, args) => {
|
|
212
|
+
if (args.includes('--show-toplevel'))
|
|
213
|
+
return { status: 0, stdout: '/repo\n' };
|
|
214
|
+
if (args.includes('main...HEAD'))
|
|
215
|
+
return { status: 0, stdout: 'src/foo.ts\0' };
|
|
216
|
+
return { status: 0, stdout: '' };
|
|
217
|
+
});
|
|
218
|
+
readFileSyncMock.mockImplementation((p) => {
|
|
219
|
+
if (p === '/repo/src/foo.ts')
|
|
220
|
+
return 'import { x } from "./bar"';
|
|
221
|
+
if (p === '/repo/src/bar.ts')
|
|
222
|
+
return 'import { y } from "./baz"';
|
|
223
|
+
return '';
|
|
224
|
+
});
|
|
225
|
+
existsSyncMock.mockImplementation((p) => {
|
|
226
|
+
return [
|
|
227
|
+
'/repo/src/foo.ts',
|
|
228
|
+
'/repo/src/bar.ts',
|
|
229
|
+
'/repo/src/baz.ts',
|
|
230
|
+
].includes(p);
|
|
231
|
+
});
|
|
232
|
+
const result = listChangedFiles('/repo', { includeDeps: true, maxDepth: 1 });
|
|
233
|
+
expect(result).toContain('/repo/src/foo.ts');
|
|
234
|
+
expect(result).toContain('/repo/src/bar.ts');
|
|
235
|
+
expect(result).not.toContain('/repo/src/baz.ts');
|
|
153
236
|
});
|
|
154
237
|
it('skips non-local imports in dependency resolution', () => {
|
|
155
238
|
spawnSyncMock.mockImplementation((_cmd, args) => {
|
|
@@ -52,20 +52,9 @@ describe('loadMutineerConfig', () => {
|
|
|
52
52
|
await fs.writeFile(configFile, '??? not valid javascript ???');
|
|
53
53
|
await expect(loadMutineerConfig(tmpDir)).rejects.toThrow(/Failed to load config from/);
|
|
54
54
|
});
|
|
55
|
-
|
|
56
|
-
// 1. validateConfig uses `&&` instead of `||`: `typeof config !== 'object' && config === null`
|
|
57
|
-
// Since typeof null === 'object', this condition is always false, so null passes validation.
|
|
58
|
-
// 2. loadModule uses `||` instead of `??`: `mod.default || mod`
|
|
59
|
-
// When default export is null (falsy), it falls back to the module namespace object.
|
|
60
|
-
// Together: null configs pass validation AND get returned as the module namespace.
|
|
61
|
-
it('BUG: null config passes validation due to && vs || logic error', async () => {
|
|
55
|
+
it('throws when config exports null', async () => {
|
|
62
56
|
const configFile = path.join(tmpDir, 'mutineer.config.mjs');
|
|
63
57
|
await fs.writeFile(configFile, 'export default null');
|
|
64
|
-
|
|
65
|
-
// Additionally, loadModule returns { default: null } instead of null
|
|
66
|
-
// because it uses || (which treats null as falsy) instead of ??
|
|
67
|
-
const config = await loadMutineerConfig(tmpDir);
|
|
68
|
-
// Bug: returns the module namespace object instead of throwing
|
|
69
|
-
expect(config).toHaveProperty('default', null);
|
|
58
|
+
await expect(loadMutineerConfig(tmpDir)).rejects.toThrow('does not export a valid configuration object');
|
|
70
59
|
});
|
|
71
60
|
});
|
|
@@ -15,6 +15,7 @@ function makeOpts(overrides = {}) {
|
|
|
15
15
|
progressMode: 'bar',
|
|
16
16
|
minKillPercent: undefined,
|
|
17
17
|
runner: 'vitest',
|
|
18
|
+
timeout: undefined,
|
|
18
19
|
...overrides,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
@@ -62,7 +63,9 @@ describe('resolveCoverageConfig', () => {
|
|
|
62
63
|
});
|
|
63
64
|
it('sets exitCode when onlyCoveredLines is set but no coverage provider', async () => {
|
|
64
65
|
const opts = makeOpts({ wantsOnlyCoveredLines: true });
|
|
65
|
-
const adapter = makeAdapter({
|
|
66
|
+
const adapter = makeAdapter({
|
|
67
|
+
hasCoverageProvider: vi.fn().mockReturnValue(false),
|
|
68
|
+
});
|
|
66
69
|
await resolveCoverageConfig(opts, {}, adapter, []);
|
|
67
70
|
expect(process.exitCode).toBe(1);
|
|
68
71
|
});
|
|
@@ -129,7 +132,9 @@ describe('loadCoverageAfterBaseline', () => {
|
|
|
129
132
|
const coverageJson = {
|
|
130
133
|
'/src/foo.ts': {
|
|
131
134
|
path: '/src/foo.ts',
|
|
132
|
-
statementMap: {
|
|
135
|
+
statementMap: {
|
|
136
|
+
'0': { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
|
|
137
|
+
},
|
|
133
138
|
s: { '0': 1 },
|
|
134
139
|
},
|
|
135
140
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
// Mock all heavy dependencies before importing orchestrator
|
|
3
|
+
vi.mock('../config.js', () => ({
|
|
4
|
+
loadMutineerConfig: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('../cache.js', () => ({
|
|
7
|
+
clearCacheOnStart: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
readMutantCache: vi.fn().mockResolvedValue({}),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('../vitest/index.js', () => ({
|
|
11
|
+
createVitestAdapter: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../jest/index.js', () => ({
|
|
14
|
+
createJestAdapter: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('../coverage-resolver.js', () => ({
|
|
17
|
+
resolveCoverageConfig: vi.fn().mockResolvedValue({
|
|
18
|
+
enableCoverageForBaseline: false,
|
|
19
|
+
wantsPerTestCoverage: false,
|
|
20
|
+
coverageData: null,
|
|
21
|
+
}),
|
|
22
|
+
loadCoverageAfterBaseline: vi.fn().mockResolvedValue({
|
|
23
|
+
coverageData: null,
|
|
24
|
+
perTestCoverage: null,
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('../discover.js', () => ({
|
|
28
|
+
autoDiscoverTargetsAndTests: vi.fn().mockResolvedValue({
|
|
29
|
+
targets: [],
|
|
30
|
+
testMap: new Map(),
|
|
31
|
+
directTestMap: new Map(),
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('../changed.js', () => ({
|
|
35
|
+
listChangedFiles: vi.fn().mockReturnValue([]),
|
|
36
|
+
}));
|
|
37
|
+
import { runOrchestrator } from '../orchestrator.js';
|
|
38
|
+
import { loadMutineerConfig } from '../config.js';
|
|
39
|
+
import { createVitestAdapter } from '../vitest/index.js';
|
|
40
|
+
import { autoDiscoverTargetsAndTests } from '../discover.js';
|
|
41
|
+
import { listChangedFiles } from '../changed.js';
|
|
42
|
+
const mockAdapter = {
|
|
43
|
+
name: 'vitest',
|
|
44
|
+
init: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
runBaseline: vi.fn().mockResolvedValue(true),
|
|
46
|
+
runMutant: vi.fn().mockResolvedValue({ status: 'killed', durationMs: 10 }),
|
|
47
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
hasCoverageProvider: vi.fn().mockReturnValue(false),
|
|
49
|
+
detectCoverageConfig: vi
|
|
50
|
+
.fn()
|
|
51
|
+
.mockResolvedValue({ perTestEnabled: false, coverageEnabled: false }),
|
|
52
|
+
};
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
|
|
56
|
+
});
|
|
57
|
+
describe('runOrchestrator --changed-with-deps diagnostic', () => {
|
|
58
|
+
it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
|
|
59
|
+
const cfg = {};
|
|
60
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
61
|
+
const depFile = '/cwd/src/dep.ts';
|
|
62
|
+
vi.mocked(listChangedFiles).mockReturnValue([depFile]);
|
|
63
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
64
|
+
targets: [depFile],
|
|
65
|
+
testMap: new Map(), // dep has no covering tests
|
|
66
|
+
directTestMap: new Map(),
|
|
67
|
+
});
|
|
68
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
69
|
+
await runOrchestrator(['--changed-with-deps'], '/cwd');
|
|
70
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 target(s) from --changed-with-deps have no covering tests and will be skipped'));
|
|
71
|
+
});
|
|
72
|
+
it('does not log when all changed-with-deps targets have covering tests', async () => {
|
|
73
|
+
const cfg = {};
|
|
74
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
75
|
+
const depFile = '/cwd/src/dep.ts';
|
|
76
|
+
const testFile = '/cwd/src/__tests__/dep.spec.ts';
|
|
77
|
+
vi.mocked(listChangedFiles).mockReturnValue([depFile]);
|
|
78
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
79
|
+
targets: [depFile],
|
|
80
|
+
testMap: new Map([[depFile, new Set([testFile])]]),
|
|
81
|
+
directTestMap: new Map(),
|
|
82
|
+
});
|
|
83
|
+
vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
|
|
84
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
85
|
+
await runOrchestrator(['--changed-with-deps'], '/cwd');
|
|
86
|
+
const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
|
|
87
|
+
expect(diagnosticCalls).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
it('does not log diagnostic when wantsChangedWithDeps is false', async () => {
|
|
90
|
+
const cfg = {};
|
|
91
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
92
|
+
const depFile = '/cwd/src/dep.ts';
|
|
93
|
+
vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
|
|
94
|
+
targets: [depFile],
|
|
95
|
+
testMap: new Map(),
|
|
96
|
+
directTestMap: new Map(),
|
|
97
|
+
});
|
|
98
|
+
const consoleSpy = vi.spyOn(console, 'log');
|
|
99
|
+
await runOrchestrator([], '/cwd');
|
|
100
|
+
const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
|
|
101
|
+
expect(diagnosticCalls).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
describe('runOrchestrator timeout precedence', () => {
|
|
105
|
+
it('uses CLI --timeout when provided', async () => {
|
|
106
|
+
const cfg = {};
|
|
107
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
108
|
+
await runOrchestrator(['--timeout', '5000'], '/cwd');
|
|
109
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 5000 }));
|
|
110
|
+
});
|
|
111
|
+
it('uses config timeout when CLI flag is absent', async () => {
|
|
112
|
+
const cfg = { timeout: 10000 };
|
|
113
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
114
|
+
await runOrchestrator([], '/cwd');
|
|
115
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 10000 }));
|
|
116
|
+
});
|
|
117
|
+
it('CLI --timeout takes precedence over config timeout', async () => {
|
|
118
|
+
const cfg = { timeout: 10000 };
|
|
119
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
120
|
+
await runOrchestrator(['--timeout', '2000'], '/cwd');
|
|
121
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2000 }));
|
|
122
|
+
});
|
|
123
|
+
it('falls back to MUTANT_TIMEOUT_MS default when neither CLI nor config timeout set', async () => {
|
|
124
|
+
const cfg = {};
|
|
125
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
126
|
+
await runOrchestrator([], '/cwd');
|
|
127
|
+
expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
|
|
128
|
+
});
|
|
129
|
+
it('env var MUTINEER_MUTANT_TIMEOUT_MS affects default when no CLI/config timeout', async () => {
|
|
130
|
+
const cfg = {};
|
|
131
|
+
vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
|
|
132
|
+
const orig = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
|
|
133
|
+
// env var is read at module load time, so we can only verify the default
|
|
134
|
+
// is used when no cli/config override is present
|
|
135
|
+
process.env.MUTINEER_MUTANT_TIMEOUT_MS = orig;
|
|
136
|
+
await runOrchestrator([], '/cwd');
|
|
137
|
+
const call = vi.mocked(createVitestAdapter).mock.calls[0][0];
|
|
138
|
+
expect(call.timeoutMs).toBeGreaterThan(0);
|
|
139
|
+
expect(Number.isFinite(call.timeoutMs)).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
package/dist/runner/args.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface ParsedCliOptions {
|
|
|
19
19
|
readonly progressMode: 'bar' | 'list' | 'quiet';
|
|
20
20
|
readonly minKillPercent: number | undefined;
|
|
21
21
|
readonly runner: 'vitest' | 'jest';
|
|
22
|
+
readonly timeout: number | undefined;
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
25
|
* Parse a numeric CLI flag value.
|
|
@@ -36,6 +37,10 @@ export declare function readStringFlag(args: readonly string[], flag: string, al
|
|
|
36
37
|
* Validate a percentage value (0-100).
|
|
37
38
|
*/
|
|
38
39
|
export declare function validatePercent(value: number | undefined, source: string): number | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Validate a timeout value (must be a positive finite number).
|
|
42
|
+
*/
|
|
43
|
+
export declare function validatePositiveMs(value: number | undefined, source: string): number | undefined;
|
|
39
44
|
/**
|
|
40
45
|
* Parse concurrency from CLI args or use default.
|
|
41
46
|
*/
|
package/dist/runner/args.js
CHANGED
|
@@ -70,6 +70,17 @@ export function validatePercent(value, source) {
|
|
|
70
70
|
}
|
|
71
71
|
return value;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Validate a timeout value (must be a positive finite number).
|
|
75
|
+
*/
|
|
76
|
+
export function validatePositiveMs(value, source) {
|
|
77
|
+
if (value === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
80
|
+
throw new Error(`Invalid ${source}: expected a positive number (received ${value})`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
73
84
|
/**
|
|
74
85
|
* Parse concurrency from CLI args or use default.
|
|
75
86
|
*/
|
|
@@ -115,6 +126,7 @@ export function parseCliOptions(args, cfg) {
|
|
|
115
126
|
const cliKillPercent = validatePercent(readNumberFlag(args, '--min-kill-percent'), '--min-kill-percent');
|
|
116
127
|
const configKillPercent = validatePercent(cfg.minKillPercent, 'mutineer.config minKillPercent');
|
|
117
128
|
const minKillPercent = cliKillPercent ?? configKillPercent;
|
|
129
|
+
const timeout = validatePositiveMs(readNumberFlag(args, '--timeout'), '--timeout');
|
|
118
130
|
return {
|
|
119
131
|
configPath,
|
|
120
132
|
wantsChanged,
|
|
@@ -126,5 +138,6 @@ export function parseCliOptions(args, cfg) {
|
|
|
126
138
|
progressMode,
|
|
127
139
|
minKillPercent,
|
|
128
140
|
runner,
|
|
141
|
+
timeout,
|
|
129
142
|
};
|
|
130
143
|
}
|
package/dist/runner/changed.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
-
import { createRequire } from 'node:module';
|
|
5
4
|
import { normalizePath } from '../utils/normalizePath.js';
|
|
6
5
|
import { createLogger } from '../utils/logger.js';
|
|
7
6
|
const log = createLogger('changed');
|
|
@@ -50,52 +49,25 @@ function resolveLocalDependencies(file, cwd, seen = new Set(), maxDepth = 1, cur
|
|
|
50
49
|
const dep = match[1];
|
|
51
50
|
if (!dep.startsWith('.'))
|
|
52
51
|
continue;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
catch {
|
|
61
|
-
// Try different extensions if direct resolution fails
|
|
62
|
-
for (const ext of SUPPORTED_EXTENSIONS) {
|
|
63
|
-
try {
|
|
64
|
-
// Try replacing existing extension
|
|
65
|
-
resolvedPath = require.resolve(dep.replace(/\.(js|ts|vue)$/, ext));
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
try {
|
|
70
|
-
// Try adding extension
|
|
71
|
-
resolvedPath = require.resolve(dep + ext);
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (!resolvedPath) {
|
|
81
|
-
log.warn(`Could not resolve path for dependency ${dep} in ${file}`);
|
|
52
|
+
const dir = path.dirname(file);
|
|
53
|
+
const base = dep.replace(/\.(js|ts|mjs|cjs|vue)$/, '');
|
|
54
|
+
const candidates = [dep, ...SUPPORTED_EXTENSIONS.map((ext) => base + ext)];
|
|
55
|
+
let resolvedPath;
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
const abs = normalizePath(path.resolve(dir, candidate));
|
|
58
|
+
if (abs.includes('node_modules') || !abs.startsWith(cwd))
|
|
82
59
|
continue;
|
|
60
|
+
if (fs.existsSync(abs)) {
|
|
61
|
+
resolvedPath = abs;
|
|
62
|
+
break;
|
|
83
63
|
}
|
|
84
|
-
// Skip anything outside the repo/cwd, node_modules, tests, or missing
|
|
85
|
-
if (!resolvedPath.startsWith(cwd) ||
|
|
86
|
-
resolvedPath.includes('node_modules'))
|
|
87
|
-
continue;
|
|
88
|
-
if (TEST_FILE_PATTERN.test(resolvedPath))
|
|
89
|
-
continue;
|
|
90
|
-
if (!fs.existsSync(resolvedPath))
|
|
91
|
-
continue;
|
|
92
|
-
deps.push(normalizePath(resolvedPath));
|
|
93
|
-
deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
|
|
94
64
|
}
|
|
95
|
-
|
|
96
|
-
log.warn(`Failed to resolve dependency ${dep} in ${file}: ${err}`);
|
|
65
|
+
if (!resolvedPath)
|
|
97
66
|
continue;
|
|
98
|
-
|
|
67
|
+
if (TEST_FILE_PATTERN.test(resolvedPath))
|
|
68
|
+
continue;
|
|
69
|
+
deps.push(resolvedPath);
|
|
70
|
+
deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
|
|
99
71
|
}
|
|
100
72
|
}
|
|
101
73
|
return [...new Set(deps)];
|
package/dist/runner/config.js
CHANGED
|
@@ -19,7 +19,7 @@ const log = createLogger('config');
|
|
|
19
19
|
async function loadModule(filePath) {
|
|
20
20
|
const moduleUrl = pathToFileURL(filePath).href;
|
|
21
21
|
const mod = await import(moduleUrl);
|
|
22
|
-
return mod.default
|
|
22
|
+
return 'default' in mod ? mod.default : mod;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Validate that the loaded configuration has the expected shape.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
2
3
|
import { JestPool, runWithJestPool } from '../pool.js';
|
|
3
4
|
// We'll use the createWorker option to inject mock workers instead of forking processes
|
|
4
5
|
function makeMockWorker(id) {
|
|
@@ -110,6 +111,46 @@ describe('JestPool', () => {
|
|
|
110
111
|
// After shutdown, initialised is set to false, so "not initialised" check fires first
|
|
111
112
|
await expect(pool.run(dummyMutant, ['test.ts'])).rejects.toThrow('Pool not initialised');
|
|
112
113
|
});
|
|
114
|
+
it('does not give a dead worker to a waiting task after timeout', async () => {
|
|
115
|
+
let callCount = 0;
|
|
116
|
+
const allWorkers = [];
|
|
117
|
+
const pool = new JestPool({
|
|
118
|
+
cwd: '/tmp',
|
|
119
|
+
concurrency: 1,
|
|
120
|
+
createWorker: (id) => {
|
|
121
|
+
callCount++;
|
|
122
|
+
const workerNum = callCount;
|
|
123
|
+
const worker = new EventEmitter();
|
|
124
|
+
worker.id = id;
|
|
125
|
+
worker._ready = true;
|
|
126
|
+
worker.start = vi.fn().mockResolvedValue(undefined);
|
|
127
|
+
worker.isReady = vi.fn(() => worker._ready);
|
|
128
|
+
worker.isBusy = vi.fn().mockReturnValue(false);
|
|
129
|
+
worker.run = vi.fn().mockImplementation(async () => {
|
|
130
|
+
if (workerNum === 1) {
|
|
131
|
+
worker._ready = false;
|
|
132
|
+
Promise.resolve().then(() => worker.emit('exit'));
|
|
133
|
+
return { killed: false, durationMs: 5000, error: 'timeout' };
|
|
134
|
+
}
|
|
135
|
+
return { killed: true, durationMs: 42 };
|
|
136
|
+
});
|
|
137
|
+
worker.shutdown = vi.fn().mockResolvedValue(undefined);
|
|
138
|
+
worker.kill = vi.fn();
|
|
139
|
+
allWorkers.push(worker);
|
|
140
|
+
return worker;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
await pool.init();
|
|
144
|
+
const [result1, result2] = await Promise.all([
|
|
145
|
+
pool.run(dummyMutant, ['a.spec.ts']),
|
|
146
|
+
pool.run({ ...dummyMutant, id: 'test#2' }, ['b.spec.ts']),
|
|
147
|
+
]);
|
|
148
|
+
expect(result1).toMatchObject({ error: 'timeout' });
|
|
149
|
+
expect(result2).toMatchObject({ killed: true });
|
|
150
|
+
expect(allWorkers).toHaveLength(2);
|
|
151
|
+
expect(allWorkers[1].run).toHaveBeenCalled();
|
|
152
|
+
await pool.shutdown();
|
|
153
|
+
});
|
|
113
154
|
it('does not double-shutdown', async () => {
|
|
114
155
|
const workers = [];
|
|
115
156
|
const pool = new JestPool({
|
package/dist/runner/jest/pool.js
CHANGED
|
@@ -239,14 +239,14 @@ export class JestPool {
|
|
|
239
239
|
});
|
|
240
240
|
}
|
|
241
241
|
releaseWorker(worker) {
|
|
242
|
+
if (!worker.isReady())
|
|
243
|
+
return;
|
|
242
244
|
const waiting = this.waitingTasks.shift();
|
|
243
245
|
if (waiting) {
|
|
244
246
|
waiting(worker);
|
|
245
247
|
return;
|
|
246
248
|
}
|
|
247
|
-
|
|
248
|
-
this.availableWorkers.push(worker);
|
|
249
|
-
}
|
|
249
|
+
this.availableWorkers.push(worker);
|
|
250
250
|
}
|
|
251
251
|
async run(mutant, tests) {
|
|
252
252
|
if (!this.initialised) {
|
|
@@ -43,7 +43,7 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
43
43
|
const adapter = (opts.runner === 'jest' ? createJestAdapter : createVitestAdapter)({
|
|
44
44
|
cwd,
|
|
45
45
|
concurrency: opts.concurrency,
|
|
46
|
-
timeoutMs: MUTANT_TIMEOUT_MS,
|
|
46
|
+
timeoutMs: opts.timeout ?? cfg.timeout ?? MUTANT_TIMEOUT_MS,
|
|
47
47
|
config: cfg,
|
|
48
48
|
cliArgs,
|
|
49
49
|
});
|
|
@@ -88,6 +88,21 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
const baselineTests = Array.from(allTestFiles);
|
|
91
|
+
if (opts.wantsChangedWithDeps) {
|
|
92
|
+
let uncoveredCount = 0;
|
|
93
|
+
for (const target of targets) {
|
|
94
|
+
const absFile = normalizePath(path.isAbsolute(getTargetFile(target))
|
|
95
|
+
? getTargetFile(target)
|
|
96
|
+
: path.join(cwd, getTargetFile(target)));
|
|
97
|
+
if (changedAbs?.has(absFile) &&
|
|
98
|
+
!testMap.get(normalizePath(absFile))?.size) {
|
|
99
|
+
uncoveredCount++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (uncoveredCount > 0) {
|
|
103
|
+
log.info(`${uncoveredCount} target(s) from --changed-with-deps have no covering tests and will be skipped`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
91
106
|
if (!baselineTests.length) {
|
|
92
107
|
log.info('No tests found for targets. Exiting.');
|
|
93
108
|
return;
|
|
@@ -103,6 +103,25 @@ describe('Vitest adapter', () => {
|
|
|
103
103
|
expect(args.join(' ')).toContain('--coverage.enabled=true');
|
|
104
104
|
expect(args.join(' ')).toContain('--coverage.perTest=true');
|
|
105
105
|
});
|
|
106
|
+
it('disables coverage thresholds in baseline-with-coverage to prevent threshold failures', async () => {
|
|
107
|
+
const adapter = makeAdapter({ cliArgs: [] });
|
|
108
|
+
spawnMock.mockImplementationOnce(() => ({
|
|
109
|
+
on: (evt, cb) => {
|
|
110
|
+
if (evt === 'exit')
|
|
111
|
+
cb(0);
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
114
|
+
await adapter.runBaseline(['test-a'], {
|
|
115
|
+
collectCoverage: true,
|
|
116
|
+
perTestCoverage: false,
|
|
117
|
+
});
|
|
118
|
+
const args = spawnMock.mock.calls[0][1];
|
|
119
|
+
const argStr = args.join(' ');
|
|
120
|
+
expect(argStr).toContain('--coverage.thresholds.lines=0');
|
|
121
|
+
expect(argStr).toContain('--coverage.thresholds.functions=0');
|
|
122
|
+
expect(argStr).toContain('--coverage.thresholds.branches=0');
|
|
123
|
+
expect(argStr).toContain('--coverage.thresholds.statements=0');
|
|
124
|
+
});
|
|
106
125
|
it('detects coverage config from vitest config file', async () => {
|
|
107
126
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-vitest-'));
|
|
108
127
|
const cfgPath = path.join(tmp, 'vitest.config.ts');
|
|
@@ -88,6 +88,63 @@ describe('VitestPool', () => {
|
|
|
88
88
|
expect(result).toEqual({ status: 'escaped', durationMs: 7 });
|
|
89
89
|
expect(mockPool.run).toHaveBeenCalledWith(mutant, ['bar.spec.ts']);
|
|
90
90
|
});
|
|
91
|
+
it('does not give a dead worker to a waiting task after timeout', async () => {
|
|
92
|
+
let callCount = 0;
|
|
93
|
+
const allWorkers = [];
|
|
94
|
+
const pool = new VitestPool({
|
|
95
|
+
cwd: process.cwd(),
|
|
96
|
+
concurrency: 1,
|
|
97
|
+
timeoutMs: 5000,
|
|
98
|
+
createWorker: (id) => {
|
|
99
|
+
callCount++;
|
|
100
|
+
const workerNum = callCount;
|
|
101
|
+
const worker = new EventEmitter();
|
|
102
|
+
worker.id = id;
|
|
103
|
+
worker._ready = true;
|
|
104
|
+
worker.start = vi.fn().mockResolvedValue(undefined);
|
|
105
|
+
worker.isReady = vi.fn(() => worker._ready);
|
|
106
|
+
worker.isBusy = vi.fn().mockReturnValue(false);
|
|
107
|
+
worker.run = vi.fn().mockImplementation(async () => {
|
|
108
|
+
if (workerNum === 1) {
|
|
109
|
+
worker._ready = false;
|
|
110
|
+
Promise.resolve().then(() => worker.emit('exit'));
|
|
111
|
+
return { killed: false, durationMs: 5000, error: 'timeout' };
|
|
112
|
+
}
|
|
113
|
+
return { killed: true, durationMs: 42 };
|
|
114
|
+
});
|
|
115
|
+
worker.shutdown = vi.fn().mockResolvedValue(undefined);
|
|
116
|
+
worker.kill = vi.fn();
|
|
117
|
+
allWorkers.push(worker);
|
|
118
|
+
return worker;
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
await pool.init();
|
|
122
|
+
const mutant1 = {
|
|
123
|
+
id: '1',
|
|
124
|
+
name: 'm1',
|
|
125
|
+
file: 'a.ts',
|
|
126
|
+
code: 'x',
|
|
127
|
+
line: 1,
|
|
128
|
+
col: 1,
|
|
129
|
+
};
|
|
130
|
+
const mutant2 = {
|
|
131
|
+
id: '2',
|
|
132
|
+
name: 'm2',
|
|
133
|
+
file: 'b.ts',
|
|
134
|
+
code: 'y',
|
|
135
|
+
line: 1,
|
|
136
|
+
col: 1,
|
|
137
|
+
};
|
|
138
|
+
const [result1, result2] = await Promise.all([
|
|
139
|
+
pool.run(mutant1, ['a.spec.ts']),
|
|
140
|
+
pool.run(mutant2, ['b.spec.ts']),
|
|
141
|
+
]);
|
|
142
|
+
expect(result1).toMatchObject({ error: 'timeout' });
|
|
143
|
+
expect(result2).toMatchObject({ killed: true });
|
|
144
|
+
expect(allWorkers).toHaveLength(2);
|
|
145
|
+
expect(allWorkers[1].run).toHaveBeenCalled();
|
|
146
|
+
await pool.shutdown();
|
|
147
|
+
});
|
|
91
148
|
it('maps runWithPool errors to error status', async () => {
|
|
92
149
|
const mockPool = {
|
|
93
150
|
run: vi.fn().mockRejectedValue(new Error('boom')),
|
|
@@ -96,6 +96,9 @@ function buildVitestArgs(args, mode) {
|
|
|
96
96
|
if (!result.some((a) => a.startsWith('--coverage.perTest='))) {
|
|
97
97
|
result.push('--coverage.perTest=true');
|
|
98
98
|
}
|
|
99
|
+
// Disable coverage thresholds so baseline doesn't fail when a broader
|
|
100
|
+
// test set (e.g. from --changed-with-deps) lowers aggregate coverage
|
|
101
|
+
result.push('--coverage.thresholds.lines=0', '--coverage.thresholds.functions=0', '--coverage.thresholds.branches=0', '--coverage.thresholds.statements=0');
|
|
99
102
|
}
|
|
100
103
|
return result;
|
|
101
104
|
}
|
|
@@ -301,6 +301,8 @@ export class VitestPool {
|
|
|
301
301
|
});
|
|
302
302
|
}
|
|
303
303
|
releaseWorker(worker) {
|
|
304
|
+
if (!worker.isReady())
|
|
305
|
+
return;
|
|
304
306
|
// If someone is waiting, give them the worker directly
|
|
305
307
|
const waiting = this.waitingTasks.shift();
|
|
306
308
|
if (waiting) {
|
|
@@ -308,9 +310,7 @@ export class VitestPool {
|
|
|
308
310
|
return;
|
|
309
311
|
}
|
|
310
312
|
// Otherwise return to the pool
|
|
311
|
-
|
|
312
|
-
this.availableWorkers.push(worker);
|
|
313
|
-
}
|
|
313
|
+
this.availableWorkers.push(worker);
|
|
314
314
|
}
|
|
315
315
|
async run(mutant, tests) {
|
|
316
316
|
if (!this.initialised) {
|
package/dist/types/config.d.ts
CHANGED