@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/mutators/utils.js
CHANGED
|
@@ -122,21 +122,86 @@ export function isBinaryOrLogical(node) {
|
|
|
122
122
|
return node.type === 'BinaryExpression' || node.type === 'LogicalExpression';
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* to token positions for accurate column reporting.
|
|
128
|
-
*
|
|
129
|
-
* @param src - The source code
|
|
130
|
-
* @param opValue - The operator to search for (e.g., '&&', '<=')
|
|
131
|
-
* @returns Array of target locations for the operator
|
|
125
|
+
* Single traversal that collects all operator targets and return statements.
|
|
126
|
+
* Eliminates per-mutator traversals when using ParseContext.
|
|
132
127
|
*/
|
|
133
|
-
export function
|
|
128
|
+
export function collectAllTargets(src, ast, tokens, ignoreLines) {
|
|
129
|
+
const operatorTargets = new Map();
|
|
130
|
+
const returnStatements = [];
|
|
131
|
+
function handleBinaryOrLogical(n) {
|
|
132
|
+
const nodeStart = n.start ?? 0;
|
|
133
|
+
const nodeEnd = n.end ?? 0;
|
|
134
|
+
const opValue = n.operator;
|
|
135
|
+
const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
|
|
136
|
+
if (tok) {
|
|
137
|
+
const line = tok.loc.start.line;
|
|
138
|
+
if (ignoreLines.has(line))
|
|
139
|
+
return;
|
|
140
|
+
const visualCol = getVisualColumn(src, tok.start);
|
|
141
|
+
let arr = operatorTargets.get(opValue);
|
|
142
|
+
if (!arr) {
|
|
143
|
+
arr = [];
|
|
144
|
+
operatorTargets.set(opValue, arr);
|
|
145
|
+
}
|
|
146
|
+
arr.push({
|
|
147
|
+
start: tok.start,
|
|
148
|
+
end: tok.end,
|
|
149
|
+
line,
|
|
150
|
+
col1: visualCol,
|
|
151
|
+
op: opValue,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
traverse(ast, {
|
|
156
|
+
BinaryExpression(p) {
|
|
157
|
+
handleBinaryOrLogical(p.node);
|
|
158
|
+
},
|
|
159
|
+
LogicalExpression(p) {
|
|
160
|
+
handleBinaryOrLogical(p.node);
|
|
161
|
+
},
|
|
162
|
+
ReturnStatement(p) {
|
|
163
|
+
const node = p.node;
|
|
164
|
+
if (!node.argument)
|
|
165
|
+
return;
|
|
166
|
+
const line = node.loc?.start.line;
|
|
167
|
+
if (line === undefined)
|
|
168
|
+
return;
|
|
169
|
+
if (ignoreLines.has(line))
|
|
170
|
+
return;
|
|
171
|
+
const argStart = node.argument.start;
|
|
172
|
+
const argEnd = node.argument.end;
|
|
173
|
+
if (argStart == null || argEnd == null)
|
|
174
|
+
return;
|
|
175
|
+
const col = getVisualColumn(src, node.start ?? 0);
|
|
176
|
+
returnStatements.push({
|
|
177
|
+
line,
|
|
178
|
+
col,
|
|
179
|
+
argStart,
|
|
180
|
+
argEnd,
|
|
181
|
+
argNode: node.argument,
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
return { operatorTargets, returnStatements };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse a source file once and build a reusable ParseContext.
|
|
189
|
+
* Pass this to mutators' applyWithContext to avoid redundant parses.
|
|
190
|
+
*/
|
|
191
|
+
export function buildParseContext(src) {
|
|
134
192
|
const ast = parseSource(src);
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
193
|
+
const tokens = ast.tokens ?? [];
|
|
194
|
+
const ignoreLines = buildIgnoreLines(ast.comments ?? []);
|
|
195
|
+
const preCollected = collectAllTargets(src, ast, tokens, ignoreLines);
|
|
196
|
+
return { ast, tokens, ignoreLines, preCollected };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Collect operator targets from a pre-built ParseContext.
|
|
200
|
+
* Avoids re-parsing; use when processing multiple operators on the same source.
|
|
201
|
+
*/
|
|
202
|
+
export function collectOperatorTargetsFromContext(src, ctx, opValue) {
|
|
203
|
+
const { ast, tokens, ignoreLines } = ctx;
|
|
138
204
|
const out = [];
|
|
139
|
-
const ignoreLines = buildIgnoreLines(comments);
|
|
140
205
|
traverse(ast, {
|
|
141
206
|
enter(p) {
|
|
142
207
|
if (!isBinaryOrLogical(p.node))
|
|
@@ -144,12 +209,10 @@ export function collectOperatorTargets(src, opValue) {
|
|
|
144
209
|
const n = p.node;
|
|
145
210
|
if (n.operator !== opValue)
|
|
146
211
|
return;
|
|
147
|
-
// Find the exact operator token inside the node span
|
|
148
212
|
const nodeStart = n.start ?? 0;
|
|
149
213
|
const nodeEnd = n.end ?? 0;
|
|
150
214
|
const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
|
|
151
215
|
if (tok) {
|
|
152
|
-
// Convert Babel's character-based column to a visual column for accurate reporting
|
|
153
216
|
const line = tok.loc.start.line;
|
|
154
217
|
if (ignoreLines.has(line))
|
|
155
218
|
return;
|
|
@@ -158,7 +221,7 @@ export function collectOperatorTargets(src, opValue) {
|
|
|
158
221
|
start: tok.start,
|
|
159
222
|
end: tok.end,
|
|
160
223
|
line,
|
|
161
|
-
col1: visualCol,
|
|
224
|
+
col1: visualCol,
|
|
162
225
|
op: opValue,
|
|
163
226
|
});
|
|
164
227
|
}
|
|
@@ -166,3 +229,15 @@ export function collectOperatorTargets(src, opValue) {
|
|
|
166
229
|
});
|
|
167
230
|
return out;
|
|
168
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Collect the operator tokens for a given operator and return their exact locations.
|
|
234
|
+
* Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
|
|
235
|
+
* to token positions for accurate column reporting.
|
|
236
|
+
*
|
|
237
|
+
* @param src - The source code
|
|
238
|
+
* @param opValue - The operator to search for (e.g., '&&', '<=')
|
|
239
|
+
* @returns Array of target locations for the operator
|
|
240
|
+
*/
|
|
241
|
+
export function collectOperatorTargets(src, opValue) {
|
|
242
|
+
return collectOperatorTargetsFromContext(src, buildParseContext(src), opValue);
|
|
243
|
+
}
|
|
@@ -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
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -34,6 +34,57 @@ vi.mock('vite', async () => {
|
|
|
34
34
|
}),
|
|
35
35
|
};
|
|
36
36
|
});
|
|
37
|
+
describe('createViteResolver Vue plugin gating', () => {
|
|
38
|
+
let warnSpy;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
it('does not warn about @vitejs/plugin-vue when no .vue files exist', async () => {
|
|
46
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-novue-'));
|
|
47
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
48
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
49
|
+
await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
|
|
50
|
+
const importLine = ['im', 'port { a } from "./a"'].join('');
|
|
51
|
+
await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
|
|
52
|
+
try {
|
|
53
|
+
await autoDiscoverTargetsAndTests(tmpDir, {
|
|
54
|
+
testPatterns: ['**/*.test.ts'],
|
|
55
|
+
});
|
|
56
|
+
const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
|
|
57
|
+
expect(pluginVueWarnings).toHaveLength(0);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it('warns when .vue files exist but @vitejs/plugin-vue fails to load', async () => {
|
|
64
|
+
vi.doMock('@vitejs/plugin-vue', () => {
|
|
65
|
+
throw new Error('Cannot find module @vitejs/plugin-vue');
|
|
66
|
+
});
|
|
67
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-vue-'));
|
|
68
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
69
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
70
|
+
await fs.writeFile(path.join(srcDir, 'Comp.vue'), '<template><div/></template>\n', 'utf8');
|
|
71
|
+
await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
|
|
72
|
+
const importLine = ['im', 'port { a } from "./a"'].join('');
|
|
73
|
+
await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
|
|
74
|
+
try {
|
|
75
|
+
await autoDiscoverTargetsAndTests(tmpDir, {
|
|
76
|
+
testPatterns: ['**/*.test.ts'],
|
|
77
|
+
extensions: ['.vue', '.ts'],
|
|
78
|
+
});
|
|
79
|
+
const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
|
|
80
|
+
expect(pluginVueWarnings.length).toBeGreaterThan(0);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
vi.doUnmock('@vitejs/plugin-vue');
|
|
84
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
37
88
|
describe('autoDiscoverTargetsAndTests', () => {
|
|
38
89
|
it('directTestMap only includes direct importers', async () => {
|
|
39
90
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct-'));
|
|
@@ -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
|
+
});
|
|
@@ -370,6 +370,46 @@ describe('executePool', () => {
|
|
|
370
370
|
});
|
|
371
371
|
expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
|
|
372
372
|
});
|
|
373
|
+
it('correctly stores snippets for multiple escaped mutants from the same file', async () => {
|
|
374
|
+
const tmpFile = path.join(tmpDir, 'shared.ts');
|
|
375
|
+
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
376
|
+
const adapter = makeAdapter({
|
|
377
|
+
runMutant: vi
|
|
378
|
+
.fn()
|
|
379
|
+
.mockResolvedValue({ status: 'escaped', durationMs: 1 }),
|
|
380
|
+
});
|
|
381
|
+
const cache = {};
|
|
382
|
+
const makeFileTask = (key, mutated) => makeTask({
|
|
383
|
+
key,
|
|
384
|
+
v: {
|
|
385
|
+
id: `shared.ts#${key}`,
|
|
386
|
+
name: 'flipArith',
|
|
387
|
+
file: tmpFile,
|
|
388
|
+
code: mutated,
|
|
389
|
+
line: 1,
|
|
390
|
+
col: 10,
|
|
391
|
+
tests: ['/tests/file.test.ts'],
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
await executePool({
|
|
395
|
+
tasks: [
|
|
396
|
+
makeFileTask('cache-key-1', 'const x = a - b\n'),
|
|
397
|
+
makeFileTask('cache-key-2', 'const x = a * b\n'),
|
|
398
|
+
makeFileTask('cache-key-3', 'const x = a / b\n'),
|
|
399
|
+
],
|
|
400
|
+
adapter,
|
|
401
|
+
cache,
|
|
402
|
+
concurrency: 1,
|
|
403
|
+
progressMode: 'list',
|
|
404
|
+
cwd: tmpDir,
|
|
405
|
+
});
|
|
406
|
+
expect(cache['cache-key-1'].originalSnippet).toBe('const x = a + b');
|
|
407
|
+
expect(cache['cache-key-1'].mutatedSnippet).toBe('const x = a - b');
|
|
408
|
+
expect(cache['cache-key-2'].originalSnippet).toBe('const x = a + b');
|
|
409
|
+
expect(cache['cache-key-2'].mutatedSnippet).toBe('const x = a * b');
|
|
410
|
+
expect(cache['cache-key-3'].originalSnippet).toBe('const x = a + b');
|
|
411
|
+
expect(cache['cache-key-3'].mutatedSnippet).toBe('const x = a / b');
|
|
412
|
+
});
|
|
373
413
|
it('handles adapter errors gracefully and still shuts down', async () => {
|
|
374
414
|
const adapter = makeAdapter({
|
|
375
415
|
runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
|
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
|
}
|