@mutineerjs/mutineer 0.2.2 → 0.2.4

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 (50) hide show
  1. package/README.md +22 -7
  2. package/dist/core/__tests__/module.spec.js +66 -3
  3. package/dist/core/__tests__/sfc.spec.d.ts +1 -0
  4. package/dist/core/__tests__/sfc.spec.js +76 -0
  5. package/dist/core/__tests__/variant-utils.spec.d.ts +1 -0
  6. package/dist/core/__tests__/variant-utils.spec.js +93 -0
  7. package/dist/runner/__tests__/args.spec.d.ts +1 -0
  8. package/dist/runner/__tests__/args.spec.js +225 -0
  9. package/dist/runner/__tests__/cache.spec.d.ts +1 -0
  10. package/dist/runner/__tests__/cache.spec.js +180 -0
  11. package/dist/runner/__tests__/changed.spec.d.ts +1 -0
  12. package/dist/runner/__tests__/changed.spec.js +227 -0
  13. package/dist/runner/__tests__/cleanup.spec.d.ts +1 -0
  14. package/dist/runner/__tests__/cleanup.spec.js +41 -0
  15. package/dist/runner/__tests__/config.spec.d.ts +1 -0
  16. package/dist/runner/__tests__/config.spec.js +71 -0
  17. package/dist/runner/__tests__/coverage-resolver.spec.d.ts +1 -0
  18. package/dist/runner/__tests__/coverage-resolver.spec.js +171 -0
  19. package/dist/runner/__tests__/pool-executor.spec.d.ts +1 -0
  20. package/dist/runner/__tests__/pool-executor.spec.js +213 -0
  21. package/dist/runner/__tests__/tasks.spec.d.ts +1 -0
  22. package/dist/runner/__tests__/tasks.spec.js +95 -0
  23. package/dist/runner/__tests__/variants.spec.d.ts +1 -0
  24. package/dist/runner/__tests__/variants.spec.js +259 -0
  25. package/dist/runner/args.d.ts +5 -0
  26. package/dist/runner/args.js +7 -0
  27. package/dist/runner/config.js +2 -2
  28. package/dist/runner/coverage-resolver.d.ts +21 -0
  29. package/dist/runner/coverage-resolver.js +96 -0
  30. package/dist/runner/jest/__tests__/pool.spec.d.ts +1 -0
  31. package/dist/runner/jest/__tests__/pool.spec.js +212 -0
  32. package/dist/runner/jest/__tests__/worker-runtime.spec.d.ts +1 -0
  33. package/dist/runner/jest/__tests__/worker-runtime.spec.js +148 -0
  34. package/dist/runner/orchestrator.js +43 -295
  35. package/dist/runner/pool-executor.d.ts +17 -0
  36. package/dist/runner/pool-executor.js +143 -0
  37. package/dist/runner/shared/__tests__/mutant-paths.spec.d.ts +1 -0
  38. package/dist/runner/shared/__tests__/mutant-paths.spec.js +66 -0
  39. package/dist/runner/shared/__tests__/redirect-state.spec.d.ts +1 -0
  40. package/dist/runner/shared/__tests__/redirect-state.spec.js +56 -0
  41. package/dist/runner/tasks.d.ts +12 -0
  42. package/dist/runner/tasks.js +25 -0
  43. package/dist/runner/variants.d.ts +17 -2
  44. package/dist/runner/variants.js +33 -0
  45. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +4 -0
  46. package/dist/utils/__tests__/logger.spec.d.ts +1 -0
  47. package/dist/utils/__tests__/logger.spec.js +61 -0
  48. package/dist/utils/__tests__/normalizePath.spec.d.ts +1 -0
  49. package/dist/utils/__tests__/normalizePath.spec.js +22 -0
  50. package/package.json +3 -1
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { JestWorkerRuntime, createJestWorkerRuntime } from '../worker-runtime.js';
3
+ // Mock the shared utilities
4
+ vi.mock('../../shared/index.js', () => ({
5
+ getMutantFilePath: vi.fn((file, id) => `/tmp/__mutineer__/mutant_${id}.ts`),
6
+ setRedirect: vi.fn(),
7
+ clearRedirect: vi.fn(),
8
+ }));
9
+ // Mock fs sync operations
10
+ const writeFileSyncMock = vi.fn();
11
+ const rmSyncMock = vi.fn();
12
+ vi.mock('node:fs', () => ({
13
+ default: {
14
+ existsSync: vi.fn(() => true),
15
+ writeFileSync: (...args) => writeFileSyncMock(...args),
16
+ rmSync: (...args) => rmSyncMock(...args),
17
+ },
18
+ existsSync: vi.fn(() => true),
19
+ writeFileSync: (...args) => writeFileSyncMock(...args),
20
+ rmSync: (...args) => rmSyncMock(...args),
21
+ }));
22
+ // Mock @jest/core
23
+ const mockRunCLI = vi.fn();
24
+ vi.mock('@jest/core', () => ({
25
+ runCLI: (...args) => mockRunCLI(...args),
26
+ }));
27
+ describe('JestWorkerRuntime', () => {
28
+ let runtime;
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ runtime = new JestWorkerRuntime({
32
+ workerId: 'w0',
33
+ cwd: '/project',
34
+ });
35
+ });
36
+ afterEach(() => {
37
+ // Clean up env vars
38
+ delete process.env.MUTINEER_REDIRECT_FROM;
39
+ delete process.env.MUTINEER_REDIRECT_TO;
40
+ });
41
+ it('init and shutdown are no-ops', async () => {
42
+ await expect(runtime.init()).resolves.toBeUndefined();
43
+ await expect(runtime.shutdown()).resolves.toBeUndefined();
44
+ });
45
+ it('runs a mutant and returns killed when tests fail', async () => {
46
+ mockRunCLI.mockResolvedValueOnce({
47
+ results: {
48
+ success: false,
49
+ numTotalTests: 3,
50
+ testResults: [{ failureMessage: 'Expected true to be false' }],
51
+ },
52
+ globalConfig: {},
53
+ });
54
+ const result = await runtime.run({
55
+ id: 'foo.ts#1',
56
+ name: 'flipEQ',
57
+ file: '/project/src/foo.ts',
58
+ code: 'mutated code',
59
+ line: 1,
60
+ col: 0,
61
+ }, ['/project/tests/foo.test.ts']);
62
+ expect(result.killed).toBe(true);
63
+ expect(result.durationMs).toBeGreaterThanOrEqual(0);
64
+ expect(result.error).toBe('Expected true to be false');
65
+ });
66
+ it('returns not killed when tests pass', async () => {
67
+ mockRunCLI.mockResolvedValueOnce({
68
+ results: {
69
+ success: true,
70
+ numTotalTests: 3,
71
+ testResults: [],
72
+ },
73
+ globalConfig: {},
74
+ });
75
+ const result = await runtime.run({
76
+ id: 'foo.ts#1',
77
+ name: 'flipEQ',
78
+ file: '/project/src/foo.ts',
79
+ code: 'mutated code',
80
+ line: 1,
81
+ col: 0,
82
+ }, ['/project/tests/foo.test.ts']);
83
+ expect(result.killed).toBe(false);
84
+ });
85
+ it('returns killed on runCLI error', async () => {
86
+ mockRunCLI.mockRejectedValueOnce(new Error('Jest crashed'));
87
+ const result = await runtime.run({
88
+ id: 'foo.ts#1',
89
+ name: 'flipEQ',
90
+ file: '/project/src/foo.ts',
91
+ code: 'mutated code',
92
+ line: 1,
93
+ col: 0,
94
+ }, ['/project/tests/foo.test.ts']);
95
+ expect(result.killed).toBe(true);
96
+ expect(result.error).toBe('Jest crashed');
97
+ });
98
+ it('writes the mutant file and cleans up after run', async () => {
99
+ mockRunCLI.mockResolvedValueOnce({
100
+ results: { success: true, testResults: [] },
101
+ globalConfig: {},
102
+ });
103
+ const { getMutantFilePath, setRedirect, clearRedirect } = await import('../../shared/index.js');
104
+ await runtime.run({
105
+ id: 'foo.ts#1',
106
+ name: 'flipEQ',
107
+ file: '/project/src/foo.ts',
108
+ code: 'mutated code',
109
+ line: 1,
110
+ col: 0,
111
+ }, ['/project/tests/foo.test.ts']);
112
+ expect(getMutantFilePath).toHaveBeenCalled();
113
+ expect(writeFileSyncMock).toHaveBeenCalled();
114
+ expect(setRedirect).toHaveBeenCalled();
115
+ expect(clearRedirect).toHaveBeenCalled();
116
+ expect(rmSyncMock).toHaveBeenCalled();
117
+ });
118
+ it('uses jest config when provided', async () => {
119
+ const runtimeWithConfig = new JestWorkerRuntime({
120
+ workerId: 'w0',
121
+ cwd: '/project',
122
+ jestConfigPath: 'jest.config.ts',
123
+ });
124
+ mockRunCLI.mockResolvedValueOnce({
125
+ results: { success: true, testResults: [] },
126
+ globalConfig: {},
127
+ });
128
+ await runtimeWithConfig.run({
129
+ id: 'foo.ts#1',
130
+ name: 'flipEQ',
131
+ file: '/project/src/foo.ts',
132
+ code: 'mutated code',
133
+ line: 1,
134
+ col: 0,
135
+ }, ['/project/tests/foo.test.ts']);
136
+ const callArgs = mockRunCLI.mock.calls[0][0];
137
+ expect(callArgs.config).toBe('jest.config.ts');
138
+ });
139
+ });
140
+ describe('createJestWorkerRuntime', () => {
141
+ it('returns a JestWorkerRuntime instance', () => {
142
+ const runtime = createJestWorkerRuntime({
143
+ workerId: 'w1',
144
+ cwd: '/project',
145
+ });
146
+ expect(runtime).toBeInstanceOf(JestWorkerRuntime);
147
+ });
148
+ });
@@ -12,51 +12,31 @@
12
12
  import path from 'node:path';
