@mutineerjs/mutineer 0.5.0 → 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/core/__tests__/variant-utils.spec.js +24 -2
- package/dist/core/sfc.js +6 -1
- package/dist/core/variant-utils.js +6 -1
- package/dist/mutators/__tests__/operator.spec.js +16 -0
- package/dist/mutators/__tests__/return-value.spec.js +37 -0
- package/dist/mutators/__tests__/utils.spec.js +96 -2
- package/dist/mutators/operator.js +11 -5
- package/dist/mutators/return-value.js +36 -22
- package/dist/mutators/types.d.ts +2 -0
- package/dist/mutators/utils.d.ts +67 -0
- package/dist/mutators/utils.js +90 -15
- 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__/discover.spec.js +52 -1
- package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
- package/dist/runner/__tests__/orchestrator.spec.js +141 -0
- package/dist/runner/__tests__/pool-executor.spec.js +40 -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/discover.js +21 -12
- 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/pool-executor.js +7 -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/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.
|
package/dist/runner/discover.js
CHANGED
|
@@ -84,15 +84,24 @@ async function createViteResolver(rootAbs, exts) {
|
|
|
84
84
|
// Load Vue plugin if needed
|
|
85
85
|
let plugins = [];
|
|
86
86
|
if (exts.has('.vue')) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
const vueFiles = await fg(['**/*.vue'], {
|
|
88
|
+
cwd: rootAbs,
|
|
89
|
+
onlyFiles: true,
|
|
90
|
+
ignore: ['**/node_modules/**'],
|
|
91
|
+
deep: 5,
|
|
92
|
+
});
|
|
93
|
+
if (vueFiles.length > 0) {
|
|
94
|
+
try {
|
|
95
|
+
const mod = await import(
|
|
96
|
+
/* @vite-ignore */ '@vitejs/plugin-vue');
|
|
97
|
+
const vue = mod.default ?? mod;
|
|
98
|
+
plugins =
|
|
99
|
+
typeof vue === 'function' ? [vue()] : [];
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
103
|
+
log.warn(`Unable to load @vitejs/plugin-vue; Vue SFC imports may fail to resolve (${detail})`);
|
|
104
|
+
}
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
const quietLogger = {
|
|
@@ -280,12 +289,12 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
280
289
|
}
|
|
281
290
|
}
|
|
282
291
|
try {
|
|
283
|
-
|
|
292
|
+
await Promise.all(tests.map(async (testAbs) => {
|
|
284
293
|
const seen = new Set();
|
|
285
294
|
// prime with the test's own direct imports
|
|
286
295
|
const code = safeRead(testAbs);
|
|
287
296
|
if (!code)
|
|
288
|
-
|
|
297
|
+
return;
|
|
289
298
|
const firstHop = [];
|
|
290
299
|
for (const spec of extractImportSpecs(code)) {
|
|
291
300
|
if (!spec)
|
|
@@ -302,7 +311,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
302
311
|
for (const abs of firstHop) {
|
|
303
312
|
await crawl(abs, 0, seen, testAbs);
|
|
304
313
|
}
|
|
305
|
-
}
|
|
314
|
+
}));
|
|
306
315
|
return { targets: Array.from(targets.values()), testMap, directTestMap };
|
|
307
316
|
}
|
|
308
317
|
finally {
|
|
@@ -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;
|
|
@@ -64,6 +64,7 @@ export async function executePool(opts) {
|
|
|
64
64
|
const poolDurationMs = Date.now() - poolStart;
|
|
65
65
|
log.info(`\u2713 Worker pool ready (${poolDurationMs}ms)`);
|
|
66
66
|
progress.start();
|
|
67
|
+
const fileCache = new Map();
|
|
67
68
|
let nextIdx = 0;
|
|
68
69
|
async function processTask(task) {
|
|
69
70
|
const { v, tests, key, directTests } = task;
|
|
@@ -98,7 +99,12 @@ export async function executePool(opts) {
|
|
|
98
99
|
let mutatedSnippet;
|
|
99
100
|
if (status === 'escaped') {
|
|
100
101
|
try {
|
|
101
|
-
|
|
102
|
+
let fileContent = fileCache.get(v.file);
|
|
103
|
+
if (fileContent === undefined) {
|
|
104
|
+
fileContent = fs.readFileSync(v.file, 'utf8');
|
|
105
|
+
fileCache.set(v.file, fileContent);
|
|
106
|
+
}
|
|
107
|
+
const originalLines = fileContent.split('\n');
|
|
102
108
|
const mutatedLines = v.code.split('\n');
|
|
103
109
|
const lineIdx = v.line - 1;
|
|
104
110
|
const orig = originalLines[lineIdx]?.trim();
|
|
@@ -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