@mutineerjs/mutineer 0.6.0 → 0.8.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.
Files changed (69) hide show
  1. package/README.md +32 -15
  2. package/dist/bin/__tests__/mutineer.spec.js +67 -2
  3. package/dist/bin/mutineer.d.ts +6 -1
  4. package/dist/bin/mutineer.js +58 -2
  5. package/dist/core/__tests__/schemata.spec.d.ts +1 -0
  6. package/dist/core/__tests__/schemata.spec.js +165 -0
  7. package/dist/core/schemata.d.ts +22 -0
  8. package/dist/core/schemata.js +236 -0
  9. package/dist/mutators/__tests__/operator.spec.js +97 -1
  10. package/dist/mutators/__tests__/registry.spec.js +8 -0
  11. package/dist/mutators/operator.d.ts +8 -0
  12. package/dist/mutators/operator.js +58 -1
  13. package/dist/mutators/registry.js +9 -1
  14. package/dist/mutators/utils.d.ts +2 -0
  15. package/dist/mutators/utils.js +58 -1
  16. package/dist/runner/__tests__/args.spec.js +89 -1
  17. package/dist/runner/__tests__/cache.spec.js +65 -8
  18. package/dist/runner/__tests__/cleanup.spec.js +37 -0
  19. package/dist/runner/__tests__/coverage-resolver.spec.js +5 -0
  20. package/dist/runner/__tests__/discover.spec.js +128 -0
  21. package/dist/runner/__tests__/orchestrator.spec.js +332 -2
  22. package/dist/runner/__tests__/pool-executor.spec.js +107 -1
  23. package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
  24. package/dist/runner/__tests__/ts-checker.spec.js +115 -0
  25. package/dist/runner/args.d.ts +18 -0
  26. package/dist/runner/args.js +37 -0
  27. package/dist/runner/cache.d.ts +19 -3
  28. package/dist/runner/cache.js +14 -7
  29. package/dist/runner/cleanup.d.ts +3 -1
  30. package/dist/runner/cleanup.js +19 -2
  31. package/dist/runner/coverage-resolver.js +1 -1
  32. package/dist/runner/discover.d.ts +1 -1
  33. package/dist/runner/discover.js +30 -20
  34. package/dist/runner/orchestrator.d.ts +1 -0
  35. package/dist/runner/orchestrator.js +114 -19
  36. package/dist/runner/pool-executor.d.ts +7 -0
  37. package/dist/runner/pool-executor.js +29 -7
  38. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  39. package/dist/runner/shared/index.d.ts +1 -1
  40. package/dist/runner/shared/index.js +1 -1
  41. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  42. package/dist/runner/shared/mutant-paths.js +24 -0
  43. package/dist/runner/ts-checker-worker.d.ts +5 -0
  44. package/dist/runner/ts-checker-worker.js +66 -0
  45. package/dist/runner/ts-checker.d.ts +36 -0
  46. package/dist/runner/ts-checker.js +210 -0
  47. package/dist/runner/types.d.ts +2 -0
  48. package/dist/runner/vitest/__tests__/adapter.spec.js +41 -0
  49. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  50. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  51. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  52. package/dist/runner/vitest/adapter.js +14 -9
  53. package/dist/runner/vitest/plugin.d.ts +3 -0
  54. package/dist/runner/vitest/plugin.js +49 -11
  55. package/dist/runner/vitest/pool.d.ts +4 -1
  56. package/dist/runner/vitest/pool.js +25 -4
  57. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  58. package/dist/runner/vitest/worker-runtime.js +57 -18
  59. package/dist/runner/vitest/worker.mjs +10 -0
  60. package/dist/types/config.d.ts +16 -0
  61. package/dist/types/mutant.d.ts +5 -2
  62. package/dist/utils/CompileErrors.d.ts +7 -0
  63. package/dist/utils/CompileErrors.js +24 -0
  64. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  65. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  66. package/dist/utils/__tests__/summary.spec.js +126 -2
  67. package/dist/utils/summary.d.ts +23 -1
  68. package/dist/utils/summary.js +63 -3
  69. package/package.json +2 -1
@@ -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
  *
