@mutineerjs/mutineer 0.7.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 (52) 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 +55 -1
  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/runner/__tests__/args.spec.js +32 -0
  10. package/dist/runner/__tests__/cleanup.spec.js +7 -0
  11. package/dist/runner/__tests__/coverage-resolver.spec.js +3 -0
  12. package/dist/runner/__tests__/orchestrator.spec.js +183 -18
  13. package/dist/runner/__tests__/pool-executor.spec.js +47 -0
  14. package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
  15. package/dist/runner/__tests__/ts-checker.spec.js +115 -0
  16. package/dist/runner/args.d.ts +5 -0
  17. package/dist/runner/args.js +10 -0
  18. package/dist/runner/cleanup.js +1 -1
  19. package/dist/runner/orchestrator.js +98 -17
  20. package/dist/runner/pool-executor.d.ts +2 -0
  21. package/dist/runner/pool-executor.js +15 -4
  22. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  23. package/dist/runner/shared/index.d.ts +1 -1
  24. package/dist/runner/shared/index.js +1 -1
  25. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  26. package/dist/runner/shared/mutant-paths.js +24 -0
  27. package/dist/runner/ts-checker-worker.d.ts +5 -0
  28. package/dist/runner/ts-checker-worker.js +66 -0
  29. package/dist/runner/ts-checker.d.ts +36 -0
  30. package/dist/runner/ts-checker.js +210 -0
  31. package/dist/runner/types.d.ts +2 -0
  32. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  33. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  34. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  35. package/dist/runner/vitest/adapter.js +1 -0
  36. package/dist/runner/vitest/plugin.d.ts +3 -0
  37. package/dist/runner/vitest/plugin.js +49 -11
  38. package/dist/runner/vitest/pool.d.ts +4 -1
  39. package/dist/runner/vitest/pool.js +25 -4
  40. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  41. package/dist/runner/vitest/worker-runtime.js +57 -18
  42. package/dist/runner/vitest/worker.mjs +10 -0
  43. package/dist/types/config.d.ts +14 -0
  44. package/dist/types/mutant.d.ts +5 -2
  45. package/dist/utils/CompileErrors.d.ts +7 -0
  46. package/dist/utils/CompileErrors.js +24 -0
  47. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  48. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  49. package/dist/utils/__tests__/summary.spec.js +83 -1
  50. package/dist/utils/summary.d.ts +5 -1
  51. package/dist/utils/summary.js +38 -3
  52. package/package.json +2 -2
@@ -7,22 +7,41 @@
7
7
  *
8
8
  * The worker process sets globalThis.__mutineer_redirect__ before each test run,
9
9
  * and this plugin intercepts module loading to return the mutated code.
10
+ *
11
+ * For schema-eligible variants, the plugin serves a pre-built schema file that
12
+ * embeds all mutations as ternary chains keyed by globalThis.__mutineer_active_id__.
10
13
  */
11
14
  import * as fs from 'node:fs';
12
15
  import * as path from 'node:path';
13
- import { getRedirect } from '../shared/index.js';
16
+ import { getRedirect, getSchemaFilePath } from '../shared/index.js';
14
17
  import { createLogger } from '../../utils/logger.js';
15
18
  const log = createLogger('mutineer:swap');