13
13
  import os from 'node:os';
14
14
  import { normalizePath } from '../utils/normalizePath.js';
15
- import { render } from 'ink';
16
- import { createElement } from 'react';
17
15
  import { autoDiscoverTargetsAndTests } from './discover.js';
18
16
  import { listChangedFiles } from './changed.js';
19
17
  import { loadMutineerConfig } from './config.js';
20
- import { Progress } from '../utils/progress.js';
21
- import { computeSummary, printSummary } from '../utils/summary.js';
22
- import { loadCoverageData, loadPerTestCoverageData, isLineCovered, } from '../utils/coverage.js';
23
- import { createVitestAdapter, isCoverageRequestedInArgs, } from './vitest/index.js';
18
+ import { createVitestAdapter } from './vitest/index.js';
24
19
  import { createJestAdapter } from './jest/index.js';
25
20
  import { createLogger } from '../utils/logger.js';
26
- import { PoolSpinner } from '../utils/PoolSpinner.js';
27
- // CLI argument parsing
28
- import { parseCliOptions } from './args.js';
29
- // Cache management
30
- import { clearCacheOnStart, saveCacheAtomic, readMutantCache, keyForTests, hash, } from './cache.js';
31
- // Variant enumeration
32
- import { enumerateVariantsForTarget, filterTestsByCoverage, getTargetFile, } from './variants.js';
21
+ import { extractConfigPath, parseCliOptions } from './args.js';
22
+ import { clearCacheOnStart, readMutantCache } from './cache.js';
23
+ import { getTargetFile, enumerateAllVariants } from './variants.js';
24
+ import { resolveCoverageConfig, loadCoverageAfterBaseline, } from './coverage-resolver.js';
25
+ import { prepareTasks } from './tasks.js';
26
+ import { executePool } from './pool-executor.js';
33
27
  const log = createLogger('orchestrator');