@@ -3,6 +3,30 @@
3
3
  */
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
+ /**
7
+ * Generate a file path for the schema file in the __mutineer__ directory.
8
+ * The schema file embeds all mutation variants for a source file.
9
+ *
10
+ * @param originalFile - Path to the original source file
11
+ * @returns Path where the schema file should be written (dir may not exist)
12
+ */
13
+ export function getSchemaFilePath(originalFile) {
14
+ const dir = path.dirname(originalFile);
15
+ const ext = path.extname(originalFile);
16
+ const basename = path.basename(originalFile, ext);
17
+ return path.join(dir, '__mutineer__', `${basename}_schema${ext}`);
18
+ }
19
+ /**
20
+ * Generate the path for a worker's active-mutant-ID file.
21
+ * Each worker writes the active mutant ID here so test forks can read it.
22
+ *
23
+ * @param workerId - Unique worker identifier
24
+ * @param cwd - Project working directory
25
+ * @returns Absolute path for the active ID file
26
+ */
27
+ export function getActiveIdFilePath(workerId, cwd) {
28
+ return path.join(cwd, '__mutineer__', `active_id_${workerId}.txt`);
29
+ }
6
30
  /**
7
31
  * Generate a file path for a mutant file in the __mutineer__ directory.
8
32
  *
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Worker thread entry point for parallel TypeScript type checking.
3
+ * Receives one file group, runs baseline + per-variant diagnose, posts results.
4
+ */
5
+ export {};
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Worker thread entry point for parallel TypeScript type checking.
3
+ * Receives one file group, runs baseline + per-variant diagnose, posts results.
4
+ */
5
+ import { workerData, parentPort } from 'worker_threads';
6
+ import ts from 'typescript';
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ /** Stable fingerprint for a diagnostic. */
10
+ function diagnosticKey(d) {
11
+ return `${d.code}:${d.start ?? -1}`;
12
+ }
13
+ /** Create a compiler host that serves `code` for `targetPath`. */
14
+ function makeHost(options, targetPath, code) {
15
+ const host = ts.createCompilerHost(options);
16
+ const orig = host.getSourceFile.bind(host);
17
+ host.getSourceFile = (fileName, langOrOpts) => {
18
+ if (path.resolve(fileName) === targetPath) {
19
+ return ts.createSourceFile(fileName, code, langOrOpts);
20
+ }
21
+ return orig(fileName, langOrOpts);
22
+ };
23
+ return host;
24
+ }
25
+ /** Run semantic diagnostics for `code` in `targetPath`. */
26
+ function diagnose(options, targetPath, code, oldProgram) {
27
+ const host = makeHost(options, targetPath, code);
28
+ const program = ts.createProgram({
29
+ rootNames: [targetPath],
30
+ options,
31
+ host,
32
+ oldProgram,
33
+ });
34
+ const sourceFile = program.getSourceFile(targetPath) ??
35
+ program.getSourceFile(path.relative(process.cwd(), targetPath));
36
+ if (!sourceFile) {
37
+ return { program, keys: new Set() };
38
+ }
39
+ const keys = new Set(program.getSemanticDiagnostics(sourceFile).map(diagnosticKey));
40
+ return { program, keys };
41
+ }
42
+ const { options, filePath, variants } = workerData;
43
+ const resolvedPath = path.resolve(filePath);
44
+ let originalCode = '';
45
+ try {
46
+ originalCode = fs.readFileSync(resolvedPath, 'utf8');
47
+ }
48
+ catch {
49
+ // empty baseline — all mutant errors count as new
50
+ }
51
+ const { program: baseProgram, keys: baselineKeys } = diagnose(options, resolvedPath, originalCode, undefined);
52
+ let prevProgram = baseProgram;
53
+ const compileErrorIds = [];
54
+ for (const variant of variants) {
55
+ const { program: mutProgram, keys: mutantKeys } = diagnose(options, resolvedPath, variant.code, prevProgram);
56
+ prevProgram = mutProgram;
57
+ let newErrors = 0;
58
+ for (const key of mutantKeys) {
59
+ if (!baselineKeys.has(key))
60
+ newErrors++;
61
+ }
62
+ if (newErrors > 0) {
63
+ compileErrorIds.push(variant.id);
64
+ }
65
+ }
66
+ parentPort.postMessage({ compileErrorIds });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * TypeScript Type Checker for Mutants
3
+ *
4
+ * Pre-filters mutants that produce TypeScript compile errors before running
5
+ * them against tests. This avoids running mutations that would be trivially
6
+ * detected by the type system, saving significant test execution time.
7
+ *
8
+ * Strategy: noLib + noResolve (zero I/O) + baseline comparison
9
+ * - noLib: true → don't load lib.d.ts (huge, causes hangs)
10
+ * - noResolve: true → don't follow user imports (avoids loading project files)
11
+ * - Baseline check → type-check original file first; only flag mutants that
12
+ * introduce NEW errors not present in the original. This
13
+ * eliminates false positives from missing lib/import types.
14
+ */
15
+ import ts from 'typescript';
16
+ import type { MutineerConfig } from '../types/config.js';
17
+ import type { Variant } from '../types/mutant.js';
18
+ /**
19
+ * Run type checks for one file group synchronously. Used both by the sync
20
+ * fallback (test/dev environments) and by the worker thread.
21
+ */
22
+ export declare function checkFileSync(options: ts.CompilerOptions, filePath: string, fileVariants: readonly Variant[]): string[];
23
+ /**
24
+ * Check TypeScript types for mutated variants.
25
+ * Returns a Set of variant IDs that introduce NEW compile errors vs the original.
26
+ */
27
+ export declare function checkTypes(variants: readonly Variant[], tsconfig: string | undefined, cwd: string): Promise<Set<string>>;
28
+ /**
29
+ * Determine whether TypeScript type checking should run for this invocation.
30
+ * Precedence: CLI flag > config field > auto-detect (tsconfig.json presence).
31
+ */
32
+ export declare function resolveTypescriptEnabled(cliFlag: boolean | undefined, config: MutineerConfig, cwd: string): boolean;
33
+ /**
34
+ * Extract the tsconfig path from the MutineerConfig typescript option.
35
+ */
36
+ export declare function resolveTsconfigPath(config: MutineerConfig): string | undefined;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * TypeScript Type Checker for Mutants
3
+ *
4
+ * Pre-filters mutants that produce TypeScript compile errors before running
5
+ * them against tests. This avoids running mutations that would be trivially
6
+ * detected by the type system, saving significant test execution time.
7
+ *
8
+ * Strategy: noLib + noResolve (zero I/O) + baseline comparison
9
+ * - noLib: true → don't load lib.d.ts (huge, causes hangs)
10
+ * - noResolve: true → don't follow user imports (avoids loading project files)
11
+ * - Baseline check → type-check original file first; only flag mutants that
12
+ * introduce NEW errors not present in the original. This
13
+ * eliminates false positives from missing lib/import types.
14
+ */
15
+ import ts from 'typescript';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
19
+ import { Worker } from 'worker_threads';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { createLogger } from '../utils/logger.js';
22
+ const log = createLogger('ts-checker');
23
+ /**
24
+ * Compiler options used for all type checks: fully isolated, zero I/O.
25
+ * We read strict/noImplicitAny/etc from the user's tsconfig (to catch the
26
+ * same errors they care about), but always override lib/resolve settings.
27
+ */
28
+ function resolveCompilerOptions(tsconfig, cwd) {
29
+ const base = {
30
+ noEmit: true,
31
+ skipLibCheck: true,
32
+ strict: false,
33
+ noLib: true, // Never load lib.d.ts — huge, causes hangs
34
+ noResolve: true, // Never follow user imports — avoids loading project files
35
+ };
36
+ const searchDir = tsconfig ? path.dirname(path.resolve(cwd, tsconfig)) : cwd;
37
+ const configPath = ts.findConfigFile(searchDir, ts.sys.fileExists) ?? undefined;
38
+ if (!configPath)
39
+ return base;
40
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
41
+ if (configFile.error) {
42
+ log.debug(`Failed to read tsconfig at ${configPath}: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')}`);
43
+ return base;
44
+ }
45
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath));
46
+ // Take user's strictness settings (strict, noImplicitAny, exactOptionalPropertyTypes,
47
+ // etc.) so we catch the same class of errors they care about, but always override
48
+ // the I/O settings to keep checks fast and isolated.
49
+ return {
50
+ ...parsed.options,
51
+ noEmit: true,
52
+ skipLibCheck: true,
53
+ noLib: true,
54
+ noResolve: true,
55
+ };
56
+ }
57
+ /** Stable fingerprint for a diagnostic — used to diff baseline vs mutant errors. */
58
+ function diagnosticKey(d) {
59
+ return `${d.code}:${d.start ?? -1}`;
60
+ }
61
+ /** Create a compiler host that serves `code` for `targetPath`, real fs for everything else. */
62
+ function makeHost(options, targetPath, code) {
63
+ const host = ts.createCompilerHost(options);
64
+ const orig = host.getSourceFile.bind(host);
65
+ host.getSourceFile = (fileName, langOrOpts) => {
66
+ if (path.resolve(fileName) === targetPath) {
67
+ return ts.createSourceFile(fileName, code, langOrOpts);
68
+ }
69
+ return orig(fileName, langOrOpts);
70
+ };
71
+ return host;
72
+ }
73
+ /** Run semantic diagnostics for `code` in `targetPath`, reusing `oldProgram` if provided. */
74
+ function diagnose(options, targetPath, code, oldProgram) {
75
+ const host = makeHost(options, targetPath, code);
76
+ const program = ts.createProgram({
77
+ rootNames: [targetPath],
78
+ options,
79
+ host,
80
+ oldProgram,
81
+ });
82
+ const sourceFile = program.getSourceFile(targetPath) ??
83
+ program.getSourceFile(path.relative(process.cwd(), targetPath));
84
+ if (!sourceFile) {
85
+ return { program, keys: new Set() };
86
+ }
87
+ const keys = new Set(program.getSemanticDiagnostics(sourceFile).map(diagnosticKey));
88
+ return { program, keys };
89
+ }
90
+ /**
91
+ * Run type checks for one file group synchronously. Used both by the sync
92
+ * fallback (test/dev environments) and by the worker thread.
93
+ */
94
+ export function checkFileSync(options, filePath, fileVariants) {
95
+ const resolvedPath = path.resolve(filePath);
96
+ const compileErrorIds = [];
97
+ let originalCode = '';
98
+ try {
99
+ originalCode = fs.readFileSync(resolvedPath, 'utf8');
100
+ }
101
+ catch {
102
+ log.debug(`Cannot read ${filePath} for baseline — using empty baseline`);
103
+ }
104
+ const { program: baseProgram, keys: baselineKeys } = diagnose(options, resolvedPath, originalCode, undefined);
105
+ log.debug(`Baseline for ${filePath}: ${baselineKeys.size} error(s)`);
106
+ let prevProgram = baseProgram;
107
+ for (const variant of fileVariants) {
108
+ const { program: mutProgram, keys: mutantKeys } = diagnose(options, resolvedPath, variant.code, prevProgram);
109
+ prevProgram = mutProgram;
110
+ let newErrors = 0;
111
+ for (const key of mutantKeys) {
112
+ if (!baselineKeys.has(key))
113
+ newErrors++;
114
+ }
115
+ if (newErrors > 0) {
116
+ compileErrorIds.push(variant.id);
117
+ log.debug(`Compile error in ${variant.id}: ${newErrors} new error(s)`);
118
+ }
119
+ }
120
+ return compileErrorIds;
121
+ }
122
+ /** Spawn a worker for one file group and resolve with the compile-error IDs. */
123
+ function runWorker(workerPath, options, filePath, variants) {
124
+ return new Promise((resolve, reject) => {
125
+ const worker = new Worker(workerPath, {
126
+ workerData: {
127
+ options,
128
+ filePath,
129
+ variants: variants.map((v) => ({ id: v.id, code: v.code })),
130
+ },
131
+ });
132
+ worker.once('message', (msg) => {
133
+ resolve(msg.compileErrorIds);
134
+ });
135
+ worker.once('error', reject);
136
+ worker.once('exit', (code) => {
137
+ if (code !== 0)
138
+ reject(new Error(`ts-checker worker exited with code ${code}`));
139
+ });
140
+ });
141
+ }
142
+ /**
143
+ * Check TypeScript types for mutated variants.
144
+ * Returns a Set of variant IDs that introduce NEW compile errors vs the original.
145
+ */
146
+ export async function checkTypes(variants, tsconfig, cwd) {
147
+ const compileErrors = new Set();
148
+ if (variants.length === 0)
149
+ return compileErrors;
150
+ const options = resolveCompilerOptions(tsconfig, cwd);
151
+ // Group variants by source file
152
+ const byFile = new Map();
153
+ for (const v of variants) {
154
+ const list = byFile.get(v.file);
155
+ if (list)
156
+ list.push(v);
157
+ else
158
+ byFile.set(v.file, [v]);
159
+ }
160
+ // In test/dev environments import.meta.url ends in .ts — Node can't load .ts
161
+ // workers, so fall back to the synchronous path.
162
+ const isTsSource = import.meta.url.endsWith('.ts');
163
+ if (isTsSource) {
164
+ for (const [filePath, fileVariants] of byFile) {
165
+ for (const id of checkFileSync(options, filePath, fileVariants)) {
166
+ compileErrors.add(id);
167
+ }
168
+ }
169
+ return compileErrors;
170
+ }
171
+ // Parallel worker dispatch
172
+ const workerPath = fileURLToPath(new URL('./ts-checker-worker.js', import.meta.url));
173
+ const maxConcurrency = Math.max(os.cpus().length - 1, 1);
174
+ const entries = Array.from(byFile.entries());
175
+ for (let i = 0; i < entries.length; i += maxConcurrency) {
176
+ const batch = entries.slice(i, i + maxConcurrency);
177
+ const results = await Promise.all(batch.map(([filePath, fileVariants]) => runWorker(workerPath, options, filePath, fileVariants)));
178
+ for (const ids of results) {
179
+ for (const id of ids)
180
+ compileErrors.add(id);
181
+ }
182
+ }
183
+ return compileErrors;
184
+ }
185
+ /**
186
+ * Determine whether TypeScript type checking should run for this invocation.
187
+ * Precedence: CLI flag > config field > auto-detect (tsconfig.json presence).
188
+ */
189
+ export function resolveTypescriptEnabled(cliFlag, config, cwd) {
190
+ if (cliFlag === true)
191
+ return true;
192
+ if (cliFlag === false)
193
+ return false;
194
+ const cfgTs = config.typescript;
195
+ if (cfgTs === false)
196
+ return false;
197
+ if (cfgTs !== undefined)
198
+ return true;
199
+ // Auto-detect: enable if tsconfig.json exists in cwd
200
+ return ts.findConfigFile(cwd, ts.sys.fileExists) !== undefined;
201
+ }
202
+ /**
203
+ * Extract the tsconfig path from the MutineerConfig typescript option.
204
+ */
205
+ export function resolveTsconfigPath(config) {
206
+ if (typeof config.typescript === 'object' && config.typescript !== null) {
207
+ return config.typescript.tsconfig;
208
+ }
209
+ return undefined;
210
+ }
@@ -16,6 +16,8 @@ export interface TestRunnerAdapterOptions {
16
16
  readonly timeoutMs: number;
17
17
  readonly config: MutineerConfig;
18
18
  readonly cliArgs: string[];
19
+ /** Vitest workspace project name to target (from --vitest-project or config) */
20
+ readonly vitestProject?: string;
19
21
  }