16
19
  export function poolMutineerPlugin() {
20
+ // Cache schema file contents keyed by normalised source path.
21
+ // null = checked and no schema exists; string = schema code.
22
+ // Schema files are written once before the pool starts and never change,
23
+ // so this cache is always valid for the lifetime of the plugin.
24
+ const schemaCache = new Map();
17
25
  return {
18
26
  name: 'mutineer:swap',
19
27
  enforce: 'pre',
20
- load(id) {
21
- const redirect = getRedirect();
22
- if (!redirect) {
28
+ config(config) {
29
+ const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
30
+ if (!activeIdFile || !path.isAbsolute(activeIdFile))
23
31
  return null;
24
- }
25
- // Normalize the module ID, handling query strings
32
+ const setupFile = path.join(path.dirname(activeIdFile), 'setup.mjs');
33
+ const testConfig = config.test;
34
+ const existing = testConfig?.setupFiles;
35
+ const existingArr = Array.isArray(existing)
36
+ ? existing
37
+ : existing
38
+ ? [existing]
39
+ : [];
40
+ return {
41
+ test: { setupFiles: [...existingArr, setupFile] },
42
+ };
43
+ },
44
+ load(id) {
26
45
  const cleanId = id.split('?')[0];
27
46
  let normalizedId;
28
47
  try {
@@ -31,18 +50,37 @@ export function poolMutineerPlugin() {
31
50
  catch {
32
51
  return null;
33
52
  }
34
- // Check if this is the file we're redirecting
35
- if (normalizedId === path.resolve(redirect.from)) {
36
- // Read the mutated code from the temp file
53
+ // Redirect takes priority: fallback mutations use setRedirect + invalidateFile.
54
+ // Must check redirect first so the schema file (which exists for this source)
55
+ // does not shadow the mutant code during fallback runs.
56
+ const redirect = getRedirect();
57
+ if (redirect && normalizedId === path.resolve(redirect.from)) {
37
58
  try {
38
- const mutatedCode = fs.readFileSync(redirect.to, 'utf8');
39
- return mutatedCode;
59
+ return fs.readFileSync(redirect.to, 'utf8');
40
60
  }
41
61
  catch (err) {
42
62
  log.error(`Failed to read mutant file: ${redirect.to} ${err}`);
43
63
  return null;
44
64
  }
45
65
  }
66
+ // Schema path: serves pre-built schema file for schema-eligible variants.
67
+ // Use cache to avoid existsSync + readFileSync on every module import.
68
+ const cached = schemaCache.get(normalizedId);
69
+ if (cached !== undefined) {
70
+ return cached;
71
+ }
72
+ const schemaPath = getSchemaFilePath(normalizedId);
73
+ try {
74
+ if (fs.existsSync(schemaPath)) {
75
+ const code = fs.readFileSync(schemaPath, 'utf8');
76
+ schemaCache.set(normalizedId, code);
77
+ return code;
78
+ }
79
+ }
80
+ catch {
81
+ // fall through
82
+ }
83
+ schemaCache.set(normalizedId, null);
46
84
  return null;
47
85
  },
48
86
  };
@@ -16,13 +16,14 @@ import type { MutantPayload, MutantRunResult, MutantRunSummary } from '../../typ
16
16
  declare class VitestWorker extends EventEmitter {
17
17
  private readonly cwd;
18
18
  private readonly vitestConfig?;
19
+ private readonly vitestProject?;
19
20
  readonly id: string;
20
21
  private process;
21
22
  private rl;
22
23
  private pendingTask;
23
24
  private ready;
24
25
  private shuttingDown;
25
- constructor(id: string, cwd: string, vitestConfig?: string | undefined);
26
+ constructor(id: string, cwd: string, vitestConfig?: string | undefined, vitestProject?: string | undefined);
26
27
  start(): Promise<void>;
27
28
  private handleMessage;
28
29
  private handleExit;
@@ -36,10 +37,12 @@ export interface VitestPoolOptions {
36
37
  cwd: string;
37
38
  concurrency: number;
38
39
  vitestConfig?: string;
40
+ vitestProject?: string;
39
41
  timeoutMs?: number;
40
42
  createWorker?: (id: string, opts: {
41
43
  cwd: string;
42
44
  vitestConfig?: string;
45
+ vitestProject?: string;
43
46
  }) => VitestWorker;
44
47
  }
45
48
  export declare class VitestPool {
@@ -17,15 +17,17 @@ import * as readline from 'node:readline';
17
17
  import * as fs from 'node:fs';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { EventEmitter } from 'node:events';
20
+ import { getActiveIdFilePath } from '../shared/index.js';
20
21
  import { createLogger, DEBUG } from '../../utils/logger.js';
21
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
23
  const workerLog = createLogger('VitestWorker');
23
24
  const poolLog = createLogger('VitestPool');
24
25
  class VitestWorker extends EventEmitter {
25
- constructor(id, cwd, vitestConfig) {
26
+ constructor(id, cwd, vitestConfig, vitestProject) {
26
27
  super();
27
28
  this.cwd = cwd;
28
29
  this.vitestConfig = vitestConfig;
30
+ this.vitestProject = vitestProject;
29
31
  this.process = null;
30
32
  this.rl = null;
31
33
  this.pendingTask = null;
@@ -54,9 +56,13 @@ class VitestWorker extends EventEmitter {
54
56
  ...process.env,
55
57
  MUTINEER_WORKER_ID: this.id,
56
58
  MUTINEER_CWD: this.cwd,
59
+ MUTINEER_ACTIVE_ID_FILE: getActiveIdFilePath(this.id, this.cwd),
57
60
  ...(this.vitestConfig
58
61
  ? { MUTINEER_VITEST_CONFIG: this.vitestConfig }
59
62
  : {}),
63
+ ...(this.vitestProject
64
+ ? { MUTINEER_VITEST_PROJECT: this.vitestProject }
65
+ : {}),
60
66
  ...(DEBUG ? { MUTINEER_DEBUG: '1' } : {}),
61
67
  };
62
68
  workerLog.debug(`[${this.id}] Starting worker process`);
@@ -71,6 +77,10 @@ class VitestWorker extends EventEmitter {
71
77
  cwd: this.cwd,
72
78
  env,
73
79
  stdio: ['pipe', 'pipe', 'pipe'],
80
+ // Create own process group so killing this process also kills its
81
+ // children (Vitest inner forks). Without this, SIGKILL to worker.mjs
82
+ // orphans the Vitest fork workers.
83
+ detached: true,
74
84
  });
75
85
  // Handle stderr (debug/error output)
76
86
  this.process.stderr?.on('data', (data) => {
@@ -205,8 +215,16 @@ class VitestWorker extends EventEmitter {
205
215
  }
206
216
  kill() {
207
217
  if (this.process) {
218
+ const pid = this.process.pid;
208
219
  try {
209
- this.process.kill('SIGKILL');
220
+ if (pid !== undefined) {
221
+ // Kill the entire process group (negative PID) so Vitest inner fork
222
+ // workers die alongside worker.mjs instead of becoming orphans.
223
+ process.kill(-pid, 'SIGKILL');
224
+ }
225
+ else {
226
+ this.process.kill('SIGKILL');
227
+ }
210
228
  }
211
229
  catch {
212
230
  // Ignore
@@ -227,6 +245,7 @@ export class VitestPool {
227
245
  cwd: options.cwd,
228
246
  concurrency: options.concurrency,
229
247
  vitestConfig: options.vitestConfig,
248
+ vitestProject: options.vitestProject,
230
249
  timeoutMs: options.timeoutMs ?? 10_000,
231
250
  createWorker: options.createWorker,
232
251
  };
@@ -240,8 +259,9 @@ export class VitestPool {
240
259
  const worker = this.options.createWorker?.(`w${i}`, {
241
260
  cwd: this.options.cwd,
242
261
  vitestConfig: this.options.vitestConfig,
262
+ vitestProject: this.options.vitestProject,
243
263
  }) ??
244
- new VitestWorker(`w${i}`, this.options.cwd, this.options.vitestConfig);
264
+ new VitestWorker(`w${i}`, this.options.cwd, this.options.vitestConfig, this.options.vitestProject);
245
265
  worker.on('exit', () => {
246
266
  if (!this.shuttingDown) {
247
267
  this.handleWorkerExit(worker);
@@ -268,8 +288,9 @@ export class VitestPool {
268
288
  const newWorker = this.options.createWorker?.(worker.id, {
269
289
  cwd: this.options.cwd,
270
290
  vitestConfig: this.options.vitestConfig,
291
+ vitestProject: this.options.vitestProject,
271
292
  }) ??
272
- new VitestWorker(worker.id, this.options.cwd, this.options.vitestConfig);
293
+ new VitestWorker(worker.id, this.options.cwd, this.options.vitestConfig, this.options.vitestProject);
273
294
  const idx = this.workers.indexOf(worker);
274
295
  if (idx >= 0) {
275
296
  this.workers[idx] = newWorker;
@@ -4,6 +4,7 @@ export interface VitestWorkerRuntimeOptions {
4
4
  workerId: string;
5
5
  cwd: string;
6
6
  vitestConfigPath?: string;
7
+ vitestProject?: string;
7
8
  }
8
9
  export declare class VitestWorkerRuntime {
9
10
  private readonly options;
@@ -5,6 +5,14 @@ import { poolMutineerPlugin } from './plugin.js';
5
5
  import { getMutantFilePath, setRedirect, clearRedirect, } from '../shared/index.js';
6
6
  import { createLogger } from '../../utils/logger.js';
7
7
  const log = createLogger('vitest-runtime');
8
+ const SETUP_MJS_CONTENT = `import { beforeAll } from 'vitest'
9
+ import { readFileSync } from 'node:fs'
10
+ const _f = process.env.MUTINEER_ACTIVE_ID_FILE
11
+ beforeAll(() => {
12
+ try { globalThis.__mutineer_active_id__ = readFileSync(_f, 'utf8').trim() || null }
13
+ catch { globalThis.__mutineer_active_id__ = null }
14
+ })
15
+ `;
8
16
  export class VitestWorkerRuntime {
9
17
  constructor(options) {
10
18
  this.options = options;
@@ -12,12 +20,24 @@ export class VitestWorkerRuntime {
12
20
  }
13
21
  async init() {
14
22
  try {
23
+ // Write setup.mjs before creating Vitest so the config hook can find it
24
+ const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
25
+ if (activeIdFile && path.isAbsolute(activeIdFile)) {
26
+ const mutineerDir = path.dirname(activeIdFile);
27
+ fs.mkdirSync(mutineerDir, { recursive: true });
28
+ fs.writeFileSync(path.join(mutineerDir, 'setup.mjs'), SETUP_MJS_CONTENT, 'utf8');
29
+ }
15
30
  this.vitest = await createVitest('test', {
16
- watch: true,
31
+ watch: false,
17
32
  reporters: ['dot'],
18
33
  silent: true,
19
34
  pool: 'forks',
20
35
  bail: 1,
36
+ // Limit to 1 inner fork so bail:1 stops after the first failure
37
+ // without spawning additional fork processes. The single fork is
38
+ // persistent (reused across mutant runs), eliminating per-mutant
39
+ // fork startup overhead.
40
+ maxWorkers: 1,
21
41
  ...(this.options.vitestConfigPath
22
42
  ? { config: this.options.vitestConfigPath }
23
43
  : {}),
@@ -43,20 +63,29 @@ export class VitestWorkerRuntime {
43
63
  throw new Error('Vitest runtime not initialised');
44
64
  }
45
65
  const start = Date.now();
66
+ const activeIdFile = process.env.MUTINEER_ACTIVE_ID_FILE;
67
+ const useSchema = !mutant.isFallback && !!activeIdFile;
46
68
  try {
47
- const mutantPath = getMutantFilePath(mutant.file, mutant.id);
48
- fs.writeFileSync(mutantPath, mutant.code, 'utf8');
49
- log.debug(`Wrote mutant to ${mutantPath}`);
50
- setRedirect({
51
- from: path.resolve(mutant.file),
52
- to: mutantPath,
53
- });
54
- this.vitest.invalidateFile(mutant.file);
55
- log.debug(`Invalidated ${mutant.file}`);
69
+ if (useSchema) {
70
+ fs.writeFileSync(activeIdFile, mutant.id, 'utf8');
71
+ log.debug(`Schema path: wrote active ID ${mutant.id}`);
72
+ }
73
+ else {
74
+ const mutantPath = getMutantFilePath(mutant.file, mutant.id);
75
+ fs.writeFileSync(mutantPath, mutant.code, 'utf8');
76
+ log.debug(`Wrote mutant to ${mutantPath}`);
77
+ setRedirect({
78
+ from: path.resolve(mutant.file),
79
+ to: mutantPath,
80
+ });
81
+ this.vitest.invalidateFile(mutant.file);
82
+ log.debug(`Invalidated ${mutant.file}`);
83
+ }
56
84
  const specs = [];
85
+ const projectName = this.options.vitestProject ?? '';
57
86
  for (const testFile of tests) {
58
87
  const spec = this.vitest
59
- .getProjectByName('')
88
+ .getProjectByName(projectName)
60
89
  ?.createSpecification(testFile);
61
90
  if (spec)
62
91
  specs.push(spec);
@@ -68,6 +97,7 @@ export class VitestWorkerRuntime {
68
97
  };
69
98
  }
70
99
  log.debug(`Running ${specs.length} test specs`);
100
+ this.vitest.state?.filesMap?.clear();
71
101
  const results = await this.vitest.runTestSpecifications(specs);
72
102
  const requestedModules = new Set(specs.map((s) => s.moduleId));
73
103
  const relevantModules = results.testModules.filter((mod) => requestedModules.has(mod.moduleId));
@@ -88,14 +118,23 @@ export class VitestWorkerRuntime {
88
118
  };
89
119
  }
90
120
  finally {
91
- // Clear redirect and clean up temp file
92
- const mutantPath = getMutantFilePath(mutant.file, mutant.id);
93
- clearRedirect();
94
- try {
95
- fs.rmSync(mutantPath, { force: true });
121
+ if (useSchema) {
122
+ try {
123
+ fs.writeFileSync(activeIdFile, '', 'utf8');
124
+ }
125
+ catch {
126
+ // ignore
127
+ }
96
128
  }
97
- catch {
98
- // ignore
129
+ else {
130
+ clearRedirect();
131
+ const mutantPath = getMutantFilePath(mutant.file, mutant.id);
132
+ try {
133
+ fs.rmSync(mutantPath, { force: true });
134
+ }
135
+ catch {
136
+ // ignore
137
+ }
99
138
  }
100
139
  }
101
140
  }
@@ -26,11 +26,13 @@ async function main() {
26
26
  const workerId = process.env.MUTINEER_WORKER_ID ?? 'unknown';
27
27
  const cwd = process.env.MUTINEER_CWD ?? process.cwd();
28
28
  const vitestConfigPath = process.env.MUTINEER_VITEST_CONFIG;
29
+ const vitestProject = process.env.MUTINEER_VITEST_PROJECT;
29
30
  log.debug(`Starting worker ${workerId} in ${cwd}`);
30
31
  const runtime = createVitestWorkerRuntime({
31
32
  workerId,
32
33
  cwd,
33
34
  vitestConfigPath,
35
+ vitestProject,
34
36
  });
35
37
  try {
36
38
  await runtime.init();
@@ -41,6 +43,14 @@ async function main() {
41
43
  }
42
44
  // Signal ready
43
45
  send({ type: 'ready', workerId });
46
+ // Graceful SIGTERM handler: clean up Vitest inner forks before exiting.
47
+ // This runs when the parent kills the process group with SIGTERM (e.g.
48
+ // future graceful shutdown path). Vitest forks are in the same process
49
+ // group so they also receive the signal, but calling close() ensures the
50
+ // Vitest instance is torn down cleanly.
51
+ process.on('SIGTERM', () => {
52
+ void runtime.shutdown().finally(() => process.exit(0));
53
+ });
44
54
  // Process requests from stdin
45
55
  const rl = readline.createInterface({
46
56
  input: process.stdin,
@@ -42,4 +42,18 @@ export interface MutineerConfig {
42
42
  readonly timeout?: number;
43
43
  /** Output report format: 'text' (default) or 'json' (writes mutineer-report.json) */
44
44
  readonly report?: 'text' | 'json';
45
+ /**
46
+ * Enable TypeScript type checking to pre-filter mutants that produce compile errors.
47
+ * true = enable (requires tsconfig.json), false = disable,
48
+ * object = enable with optional custom tsconfig path.
49
+ * Defaults to auto-detect (enabled if tsconfig.json found in cwd).
50
+ */
51
+ readonly typescript?: boolean | {
52
+ readonly tsconfig?: string;
53
+ };
54
+ /**
55
+ * Filter mutations to a specific Vitest workspace project.
56
+ * Requires a vitest.config.ts with test.projects configured.
57
+ */
58
+ readonly vitestProject?: string | readonly string[];
45
59
  }
@@ -4,7 +4,7 @@
4
4
  * Centralises the shapes used across the runner and adapters so we
5
5
  * don't duplicate unions or object shapes in multiple modules.
6
6
  */
7
- export type MutantStatus = 'killed' | 'escaped' | 'skipped' | 'error' | 'timeout';
7
+ export type MutantStatus = 'killed' | 'escaped' | 'skipped' | 'error' | 'timeout' | 'compile-error';
8
8
  export type MutantRunStatus = MutantStatus;
9
9
  export interface MutantLocation {
10
10
  readonly file: string;
@@ -17,7 +17,10 @@ export interface MutantDescriptor extends MutantLocation {
17
17
  readonly code: string;
18
18
  }
19
19
  /** Payload passed to workers/pools for execution. */
20
- export type MutantPayload = MutantDescriptor;
20
+ export interface MutantPayload extends MutantDescriptor {
21
+ /** When true, this mutant must use the legacy redirect path instead of the schema path. */
22
+ readonly isFallback?: boolean;
23
+ }
21
24
  /** Variant with attached test files. */
22
25
  export interface Variant extends MutantDescriptor {
23
26
  readonly tests: readonly string[];
@@ -0,0 +1,7 @@
1
+ import type { MutantCacheEntry } from '../types/mutant.js';
2
+ interface Props {
3
+ entries: MutantCacheEntry[];
4
+ cwd: string;
5
+ }
6
+ export declare function CompileErrors({ entries, cwd }: Props): import("react/jsx-runtime").JSX.Element;
7
+ export {};
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import path from 'node:path';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import { useState, useEffect } from 'react';
5
+ export function CompileErrors({ entries, cwd }) {
6
+ const { exit } = useApp();
7
+ const [expanded, setExpanded] = useState(false);
8
+ useEffect(() => {
9
+ if (expanded)
10
+ exit();
11
+ }, [expanded, exit]);
12
+ useInput((input, key) => {
13
+ if (input === 'e') {
14
+ setExpanded(true);
15
+ }
16
+ else if (key.return || input === 'q') {
17
+ exit();
18
+ }
19
+ });
20
+ if (expanded) {
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Compile Error Mutants (type-filtered):" }), entries.map((entry, i) => (_jsxs(Text, { dimColor: true, children: [' \u2022 ', path.relative(cwd, entry.file), "@", entry.line, ",", entry.col, ' ', entry.mutator] }, i)))] }));
22
+ }
23
+ return (_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Compile Error Mutants (type-filtered): ", entries.length] }), _jsx(Text, { dimColor: true, children: "e expand return skip" })] }));
24
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ const mockExit = vi.fn();
3
+ const mockSetExpanded = vi.fn();
4
+ let inputHandler;
5
+ let effectCallback;
6
+ vi.mock('ink', () => ({
7
+ Box: ({ children }) => children,
8
+ Text: ({ children }) => children,
9
+ useInput: vi.fn((fn) => {
10
+ inputHandler = fn;
11
+ }),
12
+ useApp: () => ({ exit: mockExit }),
13
+ }));
14
+ vi.mock('react', async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return {
17
+ ...actual,
18
+ useState: vi.fn((init) => [init, mockSetExpanded]),
19
+ useEffect: vi.fn((fn) => {
20
+ effectCallback = fn;
21
+ }),
22
+ };
23
+ });
24
+ import { CompileErrors } from '../CompileErrors.js';
25
+ import { useState } from 'react';
26
+ const entries = [
27
+ {
28
+ status: 'compile-error',
29
+ file: '/cwd/src/foo.ts',
30
+ line: 10,
31
+ col: 5,
32
+ mutator: 'returnToNull',
33
+ },
34
+ {
35
+ status: 'compile-error',
36
+ file: '/cwd/src/bar.ts',
37
+ line: 20,
38
+ col: 3,
39
+ mutator: 'returnFlipBool',
40
+ },
41
+ ];
42
+ describe('CompileErrors', () => {
43
+ beforeEach(() => {
44
+ mockExit.mockClear();
45
+ mockSetExpanded.mockClear();
46
+ inputHandler = undefined;
47
+ effectCallback = undefined;
48
+ vi.mocked(useState).mockImplementation(((init) => [
49
+ init,
50
+ mockSetExpanded,
51
+ ]));
52
+ });
53
+ it('registers a useInput handler on render', () => {
54
+ CompileErrors({ entries, cwd: '/cwd' });
55
+ expect(inputHandler).toBeDefined();
56
+ });
57
+ it('calls setExpanded(true) when "e" is pressed', () => {
58
+ CompileErrors({ entries, cwd: '/cwd' });
59
+ inputHandler('e', { return: false });
60
+ expect(mockSetExpanded).toHaveBeenCalledWith(true);
61
+ });
62
+ it('calls exit() when return is pressed', () => {
63
+ CompileErrors({ entries, cwd: '/cwd' });
64
+ inputHandler('', { return: true });
65
+ expect(mockExit).toHaveBeenCalled();
66
+ });
67
+ it('calls exit() when "q" is pressed', () => {
68
+ CompileErrors({ entries, cwd: '/cwd' });
69
+ inputHandler('q', { return: false });
70
+ expect(mockExit).toHaveBeenCalled();
71
+ });
72
+ it('does not call exit() or setExpanded for other keys', () => {
73
+ CompileErrors({ entries, cwd: '/cwd' });
74
+ inputHandler('x', { return: false });
75
+ expect(mockExit).not.toHaveBeenCalled();
76
+ expect(mockSetExpanded).not.toHaveBeenCalled();
77
+ });
78
+ it('registers a useEffect handler on render', () => {
79
+ CompileErrors({ entries, cwd: '/cwd' });
80
+ expect(effectCallback).toBeDefined();
81
+ });
82
+ it('useEffect calls exit() when expanded is true', () => {
83
+ vi.mocked(useState).mockReturnValueOnce([
84
+ true,
85
+ mockSetExpanded,
86
+ ]);
87
+ CompileErrors({ entries, cwd: '/cwd' });
88
+ effectCallback();
89
+ expect(mockExit).toHaveBeenCalled();
90
+ });
91
+ it('useEffect does not call exit() when expanded is false', () => {
92
+ CompileErrors({ entries, cwd: '/cwd' });
93
+ effectCallback();
94
+ expect(mockExit).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -18,13 +18,16 @@ describe('summary', () => {
18
18
  a: makeEntry({ status: 'killed' }),
19
19
  b: makeEntry({ status: 'escaped' }),
20
20
  c: makeEntry({ status: 'skipped' }),
21
+ d: makeEntry({ status: 'timeout' }),
21
22
  };
22
23
  const s = computeSummary(cache);
23
24
  expect(s).toEqual({
24
- total: 3,
25
+ total: 4,
25
26
  killed: 1,
26
27
  escaped: 1,
27
28
  skipped: 1,
29
+ timeouts: 1,
30
+ compileErrors: 0,
28
31
  evaluated: 2,
29
32
  killRate: 50,
30
33
  });
@@ -43,6 +46,40 @@ describe('summary', () => {
43
46
  expect(lines.some((l) => l.includes('Duration: 1.50s'))).toBe(true);
44
47
  logSpy.mockRestore();
45
48
  });
49
+ it('prints Timed Out Mutants section when timeouts exist', () => {
50
+ const cache = {
51
+ a: makeEntry({ status: 'timeout', file: '/tmp/a.ts', mutator: 'flip' }),
52
+ };
53
+ const summary = computeSummary(cache);
54
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
55
+ printSummary(summary, cache);
56
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
57
+ expect(lines.some((l) => l.includes('Timed Out Mutants'))).toBe(true);
58
+ logSpy.mockRestore();
59
+ });
60
+ it('shows Timeouts count in stat line when timeouts > 0', () => {
61
+ const cache = {
62
+ a: makeEntry({ status: 'timeout', file: '/tmp/a.ts' }),
63
+ b: makeEntry({ status: 'killed', file: '/tmp/b.ts' }),
64
+ };
65
+ const summary = computeSummary(cache);
66
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
67
+ printSummary(summary, cache);
68
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
69
+ expect(lines.some((l) => l.includes('Timeouts: 1'))).toBe(true);
70
+ logSpy.mockRestore();
71
+ });
72
+ it('shows Timeouts: 0 in stat line when timeouts is zero', () => {
73
+ const cache = {
74
+ a: makeEntry({ status: 'killed', file: '/tmp/a.ts' }),
75
+ };
76
+ const summary = computeSummary(cache);
77
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
78
+ printSummary(summary, cache);
79
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
80
+ expect(lines.some((l) => l.includes('Timeouts: 0'))).toBe(true);
81
+ logSpy.mockRestore();
82
+ });
46
83
  it('prints diff lines for escaped mutant with snippets', () => {
47
84
  const cache = {
48
85
  a: makeEntry({
@@ -139,6 +176,51 @@ describe('summary', () => {
139
176
  expect('originalSnippet' in report.mutants[0]).toBe(false);
140
177
  expect('coveringTests' in report.mutants[0]).toBe(false);
141
178
  });
179
+ it('prints compile error mutants section by default', () => {
180
+ const cache = {
181
+ a: makeEntry({
182
+ status: 'compile-error',
183
+ file: '/tmp/a.ts',
184
+ mutator: 'returnToNull',
185
+ }),
186
+ };
187
+ const summary = computeSummary(cache);
188
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
189
+ printSummary(summary, cache);
190
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
191
+ expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(true);
192
+ logSpy.mockRestore();
193
+ });
194
+ it('skips compile error section when skipCompileErrors is true', () => {
195
+ const cache = {
196
+ a: makeEntry({
197
+ status: 'compile-error',
198
+ file: '/tmp/a.ts',
199
+ mutator: 'returnToNull',
200
+ }),
201
+ };
202
+ const summary = computeSummary(cache);
203
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
204
+ printSummary(summary, cache, undefined, { skipCompileErrors: true });
205
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
206
+ expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(false);
207
+ logSpy.mockRestore();
208
+ });
209
+ it('shows compile error section when skipCompileErrors is false', () => {
210
+ const cache = {
211
+ a: makeEntry({
212
+ status: 'compile-error',
213
+ file: '/tmp/a.ts',
214
+ mutator: 'returnToNull',
215
+ }),
216
+ };
217
+ const summary = computeSummary(cache);
218
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
219
+ printSummary(summary, cache, undefined, { skipCompileErrors: false });
220
+ const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
221
+ expect(lines.some((l) => l.includes('Compile Error Mutants'))).toBe(true);
222
+ logSpy.mockRestore();
223
+ });
142
224
  it('summarise returns summary and prints', () => {
143
225
  const cache = { a: makeEntry({ status: 'killed' }) };
144
226
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });