@mutineerjs/mutineer 0.7.0 → 0.9.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 +33 -15
- package/dist/bin/__tests__/mutineer.spec.js +75 -2
- package/dist/bin/mutineer.d.ts +6 -1
- package/dist/bin/mutineer.js +56 -1
- package/dist/core/__tests__/schemata.spec.d.ts +1 -0
- package/dist/core/__tests__/schemata.spec.js +165 -0
- package/dist/core/schemata.d.ts +22 -0
- package/dist/core/schemata.js +236 -0
- package/dist/runner/__tests__/args.spec.js +40 -0
- package/dist/runner/__tests__/cleanup.spec.js +7 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +4 -0
- package/dist/runner/__tests__/orchestrator.spec.js +183 -18
- package/dist/runner/__tests__/pool-executor.spec.js +47 -0
- package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
- package/dist/runner/__tests__/ts-checker.spec.js +115 -0
- package/dist/runner/args.d.ts +6 -0
- package/dist/runner/args.js +12 -0
- package/dist/runner/cleanup.js +1 -1
- package/dist/runner/jest/__tests__/adapter.spec.js +49 -0
- package/dist/runner/jest/adapter.js +30 -2
- package/dist/runner/orchestrator.js +111 -17
- package/dist/runner/pool-executor.d.ts +2 -0
- package/dist/runner/pool-executor.js +15 -4
- package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
- package/dist/runner/shared/index.d.ts +1 -1
- package/dist/runner/shared/index.js +1 -1
- package/dist/runner/shared/mutant-paths.d.ts +17 -0
- package/dist/runner/shared/mutant-paths.js +24 -0
- package/dist/runner/ts-checker-worker.d.ts +5 -0
- package/dist/runner/ts-checker-worker.js +66 -0
- package/dist/runner/ts-checker.d.ts +36 -0
- package/dist/runner/ts-checker.js +210 -0
- package/dist/runner/types.d.ts +2 -0
- package/dist/runner/vitest/__tests__/adapter.spec.js +70 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
- package/dist/runner/vitest/adapter.js +13 -1
- package/dist/runner/vitest/plugin.d.ts +3 -0
- package/dist/runner/vitest/plugin.js +49 -11
- package/dist/runner/vitest/pool.d.ts +4 -1
- package/dist/runner/vitest/pool.js +25 -4
- package/dist/runner/vitest/worker-runtime.d.ts +1 -0
- package/dist/runner/vitest/worker-runtime.js +57 -18
- package/dist/runner/vitest/worker.mjs +10 -0
- package/dist/types/config.d.ts +14 -0
- package/dist/types/mutant.d.ts +5 -2
- package/dist/utils/CompileErrors.d.ts +7 -0
- package/dist/utils/CompileErrors.js +24 -0
- package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
- package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
- package/dist/utils/__tests__/summary.spec.js +83 -1
- package/dist/utils/summary.d.ts +5 -1
- package/dist/utils/summary.js +38 -3
- package/package.json +2 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveTypescriptEnabled, resolveTsconfigPath, checkTypes, } from '../ts-checker.js';
|
|
4
|
+
// Directory with a real tsconfig.json (the project root, 3 levels up from __tests__)
|
|
5
|
+
const CWD_WITH_TSCONFIG = path.resolve(import.meta.dirname, '../../../');
|
|
6
|
+
// A temp-like directory unlikely to have tsconfig.json
|
|
7
|
+
const CWD_WITHOUT_TSCONFIG = '/tmp';
|
|
8
|
+
function makeVariant(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: 'foo.ts#0',
|
|
11
|
+
name: 'flipEQ',
|
|
12
|
+
file: '/nonexistent/foo.ts',
|
|
13
|
+
code: 'const x: number = 1',
|
|
14
|
+
line: 1,
|
|
15
|
+
col: 0,
|
|
16
|
+
tests: [],
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
describe('resolveTsconfigPath', () => {
|
|
21
|
+
it('returns undefined for boolean true', () => {
|
|
22
|
+
expect(resolveTsconfigPath({ typescript: true })).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it('returns undefined for boolean false', () => {
|
|
25
|
+
expect(resolveTsconfigPath({ typescript: false })).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
it('returns tsconfig path from object config', () => {
|
|
28
|
+
expect(resolveTsconfigPath({ typescript: { tsconfig: './tsconfig.app.json' } })).toBe('./tsconfig.app.json');
|
|
29
|
+
});
|
|
30
|
+
it('returns undefined when object config has no tsconfig property', () => {
|
|
31
|
+
expect(resolveTsconfigPath({ typescript: {} })).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
it('returns undefined when no typescript config key', () => {
|
|
34
|
+
expect(resolveTsconfigPath({})).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('resolveTypescriptEnabled', () => {
|
|
38
|
+
it('CLI false overrides everything', () => {
|
|
39
|
+
expect(resolveTypescriptEnabled(false, { typescript: true }, CWD_WITH_TSCONFIG)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
it('CLI true overrides everything', () => {
|
|
42
|
+
expect(resolveTypescriptEnabled(true, { typescript: false }, CWD_WITH_TSCONFIG)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it('config false disables checking', () => {
|
|
45
|
+
expect(resolveTypescriptEnabled(undefined, { typescript: false }, CWD_WITH_TSCONFIG)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
it('config true enables checking', () => {
|
|
48
|
+
expect(resolveTypescriptEnabled(undefined, { typescript: true }, CWD_WITHOUT_TSCONFIG)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('config object enables checking', () => {
|
|
51
|
+
expect(resolveTypescriptEnabled(undefined, { typescript: { tsconfig: './tsconfig.json' } }, CWD_WITHOUT_TSCONFIG)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
it('auto-detects: enabled when tsconfig.json is present in cwd', () => {
|
|
54
|
+
// The project root has a tsconfig.json
|
|
55
|
+
expect(resolveTypescriptEnabled(undefined, {}, CWD_WITH_TSCONFIG)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('auto-detects: disabled when no tsconfig.json in cwd', () => {
|
|
58
|
+
// /tmp should not have a tsconfig.json
|
|
59
|
+
expect(resolveTypescriptEnabled(undefined, {}, CWD_WITHOUT_TSCONFIG)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('checkTypes', () => {
|
|
63
|
+
it('returns empty set for empty variants array', async () => {
|
|
64
|
+
const result = await checkTypes([], undefined, CWD_WITHOUT_TSCONFIG);
|
|
65
|
+
expect(result.size).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
it('does not flag valid TypeScript code', async () => {
|
|
68
|
+
const variant = makeVariant({
|
|
69
|
+
id: 'valid.ts#0',
|
|
70
|
+
file: '/nonexistent/valid.ts',
|
|
71
|
+
code: 'const x: number = 42; export default x',
|
|
72
|
+
});
|
|
73
|
+
const result = await checkTypes([variant], undefined, CWD_WITHOUT_TSCONFIG);
|
|
74
|
+
expect(result.has('valid.ts#0')).toBe(false);
|
|
75
|
+
}, 15000);
|
|
76
|
+
it('flags TypeScript type mismatch as compile error', async () => {
|
|
77
|
+
const variant = makeVariant({
|
|
78
|
+
id: 'bad.ts#0',
|
|
79
|
+
file: '/nonexistent/bad.ts',
|
|
80
|
+
code: 'const x: number = "this is not a number"',
|
|
81
|
+
});
|
|
82
|
+
const result = await checkTypes([variant], undefined, CWD_WITHOUT_TSCONFIG);
|
|
83
|
+
expect(result.has('bad.ts#0')).toBe(true);
|
|
84
|
+
}, 15000);
|
|
85
|
+
it('checks multiple variants for same file independently', async () => {
|
|
86
|
+
const valid = makeVariant({
|
|
87
|
+
id: 'multi.ts#0',
|
|
88
|
+
file: '/nonexistent/multi.ts',
|
|
89
|
+
code: 'const x: number = 1',
|
|
90
|
+
});
|
|
91
|
+
const invalid = makeVariant({
|
|
92
|
+
id: 'multi.ts#1',
|
|
93
|
+
file: '/nonexistent/multi.ts',
|
|
94
|
+
code: 'const x: number = "bad"',
|
|
95
|
+
});
|
|
96
|
+
const result = await checkTypes([valid, invalid], undefined, CWD_WITHOUT_TSCONFIG);
|
|
97
|
+
expect(result.has('multi.ts#0')).toBe(false);
|
|
98
|
+
expect(result.has('multi.ts#1')).toBe(true);
|
|
99
|
+
}, 15000);
|
|
100
|
+
it('checks variants from different files independently', async () => {
|
|
101
|
+
const validA = makeVariant({
|
|
102
|
+
id: 'a.ts#0',
|
|
103
|
+
file: '/nonexistent/a.ts',
|
|
104
|
+
code: 'const x: number = 1',
|
|
105
|
+
});
|
|
106
|
+
const invalidB = makeVariant({
|
|
107
|
+
id: 'b.ts#0',
|
|
108
|
+
file: '/nonexistent/b.ts',
|
|
109
|
+
code: 'const y: string = 999',
|
|
110
|
+
});
|
|
111
|
+
const result = await checkTypes([validA, invalidB], undefined, CWD_WITHOUT_TSCONFIG);
|
|
112
|
+
expect(result.has('a.ts#0')).toBe(false);
|
|
113
|
+
expect(result.has('b.ts#0')).toBe(true);
|
|
114
|
+
}, 15000);
|
|
115
|
+
});
|
package/dist/runner/args.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface ParsedCliOptions {
|
|
|
12
12
|
readonly configPath: string | undefined;
|
|
13
13
|
readonly wantsChanged: boolean;
|
|
14
14
|
readonly wantsChangedWithDeps: boolean;
|
|
15
|
+
readonly wantsFull: boolean;
|
|
15
16
|
readonly wantsOnlyCoveredLines: boolean;
|
|
16
17
|
readonly wantsPerTestCoverage: boolean;
|
|
17
18
|
readonly coverageFilePath: string | undefined;
|
|
@@ -25,6 +26,11 @@ export interface ParsedCliOptions {
|
|
|
25
26
|
index: number;
|
|
26
27
|
total: number;
|
|
27
28
|
} | undefined;
|
|
29
|
+
/** undefined = use config/auto-detect, true = force enable, false = force disable */
|
|
30
|
+
readonly typescriptCheck: boolean | undefined;
|
|
31
|
+
/** Vitest workspace project name(s) to filter mutations to */
|
|
32
|
+
readonly vitestProject: string | undefined;
|
|
33
|
+
readonly skipBaseline: boolean;
|
|
28
34
|
}
|
|
29
35
|
/**
|
|
30
36
|
* Parse a numeric CLI flag value.
|
package/dist/runner/args.js
CHANGED
|
@@ -134,6 +134,7 @@ export function parseCliOptions(args, cfg) {
|
|
|
134
134
|
const configPath = readStringFlag(args, '--config', '-c');
|
|
135
135
|
const wantsChanged = args.includes('--changed');
|
|
136
136
|
const wantsChangedWithDeps = args.includes('--changed-with-deps');
|
|
137
|
+
const wantsFull = args.includes('--full');
|
|
137
138
|
const wantsOnlyCoveredLines = args.includes('--only-covered-lines') || cfg.onlyCoveredLines === true;
|
|
138
139
|
const wantsPerTestCoverage = args.includes('--per-test-coverage') || cfg.perTestCoverage === true;
|
|
139
140
|
const coverageFilePath = readStringFlag(args, '--coverage-file') ?? cfg.coverageFile;
|
|
@@ -152,10 +153,18 @@ export function parseCliOptions(args, cfg) {
|
|
|
152
153
|
const reportFlag = readStringFlag(args, '--report');
|
|
153
154
|
const reportFormat = reportFlag === 'json' || cfg.report === 'json' ? 'json' : 'text';
|
|
154
155
|
const shard = parseShardOption(args);
|
|
156
|
+
const typescriptCheck = args.includes('--typescript')
|
|
157
|
+
? true
|
|
158
|
+
: args.includes('--no-typescript')
|
|
159
|
+
? false
|
|
160
|
+
: undefined;
|
|
161
|
+
const vitestProject = readStringFlag(args, '--vitest-project');
|
|
162
|
+
const skipBaseline = args.includes('--skip-baseline');
|
|
155
163
|
return {
|
|
156
164
|
configPath,
|
|
157
165
|
wantsChanged,
|
|
158
166
|
wantsChangedWithDeps,
|
|
167
|
+
wantsFull,
|
|
159
168
|
wantsOnlyCoveredLines,
|
|
160
169
|
wantsPerTestCoverage,
|
|
161
170
|
coverageFilePath,
|
|
@@ -166,5 +175,8 @@ export function parseCliOptions(args, cfg) {
|
|
|
166
175
|
timeout,
|
|
167
176
|
reportFormat,
|
|
168
177
|
shard,
|
|
178
|
+
typescriptCheck,
|
|
179
|
+
vitestProject,
|
|
180
|
+
skipBaseline,
|
|
169
181
|
};
|
|
170
182
|
}
|
package/dist/runner/cleanup.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'node:fs/promises';
|
|
|
4
4
|
*/
|
|
5
5
|
export async function cleanupMutineerDirs(cwd, opts = {}) {
|
|
6
6
|
const glob = await import('fast-glob');
|
|
7
|
-
const dirs = await glob.default('**/__mutineer__', {
|
|
7
|
+
const dirs = await glob.default(['__mutineer__', '**/__mutineer__'], {
|
|
8
8
|
cwd,
|
|
9
9
|
onlyDirectories: true,
|
|
10
10
|
absolute: true,
|
|
@@ -61,6 +61,55 @@ describe('Jest adapter', () => {
|
|
|
61
61
|
expect(args.coverageProvider).toBe('v8');
|
|
62
62
|
expect(args.config).toBe('jest.config.ts');
|
|
63
63
|
});
|
|
64
|
+
it('does not write captured output to stdout/stderr on success', async () => {
|
|
65
|
+
const adapter = makeAdapter();
|
|
66
|
+
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
|
|
67
|
+
const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
68
|
+
runCLIMock.mockResolvedValueOnce({ results: { success: true } });
|
|
69
|
+
const ok = await adapter.runBaseline(['test-a'], {
|
|
70
|
+
collectCoverage: false,
|
|
71
|
+
perTestCoverage: false,
|
|
72
|
+
});
|
|
73
|
+
expect(ok).toBe(true);
|
|
74
|
+
// No output should be replayed on success
|
|
75
|
+
expect(stdoutWrite).not.toHaveBeenCalled();
|
|
76
|
+
expect(stderrWrite).not.toHaveBeenCalled();
|
|
77
|
+
stdoutWrite.mockRestore();
|
|
78
|
+
stderrWrite.mockRestore();
|
|
79
|
+
});
|
|
80
|
+
it('replays captured output to stdout/stderr on failure', async () => {
|
|
81
|
+
const adapter = makeAdapter();
|
|
82
|
+
// Spy before runBaseline so adapter's origStdoutWrite/origStderrWrite bind to the spy.
|
|
83
|
+
// runCLI writes during capture are suppressed; the spy sees only the replay after restore.
|
|
84
|
+
const writtenStdout = [];
|
|
85
|
+
const writtenStderr = [];
|
|
86
|
+
const stdoutSpy = vi
|
|
87
|
+
.spyOn(process.stdout, 'write')
|
|
88
|
+
.mockImplementation((chunk) => {
|
|
89
|
+
writtenStdout.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
|
|
90
|
+
return true;
|
|
91
|
+
});
|
|
92
|
+
const stderrSpy = vi
|
|
93
|
+
.spyOn(process.stderr, 'write')
|
|
94
|
+
.mockImplementation((chunk) => {
|
|
95
|
+
writtenStderr.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
runCLIMock.mockImplementationOnce(async () => {
|
|
99
|
+
process.stdout.write('jest stdout output');
|
|
100
|
+
process.stderr.write('jest stderr output');
|
|
101
|
+
return { results: { success: false } };
|
|
102
|
+
});
|
|
103
|
+
const ok = await adapter.runBaseline(['test-a'], {
|
|
104
|
+
collectCoverage: false,
|
|
105
|
+
perTestCoverage: false,
|
|
106
|
+
});
|
|
107
|
+
expect(ok).toBe(false);
|
|
108
|
+
expect(writtenStdout.join('')).toContain('jest stdout output');
|
|
109
|
+
expect(writtenStderr.join('')).toContain('jest stderr output');
|
|
110
|
+
stdoutSpy.mockRestore();
|
|
111
|
+
stderrSpy.mockRestore();
|
|
112
|
+
});
|
|
64
113
|
it('maps pool result to mutant status', async () => {
|
|
65
114
|
const adapter = makeAdapter();
|
|
66
115
|
await adapter.init();
|
|
@@ -33,6 +33,7 @@ function stripMutineerArgs(args) {
|
|
|
33
33
|
'--mutate',
|
|
34
34
|
'--changed',
|
|
35
35
|
'--changed-with-deps',
|
|
36
|
+
'--full',
|
|
36
37
|
'--only-covered-lines',
|
|
37
38
|
'--per-test-coverage',
|
|
38
39
|
'--perTestCoverage',
|
|
@@ -108,16 +109,43 @@ export class JestAdapter {
|
|
|
108
109
|
? 'baseline-with-coverage'
|
|
109
110
|
: 'baseline';
|
|
110
111
|
const cliOptions = buildJestCliOptions(tests, mode, this.jestConfigPath);
|
|
112
|
+
const stdoutChunks = [];
|
|
113
|
+
const stderrChunks = [];
|
|
114
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
115
|
+
const origStderrWrite = process.stderr.write.bind(process.stderr);
|
|
116
|
+
// Temporarily capture stdout/stderr so Jest output is suppressed during baseline;
|
|
117
|
+
// captured output is replayed below on failure.
|
|
118
|
+
const makeCapture = (chunks) => function (chunk, encodingOrCb, cb) {
|
|
119
|
+
chunks.push(Buffer.isBuffer(chunk)
|
|
120
|
+
? Buffer.from(chunk)
|
|
121
|
+
: Buffer.from(chunk));
|
|
122
|
+
const callback = (typeof encodingOrCb === 'function' ? encodingOrCb : cb);
|
|
123
|
+
callback?.();
|
|
124
|
+
return true;
|
|
125
|
+
};
|
|
126
|
+
process.stdout.write = makeCapture(stdoutChunks);
|
|
127
|
+
process.stderr.write = makeCapture(stderrChunks);
|
|
128
|
+
let success = false;
|
|
111
129
|
try {
|
|
112
130
|
const { runCLI } = await loadRunCLI(this.requireFromCwd);
|
|
113
131
|
const { results } = await runCLI(cliOptions, [this.options.cwd]);
|
|
114
|
-
|
|
132
|
+
success = results.success;
|
|
115
133
|
}
|
|
116
134
|
catch (err) {
|
|
117
135
|
log.debug('Failed to run Jest baseline: ' +
|
|
118
136
|
(err instanceof Error ? err.message : String(err)));
|
|
119
|
-
return false;
|
|
120
137
|
}
|
|
138
|
+
finally {
|
|
139
|
+
process.stdout.write = origStdoutWrite;
|
|
140
|
+
process.stderr.write = origStderrWrite;
|
|
141
|
+
}
|
|
142
|
+
if (!success) {
|
|
143
|
+
if (stdoutChunks.length)
|
|
144
|
+
process.stdout.write(Buffer.concat(stdoutChunks));
|
|
145
|
+
if (stderrChunks.length)
|
|
146
|
+
process.stderr.write(Buffer.concat(stderrChunks));
|
|
147
|
+
}
|
|
148
|
+
return success;
|
|
121
149
|
}
|
|
122
150
|
async runMutant(mutant, tests) {
|
|
123
151
|
if (!this.pool) {
|
|
@@ -10,8 +10,12 @@
|
|
|
10
10
|
* 6. Report results
|
|
11
11
|
*/
|
|
12
12
|
import path from 'node:path';
|
|
13
|
+
import fs from 'node:fs';
|
|
13
14
|
import os from 'node:os';
|
|
15
|
+
import { render } from 'ink';
|
|
16
|
+
import { createElement } from 'react';
|
|
14
17
|
import { normalizePath } from '../utils/normalizePath.js';
|
|
18
|
+
import { PoolSpinner } from '../utils/PoolSpinner.js';
|
|
15
19
|
import { autoDiscoverTargetsAndTests } from './discover.js';
|
|
16
20
|
import { listChangedFiles } from './changed.js';
|
|
17
21
|
import { loadMutineerConfig } from './config.js';
|
|
@@ -21,9 +25,12 @@ import { createLogger } from '../utils/logger.js';
|
|
|
21
25
|
import { extractConfigPath, parseCliOptions } from './args.js';
|
|
22
26
|
import { clearCacheOnStart, readMutantCache } from './cache.js';
|
|
23
27
|
import { getTargetFile, enumerateAllVariants } from './variants.js';
|
|
28
|
+
import { generateSchema } from '../core/schemata.js';
|
|
29
|
+
import { getSchemaFilePath } from './shared/mutant-paths.js';
|
|
24
30
|
import { resolveCoverageConfig, loadCoverageAfterBaseline, } from './coverage-resolver.js';
|
|
25
31
|
import { prepareTasks } from './tasks.js';
|
|
26
32
|
import { executePool } from './pool-executor.js';
|
|
33
|
+
import { checkTypes, resolveTypescriptEnabled, resolveTsconfigPath, } from './ts-checker.js';
|
|
27
34
|
const log = createLogger('orchestrator');
|
|
28
35
|
// Per-mutant test timeout (ms). Can be overridden with env MUTINEER_MUTANT_TIMEOUT_MS
|
|
29
36
|
export function parseMutantTimeoutMs(raw) {
|
|
@@ -40,12 +47,15 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
40
47
|
const opts = parseCliOptions(cliArgs, cfg);
|
|
41
48
|
await clearCacheOnStart(cwd, opts.shard);
|
|
42
49
|
// Create test runner adapter
|
|
50
|
+
const vitestProject = opts.vitestProject ??
|
|
51
|
+
(typeof cfg.vitestProject === 'string' ? cfg.vitestProject : undefined);
|
|
43
52
|
const adapter = (opts.runner === 'jest' ? createJestAdapter : createVitestAdapter)({
|
|
44
53
|
cwd,
|
|
45
54
|
concurrency: opts.concurrency,
|
|
46
55
|
timeoutMs: opts.timeout ?? cfg.timeout ?? MUTANT_TIMEOUT_MS,
|
|
47
56
|
config: cfg,
|
|
48
57
|
cliArgs,
|
|
58
|
+
vitestProject,
|
|
49
59
|
});
|
|
50
60
|
// 2. Resolve coverage configuration
|
|
51
61
|
const coverage = await resolveCoverageConfig(opts, cfg, adapter, cliArgs);
|
|
@@ -110,20 +120,38 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
110
120
|
return;
|
|
111
121
|
}
|
|
112
122
|
// 5. Run baseline tests (with coverage if needed for filtering)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
process.
|
|
120
|
-
|
|
123
|
+
if (opts.skipBaseline) {
|
|
124
|
+
log.info('Skipping baseline tests (--skip-baseline)');
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const baselineMsg = `Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`;
|
|
128
|
+
let baselineSpinner = null;
|
|
129
|
+
if (process.stderr.isTTY) {
|
|
130
|
+
baselineSpinner = render(createElement(PoolSpinner, { message: baselineMsg }), { stdout: process.stderr, stderr: process.stderr });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
log.info(baselineMsg);
|
|
134
|
+
}
|
|
135
|
+
let baselineOk;
|
|
136
|
+
try {
|
|
137
|
+
baselineOk = await adapter.runBaseline(baselineTests, {
|
|
138
|
+
collectCoverage: coverage.enableCoverageForBaseline,
|
|
139
|
+
perTestCoverage: coverage.wantsPerTestCoverage,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
baselineSpinner?.unmount();
|
|
144
|
+
}
|
|
145
|
+
if (!baselineOk) {
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
log.info('\u2713 Baseline tests complete');
|
|
121
150
|
}
|
|
122
|
-
log.info('\u2713 Baseline tests complete');
|
|
123
151
|
// 6. Load coverage from baseline if we generated it
|
|
124
152
|
const updatedCoverage = await loadCoverageAfterBaseline(coverage, cwd);
|
|
125
153
|
// 7. Enumerate mutation variants
|
|
126
|
-
|
|
154
|
+
let variants = await enumerateAllVariants({
|
|
127
155
|
cwd,
|
|
128
156
|
targets,
|
|
129
157
|
testMap,
|
|
@@ -138,18 +166,83 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
138
166
|
log.info(msg);
|
|
139
167
|
return;
|
|
140
168
|
}
|
|
141
|
-
//
|
|
142
|
-
let tasks = prepareTasks(variants, updatedCoverage.perTestCoverage, directTestMap);
|
|
143
|
-
// Apply shard filter if requested
|
|
169
|
+
// Apply shard filter before type-checking so each shard only processes its own mutants
|
|
144
170
|
if (opts.shard) {
|
|
145
171
|
const { index, total } = opts.shard;
|
|
146
|
-
|
|
147
|
-
log.info(`Shard ${index}/${total}:
|
|
148
|
-
if (
|
|
149
|
-
log.info('No mutants
|
|
172
|
+
variants = variants.filter((_, i) => i % total === index - 1);
|
|
173
|
+
log.info(`Shard ${index}/${total}: scoped to ${variants.length} variant(s)`);
|
|
174
|
+
if (!variants.length) {
|
|
175
|
+
log.info('No mutants in this shard. Exiting.');
|
|
150
176
|
return;
|
|
151
177
|
}
|
|
152
178
|
}
|
|
179
|
+
// TypeScript pre-filtering (filter mutants that produce compile errors)
|
|
180
|
+
const tsEnabled = resolveTypescriptEnabled(opts.typescriptCheck, cfg, cwd);
|
|
181
|
+
let runnableVariants = variants;
|
|
182
|
+
if (tsEnabled) {
|
|
183
|
+
// Only return-value mutants change the expression type — operator mutants
|
|
184
|
+
// (equality, arithmetic, logical, etc.) always preserve the type.
|
|
185
|
+
const returnValueVariants = variants.filter((v) => v.name.startsWith('return'));
|
|
186
|
+
log.info(`Running TypeScript type checks on ${returnValueVariants.length} return-value mutant(s)...`);
|
|
187
|
+
const tsconfig = resolveTsconfigPath(cfg);
|
|
188
|
+
let tsSpinner = null;
|
|
189
|
+
if (process.stderr.isTTY) {
|
|
190
|
+
tsSpinner = render(createElement(PoolSpinner, {
|
|
191
|
+
message: `Type checking ${returnValueVariants.length} return-value mutant(s)...`,
|
|
192
|
+
}), { stdout: process.stderr, stderr: process.stderr });
|
|
193
|
+
}
|
|
194
|
+
let compileErrorIds;
|
|
195
|
+
try {
|
|
196
|
+
compileErrorIds = await checkTypes(returnValueVariants, tsconfig, cwd);
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
tsSpinner?.unmount();
|
|
200
|
+
}
|
|
201
|
+
if (compileErrorIds.size > 0) {
|
|
202
|
+
log.info(`\u2713 TypeScript: filtered ${compileErrorIds.size} mutant(s) with compile errors`);
|
|
203
|
+
runnableVariants = variants.filter((v) => !compileErrorIds.has(v.id));
|
|
204
|
+
// Pre-populate cache for compile-error mutants so they appear in summary
|
|
205
|
+
const compileErrorVariants = variants.filter((v) => compileErrorIds.has(v.id));
|
|
206
|
+
const compileErrorTasks = prepareTasks(compileErrorVariants, updatedCoverage.perTestCoverage, directTestMap);
|
|
207
|
+
for (const task of compileErrorTasks) {
|
|
208
|
+
cache[task.key] = {
|
|
209
|
+
status: 'compile-error',
|
|
210
|
+
file: task.v.file,
|
|
211
|
+
line: task.v.line,
|
|
212
|
+
col: task.v.col,
|
|
213
|
+
mutator: task.v.name,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// 9. Generate schema files for each source file
|
|
219
|
+
const fallbackIds = new Set();
|
|
220
|
+
const variantsByFile = new Map();
|
|
221
|
+
for (const v of runnableVariants) {
|
|
222
|
+
const arr = variantsByFile.get(v.file) ?? [];
|
|
223
|
+
arr.push(v);
|
|
224
|
+
variantsByFile.set(v.file, arr);
|
|
225
|
+
}
|
|
226
|
+
const results = await Promise.all([...variantsByFile.entries()].map(async ([file, fileVariants]) => {
|
|
227
|
+
try {
|
|
228
|
+
const originalCode = await fs.promises.readFile(file, 'utf8');
|
|
229
|
+
const schemaPath = getSchemaFilePath(file);
|
|
230
|
+
await fs.promises.mkdir(path.dirname(schemaPath), { recursive: true });
|
|
231
|
+
const { schemaCode, fallbackIds: fileFallbacks } = generateSchema(originalCode, fileVariants);
|
|
232
|
+
await fs.promises.writeFile(schemaPath, schemaCode, 'utf8');
|
|
233
|
+
return fileFallbacks;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return new Set(fileVariants.map((v) => v.id));
|
|
237
|
+
}
|
|
238
|
+
}));
|
|
239
|
+
for (const ids of results) {
|
|
240
|
+
for (const id of ids)
|
|
241
|
+
fallbackIds.add(id);
|
|
242
|
+
}
|
|
243
|
+
log.debug(`Schema: ${runnableVariants.length - fallbackIds.size} embedded, ${fallbackIds.size} fallback`);
|
|
244
|
+
// 10. Prepare tasks and execute via worker pool
|
|
245
|
+
let tasks = prepareTasks(runnableVariants, updatedCoverage.perTestCoverage, directTestMap);
|
|
153
246
|
await executePool({
|
|
154
247
|
tasks,
|
|
155
248
|
adapter,
|
|
@@ -160,5 +253,6 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
160
253
|
reportFormat: opts.reportFormat,
|
|
161
254
|
cwd,
|
|
162
255
|
shard: opts.shard,
|
|
256
|
+
fallbackIds,
|
|
163
257
|
});
|
|
164
258
|
}
|
|
@@ -14,6 +14,8 @@ export interface PoolExecutionOptions {
|
|
|
14
14
|
index: number;
|
|
15
15
|
total: number;
|
|
16
16
|
};
|
|
17
|
+
/** IDs of variants that must use the legacy redirect path (overlapping diff ranges). */
|
|
18
|
+
fallbackIds?: Set<string>;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* Execute all mutant tasks through the worker pool.
|
|
@@ -7,6 +7,7 @@ import { computeSummary, printSummary, buildJsonReport, } from '../utils/summary
|
|
|
7
7
|
import { saveCacheAtomic } from './cache.js';
|
|
8
8
|
import { cleanupMutineerDirs } from './cleanup.js';
|
|
9
9
|
import { PoolSpinner } from '../utils/PoolSpinner.js';
|
|
10
|
+
import { CompileErrors } from '../utils/CompileErrors.js';
|
|
10
11
|
import { createLogger } from '../utils/logger.js';
|
|
11
12
|
const log = createLogger('pool-executor');
|
|
12
13
|
/**
|
|
@@ -23,7 +24,7 @@ export async function executePool(opts) {
|
|
|
23
24
|
const mutationStartTime = Date.now();
|
|
24
25
|
// Ensure we only finish once
|
|
25
26
|
let finished = false;
|
|
26
|
-
const finishOnce = () => {
|
|
27
|
+
const finishOnce = async (interactive = true) => {
|
|
27
28
|
if (finished)
|
|
28
29
|
return;
|
|
29
30
|
finished = true;
|
|
@@ -40,7 +41,15 @@ export async function executePool(opts) {
|
|
|
40
41
|
log.info(`JSON report written to ${path.relative(process.cwd(), outPath)}`);
|
|
41
42
|
}
|
|
42
43
|
else {
|
|
43
|
-
|
|
44
|
+
const compileErrorEntries = Object.values(cache).filter((e) => e.status === 'compile-error');
|
|
45
|
+
const useInteractive = interactive && process.stdout.isTTY && compileErrorEntries.length > 0;
|
|
46
|
+
printSummary(summary, cache, durationMs, {
|
|
47
|
+
skipCompileErrors: useInteractive,
|
|
48
|
+
});
|
|
49
|
+
if (useInteractive) {
|
|
50
|
+
const { waitUntilExit } = render(createElement(CompileErrors, { entries: compileErrorEntries, cwd }));
|
|
51
|
+
await waitUntilExit();
|
|
52
|
+
}
|
|
44
53
|
}
|
|
45
54
|
if (opts.minKillPercent !== undefined) {
|
|
46
55
|
const killRateString = summary.killRate.toFixed(2);
|
|
@@ -80,6 +89,7 @@ export async function executePool(opts) {
|
|
|
80
89
|
let nextIdx = 0;
|
|
81
90
|
async function processTask(task) {
|
|
82
91
|
const { v, tests, key, directTests } = task;
|
|
92
|
+
const { fallbackIds } = opts;
|
|
83
93
|
const cached = cache[key];
|
|
84
94
|
if (cached) {
|
|
85
95
|
progress.update(cached.status);
|
|
@@ -104,6 +114,7 @@ export async function executePool(opts) {
|
|
|
104
114
|
code: v.code,
|
|
105
115
|
line: v.line,
|
|
106
116
|
col: v.col,
|
|
117
|
+
isFallback: !fallbackIds || fallbackIds.has(v.id),
|
|
107
118
|
}, tests);
|
|
108
119
|
const status = result.status;
|
|
109
120
|
let originalSnippet;
|
|
@@ -159,7 +170,7 @@ export async function executePool(opts) {
|
|
|
159
170
|
return;
|
|
160
171
|
signalCleanedUp = true;
|
|
161
172
|
log.info(`\nReceived ${signal}, cleaning up...`);
|
|
162
|
-
finishOnce();
|
|
173
|
+
await finishOnce(false);
|
|
163
174
|
await adapter.shutdown();
|
|
164
175
|
await cleanupMutineerDirs(cwd);
|
|
165
176
|
process.exit(1);
|
|
@@ -176,7 +187,7 @@ export async function executePool(opts) {
|
|
|
176
187
|
process.removeAllListeners('SIGINT');
|
|
177
188
|
process.removeAllListeners('SIGTERM');
|
|
178
189
|
if (!signalCleanedUp) {
|
|
179
|
-
finishOnce();
|
|
190
|
+
await finishOnce();
|
|
180
191
|
await adapter.shutdown();
|
|
181
192
|
await cleanupMutineerDirs(cwd);
|
|
182
193
|
}
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
-
import { getMutantFilePath } from '../mutant-paths.js';
|
|
5
|
+
import { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from '../mutant-paths.js';
|
|
6
6
|
let createdDirs = [];
|
|
7
7
|
afterEach(() => {
|
|
8
8
|
// Clean up any __mutineer__ directories we created
|
|
@@ -64,3 +64,32 @@ describe('getMutantFilePath', () => {
|
|
|
64
64
|
expect(result).toBe(path.join(mutineerDir, 'foo_1.ts'));
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
|
+
describe('getSchemaFilePath', () => {
|
|
68
|
+
it('returns path with _schema suffix and correct extension', () => {
|
|
69
|
+
const result = getSchemaFilePath('/src/foo.ts');
|
|
70
|
+
expect(result).toBe('/src/__mutineer__/foo_schema.ts');
|
|
71
|
+
});
|
|
72
|
+
it('preserves the original extension', () => {
|
|
73
|
+
const result = getSchemaFilePath('/src/component.vue');
|
|
74
|
+
expect(result).toBe('/src/__mutineer__/component_schema.vue');
|
|
75
|
+
});
|
|
76
|
+
it('does not create the __mutineer__ directory', () => {
|
|
77
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-schema-'));
|
|
78
|
+
createdDirs.push(tmpDir);
|
|
79
|
+
const srcFile = path.join(tmpDir, 'bar.ts');
|
|
80
|
+
const mutineerDir = path.join(tmpDir, '__mutineer__');
|
|
81
|
+
getSchemaFilePath(srcFile);
|
|
82
|
+
expect(fs.existsSync(mutineerDir)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
describe('getActiveIdFilePath', () => {
|
|
86
|
+
it('returns path under cwd/__mutineer__ keyed by workerId', () => {
|
|
87
|
+
const result = getActiveIdFilePath('w0', '/project');
|
|
88
|
+
expect(result).toBe('/project/__mutineer__/active_id_w0.txt');
|
|
89
|
+
});
|
|
90
|
+
it('produces distinct paths for different worker IDs', () => {
|
|
91
|
+
const a = getActiveIdFilePath('w0', '/cwd');
|
|
92
|
+
const b = getActiveIdFilePath('w1', '/cwd');
|
|
93
|
+
expect(a).not.toBe(b);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -4,6 +4,6 @@
|
|
|
4
4
|
* This module provides common functionality used by both Jest and Vitest adapters,
|
|
5
5
|
* including mutant file path generation and redirect state management.
|
|
6
6
|
*/
|
|
7
|
-
export { getMutantFilePath } from './mutant-paths.js';
|
|
7
|
+
export { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from './mutant-paths.js';
|
|
8
8
|
export { setRedirect, getRedirect, clearRedirect, initialiseRedirectState, } from './redirect-state.js';
|
|
9
9
|
export type { RedirectConfig } from './redirect-state.js';
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* This module provides common functionality used by both Jest and Vitest adapters,
|
|
5
5
|
* including mutant file path generation and redirect state management.
|
|
6
6
|
*/
|
|
7
|
-
export { getMutantFilePath } from './mutant-paths.js';
|
|
7
|
+
export { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from './mutant-paths.js';
|
|
8
8
|
export { setRedirect, getRedirect, clearRedirect, initialiseRedirectState, } from './redirect-state.js';
|
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utilities for managing mutant file paths.
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* Generate a file path for the schema file in the __mutineer__ directory.
|
|
6
|
+
* The schema file embeds all mutation variants for a source file.
|
|
7
|
+
*
|
|
8
|
+
* @param originalFile - Path to the original source file
|
|
9
|
+
* @returns Path where the schema file should be written (dir may not exist)
|
|
10
|
+
*/
|
|
11
|
+
export declare function getSchemaFilePath(originalFile: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Generate the path for a worker's active-mutant-ID file.
|
|
14
|
+
* Each worker writes the active mutant ID here so test forks can read it.
|
|
15
|
+
*
|
|
16
|
+
* @param workerId - Unique worker identifier
|
|
17
|
+
* @param cwd - Project working directory
|
|
18
|
+
* @returns Absolute path for the active ID file
|
|
19
|
+
*/
|
|
20
|
+
export declare function getActiveIdFilePath(workerId: string, cwd: string): string;
|
|
4
21
|
/**
|
|
5
22
|
* Generate a file path for a mutant file in the __mutineer__ directory.
|
|
6
23
|
*
|