34
- let testMap;
35
28
  // Per-mutant test timeout (ms). Can be overridden with env MUTINEER_MUTANT_TIMEOUT_MS
36
29
  const MUTANT_TIMEOUT_MS = (() => {
37
30
  const raw = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
38
31
  const n = raw ? Number(raw) : NaN;
39
32
  return Number.isFinite(n) && n > 0 ? n : 30_000;
40
33
  })();
41
- import { cleanupMutineerDirs } from './cleanup.js';
42
34
  // Re-export readMutantCache for external use
43
35
  export { readMutantCache } from './cache.js';
44
36
  export async function runOrchestrator(cliArgs, cwd) {
45
- // Load configuration
46
- const configPath = cliArgs.find((arg, i) => arg === '--config' || arg === '-c'
47
- ? cliArgs[i + 1]
48
- : arg.startsWith('--config=') || arg.startsWith('-c='));
49
- const cfgPath = configPath?.startsWith('--config=')
50
- ? configPath.slice(9)
51
- : configPath?.startsWith('-c=')
52
- ? configPath.slice(3)
53
- : cliArgs.includes('--config')
54
- ? cliArgs[cliArgs.indexOf('--config') + 1]
55
- : cliArgs.includes('-c')
56
- ? cliArgs[cliArgs.indexOf('-c') + 1]
57
- : undefined;
37
+ // 1. Parse CLI arguments and load configuration
38
+ const cfgPath = extractConfigPath(cliArgs);
58
39
  const cfg = await loadMutineerConfig(cwd, cfgPath);
59
- // Parse CLI options
60
40
  const opts = parseCliOptions(cliArgs, cfg);
61
41
  await clearCacheOnStart(cwd);
62
42
  // Create test runner adapter
@@ -67,56 +47,17 @@ export async function runOrchestrator(cliArgs, cwd) {
67
47
  config: cfg,
68
48
  cliArgs,
69
49
  });