20
22
  /**
21
23
  * Coverage-related configuration detected from the test runner.
@@ -122,6 +122,21 @@ describe('Vitest adapter', () => {
122
122
  expect(argStr).toContain('--coverage.thresholds.branches=0');
123
123
  expect(argStr).toContain('--coverage.thresholds.statements=0');
124
124
  });
125
+ it('strips --shard= flag from vitest args', async () => {
126
+ const adapter = makeAdapter({ cliArgs: ['--shard=1/4'] });
127
+ spawnMock.mockImplementationOnce(() => ({
128
+ on: (evt, cb) => {
129
+ if (evt === 'exit')
130
+ cb(0);
131
+ },
132
+ }));
133
+ await adapter.runBaseline(['test-a'], {
134
+ collectCoverage: false,
135
+ perTestCoverage: false,
136
+ });
137
+ const args = spawnMock.mock.calls[0][1];
138
+ expect(args.join(' ')).not.toContain('--shard');
139
+ });
125
140
  it('detects coverage config from vitest config file', async () => {
126
141
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-vitest-'));
127
142
  const cfgPath = path.join(tmp, 'vitest.config.ts');
@@ -140,6 +155,32 @@ describe('Vitest adapter', () => {
140
155
  }
141
156
  });
142
157
  });
158
+ describe('hasCoverageProvider', () => {
159
+ it('returns true when @vitest/coverage-v8 is resolvable', () => {
160
+ const adapter = makeAdapter({ cwd: process.cwd() });
161
+ // coverage-v8 is installed as a devDependency, so this must resolve
162
+ expect(adapter.hasCoverageProvider()).toBe(true);
163
+ });
164
+ it('returns false when neither provider is resolvable', () => {
165
+ const adapter = makeAdapter({ cwd: '/tmp' });
166
+ expect(adapter.hasCoverageProvider()).toBe(false);
167
+ });
168
+ it('returns true when @vitest/coverage-istanbul is resolvable', () => {
169
+ const adapter = makeAdapter({ cwd: process.cwd() });
170
+ const origResolve = require.resolve;
171
+ const resolveStub = vi
172
+ .spyOn(require, 'resolve')
173
+ .mockImplementation((id, opts) => {
174
+ if (String(id).includes('coverage-v8'))
175
+ throw new Error('not found');
176
+ if (String(id).includes('coverage-istanbul'))
177
+ return '/fake/path';
178
+ return origResolve(id, opts);
179
+ });
180
+ expect(adapter.hasCoverageProvider()).toBe(true);
181
+ resolveStub.mockRestore();
182
+ });
183
+ });
143
184
  describe('isCoverageRequestedInArgs', () => {
144
185
  it('detects enabled coverage flags', () => {
145
186
  expect(isCoverageRequestedInArgs(['--coverage'])).toBe(true);
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
2
  import fs from 'node:fs/promises';
3
+ import fssync from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import os from 'node:os';
5
6
  import { poolMutineerPlugin } from '../plugin.js';
@@ -13,10 +14,21 @@ function getLoadFn() {
13
14
  }
14
15
  return undefined;
15
16
  }
17
+ function getConfigFn() {
18
+ const plugin = poolMutineerPlugin();
19
+ if (Array.isArray(plugin)) {
20
+ return plugin[0]?.config;
21
+ }
22
+ if (plugin && typeof plugin === 'object' && 'config' in plugin) {
23
+ return plugin.config;
24
+ }
25
+ return undefined;
26
+ }
16
27
  describe('poolMutineerPlugin', () => {
17
28
  afterEach(() => {
18
29
  ;
19
30
  globalThis.__mutineer_redirect__ = undefined;
31
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
20
32
  });
21
33
  it('returns null when no redirect is configured', () => {
22
34
  const load = getLoadFn();
@@ -62,4 +74,143 @@ describe('poolMutineerPlugin', () => {
62
74
  await fs.rm(tmpDir, { recursive: true, force: true });
63
75
  }
64
76
  });
77
+ it('returns schema code when a schema file exists for the source path', async () => {
78
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-plugin-'));
79
+ const sourcePath = path.join(tmpDir, 'source.ts');
80
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
81
+ const schemaPath = path.join(mutineerDir, 'source_schema.ts');
82
+ await fs.mkdir(mutineerDir);
83
+ await fs.writeFile(schemaPath, '// @ts-nocheck\nconst x = 1', 'utf8');
84
+ try {
85
+ const load = getLoadFn();
86
+ const result = load?.(sourcePath);
87
+ expect(result).toBe('// @ts-nocheck\nconst x = 1');
88
+ }
89
+ finally {
90
+ await fs.rm(tmpDir, { recursive: true, force: true });
91
+ }
92
+ });
93
+ it('caches schema content so subsequent loads skip filesystem I/O', async () => {
94
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-cache-'));
95
+ const sourcePath = path.join(tmpDir, 'source.ts');
96
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
97
+ const schemaPath = path.join(mutineerDir, 'source_schema.ts');
98
+ await fs.mkdir(mutineerDir);
99
+ await fs.writeFile(schemaPath, '// cached schema', 'utf8');
100
+ // Use the same plugin instance for both calls (shared cache)
101
+ const plugin = poolMutineerPlugin();
102
+ const load = plugin?.load?.bind(plugin);
103
+ try {
104
+ const first = load?.(sourcePath);
105
+ expect(first).toBe('// cached schema');
106
+ // Remove schema file - second call must still return cached content
107
+ await fs.rm(tmpDir, { recursive: true, force: true });
108
+ const second = load?.(sourcePath);
109
+ expect(second).toBe('// cached schema');
110
+ }
111
+ finally {
112
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
113
+ }
114
+ });
115
+ it('caches null for paths with no schema to avoid repeated existsSync calls', async () => {
116
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-nocache-'));
117
+ const sourcePath = path.join(tmpDir, 'no-schema.ts');
118
+ try {
119
+ const plugin = poolMutineerPlugin();
120
+ const load = plugin?.load?.bind(plugin);
121
+ const first = load?.(sourcePath);
122
+ expect(first).toBeNull();
123
+ // Create schema file after first call - second call must return null (cached)
124
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
125
+ await fs.mkdir(mutineerDir, { recursive: true });
126
+ await fs.writeFile(path.join(mutineerDir, 'no-schema_schema.ts'), '// late schema', 'utf8');
127
+ const second = load?.(sourcePath);
128
+ expect(second).toBeNull();
129
+ }
130
+ finally {
131
+ await fs.rm(tmpDir, { recursive: true, force: true });
132
+ }
133
+ });
134
+ it('prefers redirect over schema when both are present (fallback mutation path)', async () => {
135
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-schema-redirect-'));
136
+ const sourcePath = path.join(tmpDir, 'source.ts');
137
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
138
+ const schemaPath = path.join(mutineerDir, 'source_schema.ts');
139
+ const mutatedPath = path.join(mutineerDir, 'source_0.ts');
140
+ await fs.mkdir(mutineerDir);
141
+ await fs.writeFile(schemaPath, '// schema', 'utf8');
142
+ await fs.writeFile(mutatedPath, '// mutated', 'utf8');
143
+ try {
144
+ ;
145
+ globalThis.__mutineer_redirect__ = {
146
+ from: sourcePath,
147
+ to: mutatedPath,
148
+ };
149
+ const load = getLoadFn();
150
+ const result = load?.(sourcePath);
151
+ // Redirect wins: fallback mutations use setRedirect + invalidateFile.
152
+ // The schema must not shadow the mutant code during fallback runs.
153
+ expect(result).toBe('// mutated');
154
+ }
155
+ finally {
156
+ await fs.rm(tmpDir, { recursive: true, force: true });
157
+ }
158
+ });
159
+ describe('config hook', () => {
160
+ it('returns null when MUTINEER_ACTIVE_ID_FILE is not set', () => {
161
+ const config = getConfigFn();
162
+ const result = config?.({ test: { setupFiles: ['/existing/setup.ts'] } });
163
+ expect(result).toBeNull();
164
+ });
165
+ it('appends setup.mjs to setupFiles when MUTINEER_ACTIVE_ID_FILE is set', () => {
166
+ const tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-config-'));
167
+ const activeIdFile = path.join(tmpDir, '__mutineer__', 'active_id_w0.txt');
168
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
169
+ try {
170
+ const config = getConfigFn();
171
+ const result = config?.({
172
+ test: { setupFiles: ['/existing/setup.ts'] },
173
+ });
174
+ const setupFiles = result?.test?.setupFiles;
175
+ expect(setupFiles).toContain('/existing/setup.ts');
176
+ expect(setupFiles.some((f) => f.endsWith('setup.mjs'))).toBe(true);
177
+ }
178
+ finally {
179
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
180
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
181
+ }
182
+ });
183
+ it('creates setupFiles array from string value', () => {
184
+ const tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-config2-'));
185
+ const activeIdFile = path.join(tmpDir, '__mutineer__', 'active_id_w0.txt');
186
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
187
+ try {
188
+ const config = getConfigFn();
189
+ const result = config?.({ test: { setupFiles: '/single/setup.ts' } });
190
+ const setupFiles = result?.test?.setupFiles;
191
+ expect(setupFiles).toContain('/single/setup.ts');
192
+ expect(setupFiles.length).toBe(2);
193
+ }
194
+ finally {
195
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
196
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
197
+ }
198
+ });
199
+ it('creates setupFiles from empty config when no existing setupFiles', () => {
200
+ const tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-config3-'));
201
+ const activeIdFile = path.join(tmpDir, '__mutineer__', 'active_id_w0.txt');
202
+ process.env.MUTINEER_ACTIVE_ID_FILE = activeIdFile;
203
+ try {
204
+ const config = getConfigFn();
205
+ const result = config?.({});
206
+ const setupFiles = result?.test?.setupFiles;
207
+ expect(setupFiles.length).toBe(1);
208
+ expect(setupFiles[0]).toMatch(/setup\.mjs$/);
209
+ }
210
+ finally {
211
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
212
+ delete process.env.MUTINEER_ACTIVE_ID_FILE;
213
+ }
214
+ });
215
+ });
65
216
  });