70
- // Detect coverage configuration from the adapter
71
- const coverageConfig = await adapter.detectCoverageConfig();
72
- const wantsPerTestCoverageFromConfig = coverageConfig.perTestEnabled;
73
- const coveragePreference = cfg.coverage;
74
- const wantsCoverageRun = coveragePreference === true
75
- ? true
76
- : coveragePreference === false
77
- ? false
78
- : isCoverageRequestedInArgs(cliArgs) || coverageConfig.coverageEnabled;
79
- // Load pre-existing coverage data if provided
80
- let coverageData = null;
81
- let perTestCoverage = null;
82
- if (opts.coverageFilePath) {
83
- log.info(`Loading coverage data from ${opts.coverageFilePath}...`);
84
- coverageData = await loadCoverageData(opts.coverageFilePath, cwd);
85
- log.info(`Loaded coverage for ${coverageData.coveredLines.size} files`);
86
- }
87
- // If --only-covered-lines but no coverage file, we'll generate it during baseline
88
- const needsCoverageFromBaseline = opts.wantsOnlyCoveredLines && !coverageData;
89
- const hasCoverageProviderInstalled = adapter.hasCoverageProvider();
90
- const rawPerTestCoverage = opts.wantsPerTestCoverage ||
91
- wantsPerTestCoverageFromConfig ||
92
- (opts.wantsOnlyCoveredLines && hasCoverageProviderInstalled);
93
- const wantsPerTestCoverage = opts.runner === 'jest' ? false : rawPerTestCoverage;
94
- if (opts.runner === 'jest' && rawPerTestCoverage) {
95
- log.warn('Per-test coverage is not supported for Jest; continuing without per-test coverage.');
96
- }
97
- if (needsCoverageFromBaseline && !hasCoverageProviderInstalled) {
98
- log.warn('The "onlyCoveredLines" option requires a coverage provider to generate coverage data.');
99
- log.warn('Please install the appropriate coverage package (or disable onlyCoveredLines).');
100
- process.exitCode = 1;
101
- return;
102
- }
103
- if (opts.wantsOnlyCoveredLines &&
104
- coverageData &&
105
- !hasCoverageProviderInstalled) {
106
- log.warn('The "onlyCoveredLines" option is enabled, but no coverage provider is installed.');
107
- log.warn('Running baseline tests without injecting per-test coverage; existing coverageFile will be used for filtering.');
108
- }
50
+ // 2. Resolve coverage configuration
51
+ const coverage = await resolveCoverageConfig(opts, cfg, adapter, cliArgs);
52
+ if (process.exitCode)
53
+ return; // resolveCoverageConfig sets exitCode on fatal errors
109
54
  log.info(`Mutineer starting in ${opts.wantsChangedWithDeps
110
55
  ? 'changed files with dependencies'
111
56
  : opts.wantsChanged
112
57
  ? 'changed files only'
113
58
  : 'full'} mode${opts.wantsOnlyCoveredLines ? ' (only covered lines)' : ''}...`);
114
59
  log.info(`Using concurrency=${opts.concurrency} (cpus=${os.cpus().length})`);
115
- const enableCoverageForBaseline = needsCoverageFromBaseline ||
116
- wantsPerTestCoverage ||
117
- wantsCoverageRun ||
118
- (opts.wantsOnlyCoveredLines && hasCoverageProviderInstalled);
119
- // Enumerate changed files if requested
60
+ // 3. Enumerate changed files if requested
120
61
  const changedAbs = opts.wantsChanged || opts.wantsChangedWithDeps
121
62
  ? new Set(listChangedFiles(cwd, {
122
63
  includeDeps: opts.wantsChangedWithDeps,
@@ -124,12 +65,10 @@ export async function runOrchestrator(cliArgs, cwd) {
124
65
  maxDepth: cfg.dependencyDepth,
125
66
  }))
126
67
  : null;
127
- const variants = [];
68
+ // 4. Discover targets and tests
128
69
  const cache = await readMutantCache(cwd);
129
- // Always run discovery to build testMap (maps source files → test files)
130
70
  const discovered = await autoDiscoverTargetsAndTests(cwd, cfg);
131
- testMap = discovered.testMap;
132
- // Use explicit targets if provided, otherwise use discovered targets
71
+ const testMap = discovered.testMap;
133
72
  const targets = cfg.targets?.length
134
73
  ? [...cfg.targets]
135
74
  : (cfg.autoDiscover ?? true)
@@ -142,7 +81,7 @@ export async function runOrchestrator(cliArgs, cwd) {
142
81
  const absFile = normalizePath(path.isAbsolute(file) ? file : path.join(cwd, file));
143
82
  if (changedAbs && !changedAbs.has(absFile))
144
83
  continue;
145
- const testsAbs = testMap?.get(normalizePath(absFile));
84
+ const testsAbs = testMap.get(normalizePath(absFile));
146
85
  if (testsAbs) {
147
86
  for (const t of testsAbs)
148
87
  allTestFiles.add(t);
@@ -153,235 +92,44 @@ export async function runOrchestrator(cliArgs, cwd) {
153
92
  log.info('No tests found for targets. Exiting.');
154
93
  return;
155
94
  }
156
- // Run baseline tests first (with coverage if needed for filtering)
157
- log.info(`Running ${baselineTests.length} baseline tests${enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
95
+ // 5. Run baseline tests (with coverage if needed for filtering)
96
+ log.info(`Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
158
97
  const baselineOk = await adapter.runBaseline(baselineTests, {
159
- collectCoverage: enableCoverageForBaseline ?? false,
160
- perTestCoverage: wantsPerTestCoverage ?? false,
98
+ collectCoverage: coverage.enableCoverageForBaseline,
99
+ perTestCoverage: coverage.wantsPerTestCoverage,
161
100
  });
162
101
  if (!baselineOk) {
163
102
  process.exitCode = 1;
164
103
  return;
165
104
  }
166
105
  log.info('\u2713 Baseline tests complete');
167
- // Load coverage from baseline if we generated it
168
- if (needsCoverageFromBaseline) {
169
- const defaultCoveragePath = path.join(cwd, 'coverage', 'coverage-final.json');
170
- log.info(`Loading coverage data from ${defaultCoveragePath}...`);
171
- try {
172
- coverageData = await loadCoverageData(defaultCoveragePath, cwd);
173
- log.info(`Loaded coverage for ${coverageData.coveredLines.size} files`);
174
- }
175
- catch (err) {
176
- const msg = err instanceof Error ? err.message : String(err);
177
- log.warn(`Warning: Could not load coverage data: ${msg}`);
178
- log.warn('Continuing without coverage filtering.');
179
- }
180
- }
181
- // Load per-test coverage if requested
182
- if (wantsPerTestCoverage) {
183
- const reportsDir = path.join(cwd, 'coverage');
184
- log.info('Loading per-test coverage data...');
185
- perTestCoverage = await loadPerTestCoverageData(reportsDir, cwd);
186
- if (!perTestCoverage) {
187
- log.warn('Per-test coverage data not found. Continuing without per-test test pruning.');
188
- }
189
- else {
190
- log.info(`Loaded per-test coverage for ${perTestCoverage.size} tests`);
191
- }
192
- }
193
- // Enumerate variants for targets in parallel. Keep order deterministic by mapping then flattening.
194
- const enumerated = await Promise.all(targets.map(async (target) => {
195
- const file = getTargetFile(target);
196
- const absFile = normalizePath(path.isAbsolute(file) ? file : path.join(cwd, file));
197
- if (changedAbs && !changedAbs.has(absFile))
198
- return [];
199
- log.debug('Target file: ' + absFile);
200
- const files = await enumerateVariantsForTarget(cwd, target, cfg.include, cfg.exclude, cfg.maxMutantsPerFile);
201
- const testsAbs = testMap?.get(normalizePath(absFile));
202
- const tests = testsAbs ? Array.from(testsAbs) : [];
203
- log.debug(` found ${files.length} variants, linked to ${tests.length} tests`);
204
- // Filter by coverage if enabled
205
- let filtered = files;
206
- if (coverageData) {
207
- filtered = files.filter((v) => isLineCovered(coverageData, absFile, v.line));
208
- if (filtered.length !== files.length) {
209
- log.debug(` filtered ${files.length} -> ${filtered.length} variants by coverage`);
210
- }
211
- }
212
- return filtered.map((v) => ({ ...v, tests }));
213
- }));
214
- for (const list of enumerated)
215
- variants.push(...list);
106
+ // 6. Load coverage from baseline if we generated it
107
+ const updatedCoverage = await loadCoverageAfterBaseline(coverage, cwd);
108
+ // 7. Enumerate mutation variants
109
+ const variants = await enumerateAllVariants({
110
+ cwd,
111
+ targets,
112
+ testMap,
113
+ changedFiles: changedAbs,
114
+ coverageData: updatedCoverage.coverageData,
115
+ config: cfg,
116
+ });
216
117
  if (!variants.length) {
217
- const msg = coverageData
118
+ const msg = updatedCoverage.coverageData
218
119
  ? 'No mutants to test (all mutations are on uncovered lines). Exiting.'
219
120
  : 'No mutants to test. Exiting.';
220
121
  log.info(msg);
221
122
  return;
222
123
  }
223
- const progress = new Progress(variants.length, {
224
- mode: opts.progressMode === 'bar' ? 'bar' : 'list',
225
- stream: 'stderr',
226
- });
227
- // Track mutation testing duration
228
- const mutationStartTime = Date.now();
229
- // Precompute task metadata for faster worker loops (sort tests, compute keys once)
230
- const tasks = variants.map((v) => {
231
- let tests = Array.from(v.tests);
232
- if (perTestCoverage && tests.length) {
233
- const before = tests.length;
234
- tests = filterTestsByCoverage(perTestCoverage, tests, v.file, v.line);
235
- if (tests.length !== before) {
236
- log.debug(`Pruned tests ${before} -> ${tests.length} for mutant ${v.name} via per-test coverage`);
237
- }
238
- }
239
- tests.sort();
240
- const testSig = hash(keyForTests(tests));
241
- const codeSig = hash(v.code);
242
- const key = `${testSig}:${codeSig}`;
243
- return { v, tests, key };
124
+ // 8. Prepare tasks and execute via worker pool
125
+ const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage);
126
+ await executePool({
127
+ tasks,
128
+ adapter,
129
+ cache,
130
+ concurrency: opts.concurrency,
131
+ progressMode: opts.progressMode,
132
+ minKillPercent: opts.minKillPercent,
133
+ cwd,
244
134
  });
245
- const workerCount = Math.max(1, Math.min(opts.concurrency, tasks.length));
246
- // Ensure we only finish once
247
- let finished = false;
248
- const finishOnce = () => {
249
- if (finished)
250
- return;
251
- finished = true;
252
- const durationMs = Date.now() - mutationStartTime;
253
- // Finish progress display first
254
- progress.finish();
255
- // Compute and print a human-friendly summary
256
- const summary = computeSummary(cache);
257
- printSummary(summary, cache, durationMs);
258
- if (opts.minKillPercent !== undefined) {
259
- const killRateString = summary.killRate.toFixed(2);
260
- const thresholdString = opts.minKillPercent.toFixed(2);
261
- if (summary.killRate < opts.minKillPercent) {
262
- const note = summary.evaluated === 0 ? ' No mutants were executed.' : '';
263
- log.error(`Mutation kill rate ${killRateString}% did not meet required ${thresholdString}% threshold.${note}`);
264
- // Set exit code and let caller/CLI decide if it should terminate abruptly
265
- process.exitCode = 1;
266
- }
267
- else {
268
- log.info(`Mutation kill rate ${killRateString}% meets required ${thresholdString}% threshold`);
269
- }
270
- }
271
- };
272
- // Initialize test runner adapter
273
- const workerLogSuffix = workerCount < opts.concurrency ? ` (requested ${opts.concurrency})` : '';
274
- log.info(`Initializing ${adapter.name} worker pool with ${workerCount} workers...${workerLogSuffix}`);
275
- const poolStart = Date.now();
276
- // Ink spinner on stderr while workers start up
277
- let spinnerInstance = null;
278
- if (process.stderr.isTTY) {
279
- spinnerInstance = render(createElement(PoolSpinner, { message: 'Starting pool...' }), { stdout: process.stderr, stderr: process.stderr });
280
- }
281
- try {
282
- await adapter.init(workerCount);
283
- }
284
- finally {
285
- if (spinnerInstance) {
286
- spinnerInstance.unmount();
287
- spinnerInstance = null;
288
- }
289
- }
290
- const poolDurationMs = Date.now() - poolStart;
291
- log.info(`\u2713 Worker pool ready (${poolDurationMs}ms)`);
292
- progress.start();
293
- let nextIdx = 0;
294
- /**
295
- * Process a single mutant task: check cache, run tests if needed, update cache and progress.
296
- * This function is designed to be called by multiple workers concurrently.
297
- */
298
- async function processTask(task) {
299
- const { v, tests, key } = task;
300
- log.debug('Cache ' + JSON.stringify(cache));
301
- // Check if already cached
302
- const cached = cache[key];
303
- if (cached) {
304
- progress.update(cached.status);
305
- return;
306
- }
307
- // Skip if no tests import this file
308
- if (tests.length === 0) {
309
- cache[key] = {
310
- status: 'skipped',
311
- file: v.file,
312
- line: v.line,
313
- col: v.col,
314
- mutator: v.name,
315
- };
316
- progress.update('skipped');
317
- return;
318
- }
319
- log.debug(`Running tests for mutant ${v.name} ${JSON.stringify(tests)}`);
320
- // Run mutant via test runner adapter
321
- const result = await adapter.runMutant({
322
- id: v.id,
323
- name: v.name,
324
- file: v.file,
325
- code: v.code,
326
- line: v.line,
327
- col: v.col,
328
- }, tests);
329
- const status = result.status;
330
- cache[key] = {
331
- status,
332
- file: v.file,
333
- line: v.line,
334
- col: v.col,
335
- mutator: v.name,
336
- };
337
- progress.update(status);
338
- }
339
- /**
340
- * Worker coroutine: process mutant tasks from the queue until exhausted.
341
- * Multiple workers run concurrently, sharing the task queue.
342
- */
343
- async function worker() {
344
- while (true) {
345
- const i = nextIdx++;
346
- if (i >= tasks.length)
347
- break;
348
- await processTask(tasks[i]);
349
- }
350
- }
351
- const workers = [];
352
- // Register signal handlers so Ctrl+C / SIGTERM still triggers cleanup
353
- let signalCleanedUp = false;
354
- const signalHandler = async (signal) => {
355
- if (signalCleanedUp)
356
- return;
357
- signalCleanedUp = true;
358
- log.info(`\nReceived ${signal}, cleaning up...`);
359
- finishOnce();
360
- await adapter.shutdown();
361
- await cleanupMutineerDirs(cwd);
362
- process.exit(1);
363
- };
364
- process.on('SIGINT', () => void signalHandler('SIGINT'));
365
- process.on('SIGTERM', () => void signalHandler('SIGTERM'));
366
- try {
367
- // Spawn worker coroutines
368
- for (let i = 0; i < workerCount; i++)
369
- workers.push(worker());
370
- // Wait for all workers to complete
371
- await Promise.all(workers);
372
- // Persist results to disk
373
- await saveCacheAtomic(cwd, cache);
374
- }
375
- finally {
376
- // Remove signal handlers to avoid double cleanup
377
- process.removeAllListeners('SIGINT');
378
- process.removeAllListeners('SIGTERM');
379
- if (!signalCleanedUp) {
380
- finishOnce();
381
- // Shutdown adapter
382
- await adapter.shutdown();
383
- // Clean up all __mutineer__ temp directories
384
- await cleanupMutineerDirs(cwd);
385
- }
386
- }
387
135
  }
@@ -0,0 +1,17 @@
1
+ import type { MutantCacheEntry } from '../types/mutant.js';
2
+ import type { TestRunnerAdapter } from './types.js';
3
+ import type { MutantTask } from './tasks.js';
4
+ export interface PoolExecutionOptions {
5
+ tasks: MutantTask[];
6
+ adapter: TestRunnerAdapter;
7
+ cache: Record<string, MutantCacheEntry>;
8
+ concurrency: number;
9
+ progressMode: 'bar' | 'list' | 'quiet';
10
+ minKillPercent?: number;
11
+ cwd: string;
12
+ }
13
+ /**
14
+ * Execute all mutant tasks through the worker pool.
15
+ * Handles worker init, progress display, signal handling, and cleanup.
16
+ */
17
+ export declare function executePool(opts: PoolExecutionOptions): Promise<void>;