@mutineerjs/mutineer 0.2.3 → 0.3.2

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 (162) hide show
  1. package/README.md +48 -42
  2. package/dist/bin/mutineer.js +0 -0
  3. package/dist/core/__tests__/module.spec.js +66 -3
  4. package/dist/core/__tests__/sfc.spec.js +76 -0
  5. package/dist/core/__tests__/variant-utils.spec.js +93 -0
  6. package/dist/mutators/__tests__/operator.spec.js +169 -0
  7. package/dist/mutators/__tests__/registry.spec.js +6 -0
  8. package/dist/mutators/__tests__/return-value.spec.js +239 -0
  9. package/dist/mutators/__tests__/utils.spec.js +68 -1
  10. package/dist/mutators/operator.d.ts +25 -0
  11. package/dist/mutators/operator.js +50 -0
  12. package/dist/mutators/registry.d.ts +6 -28
  13. package/dist/mutators/registry.js +14 -66
  14. package/dist/mutators/return-value.d.ts +39 -0
  15. package/dist/mutators/return-value.js +104 -0
  16. package/dist/mutators/utils.d.ts +21 -0
  17. package/dist/mutators/utils.js +44 -27
  18. package/dist/runner/__tests__/args.spec.js +225 -0
  19. package/dist/runner/__tests__/cache.spec.js +180 -0
  20. package/dist/runner/__tests__/changed.spec.js +227 -0
  21. package/dist/runner/__tests__/cleanup.spec.js +41 -0
  22. package/dist/runner/__tests__/config.spec.js +71 -0
  23. package/dist/runner/__tests__/coverage-resolver.spec.js +171 -0
  24. package/dist/runner/__tests__/pool-executor.spec.js +211 -0
  25. package/dist/runner/__tests__/tasks.spec.js +95 -0
  26. package/dist/runner/__tests__/variants.spec.js +261 -0
  27. package/dist/runner/args.d.ts +5 -0
  28. package/dist/runner/args.js +7 -0
  29. package/dist/runner/config.js +2 -2
  30. package/dist/runner/coverage-resolver.d.ts +21 -0
  31. package/dist/runner/coverage-resolver.js +96 -0
  32. package/dist/runner/discover.js +2 -1
  33. package/dist/runner/jest/__tests__/adapter.spec.js +1 -1
  34. package/dist/runner/jest/__tests__/pool.spec.d.ts +1 -0
  35. package/dist/runner/jest/__tests__/pool.spec.js +211 -0
  36. package/dist/runner/jest/__tests__/worker-runtime.spec.d.ts +1 -0
  37. package/dist/runner/jest/__tests__/worker-runtime.spec.js +148 -0
  38. package/dist/runner/jest/adapter.js +1 -1
  39. package/dist/runner/jest/pool.d.ts +1 -1
  40. package/dist/runner/jest/pool.js +6 -6
  41. package/dist/runner/jest/worker.mjs +1 -1
  42. package/dist/runner/orchestrator.js +43 -295
  43. package/dist/runner/pool-executor.d.ts +17 -0
  44. package/dist/runner/pool-executor.js +143 -0
  45. package/dist/runner/shared/__tests__/mutant-paths.spec.d.ts +1 -0
  46. package/dist/runner/shared/__tests__/mutant-paths.spec.js +66 -0
  47. package/dist/runner/shared/__tests__/redirect-state.spec.d.ts +1 -0
  48. package/dist/runner/shared/__tests__/redirect-state.spec.js +56 -0
  49. package/dist/runner/shared/index.d.ts +1 -1
  50. package/dist/runner/shared/index.js +1 -1
  51. package/dist/runner/shared/redirect-state.d.ts +2 -2
  52. package/dist/runner/shared/redirect-state.js +4 -4
  53. package/dist/runner/tasks.d.ts +12 -0
  54. package/dist/runner/tasks.js +25 -0
  55. package/dist/runner/types.d.ts +1 -1
  56. package/dist/runner/variants.d.ts +17 -2
  57. package/dist/runner/variants.js +33 -0
  58. package/dist/runner/vitest/__tests__/adapter.spec.js +1 -1
  59. package/dist/runner/vitest/__tests__/pool.spec.js +1 -1
  60. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +87 -1
  61. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +84 -0
  62. package/dist/runner/vitest/adapter.js +1 -1
  63. package/dist/runner/vitest/index.d.ts +0 -1
  64. package/dist/runner/vitest/index.js +0 -1
  65. package/dist/runner/vitest/pool.d.ts +1 -1
  66. package/dist/runner/vitest/pool.js +7 -7
  67. package/dist/runner/vitest/redirect-loader.d.ts +1 -1
  68. package/dist/runner/vitest/redirect-loader.js +1 -1
  69. package/dist/runner/vitest/worker-runtime.js +3 -3
  70. package/dist/runner/vitest/worker.mjs +1 -1
  71. package/dist/utils/__tests__/coverage.spec.js +167 -0
  72. package/dist/utils/__tests__/logger.spec.d.ts +1 -0
  73. package/dist/utils/__tests__/logger.spec.js +61 -0
  74. package/dist/utils/__tests__/normalizePath.spec.d.ts +1 -0
  75. package/dist/utils/__tests__/normalizePath.spec.js +22 -0
  76. package/dist/utils/__tests__/progress.spec.js +96 -0
  77. package/package.json +71 -22
  78. package/dist/admin/assets/index-B7nXq-e7.js +0 -32
  79. package/dist/admin/assets/index-B7nXq-e7.js.map +0 -1
  80. package/dist/admin/assets/index-BDQLkBUE.js +0 -32
  81. package/dist/admin/assets/index-BDQLkBUE.js.map +0 -1
  82. package/dist/admin/assets/index-DVkP-Tc7.css +0 -1
  83. package/dist/admin/index.html +0 -13
  84. package/dist/admin/server/admin.d.ts +0 -6
  85. package/dist/admin/server/admin.js +0 -234
  86. package/dist/bin/mutate-vitest.d.ts +0 -2
  87. package/dist/bin/mutate-vitest.js +0 -90
  88. package/dist/plugin/viteMutate.d.ts +0 -15
  89. package/dist/plugin/viteMutate.js +0 -52
  90. package/dist/plugin/vitest.setup.d.ts +0 -47
  91. package/dist/plugin/vitest.setup.js +0 -118
  92. package/dist/plugin/withVitest.d.ts +0 -13
  93. package/dist/plugin/withVitest.js +0 -30
  94. package/dist/runner/__tests__/orchestrator.spec.js +0 -55
  95. package/dist/runner/adapters/__tests__/jest.spec.js +0 -88
  96. package/dist/runner/adapters/__tests__/vitest-worker-runtime.spec.js +0 -59
  97. package/dist/runner/adapters/__tests__/vitest.spec.js +0 -118
  98. package/dist/runner/adapters/index.d.ts +0 -10
  99. package/dist/runner/adapters/index.js +0 -9
  100. package/dist/runner/adapters/jest/__tests__/index.spec.js +0 -88
  101. package/dist/runner/adapters/jest/index.d.ts +0 -24
  102. package/dist/runner/adapters/jest/index.js +0 -216
  103. package/dist/runner/adapters/jest/worker-runtime.d.ts +0 -37
  104. package/dist/runner/adapters/jest/worker-runtime.js +0 -171
  105. package/dist/runner/adapters/jest-worker-runtime.d.ts +0 -37
  106. package/dist/runner/adapters/jest-worker-runtime.js +0 -171
  107. package/dist/runner/adapters/jest.d.ts +0 -24
  108. package/dist/runner/adapters/jest.js +0 -216
  109. package/dist/runner/adapters/types.d.ts +0 -89
  110. package/dist/runner/adapters/types.js +0 -8
  111. package/dist/runner/adapters/vitest/__tests__/index.spec.js +0 -118
  112. package/dist/runner/adapters/vitest/__tests__/worker-runtime.spec.js +0 -59
  113. package/dist/runner/adapters/vitest/index.d.ts +0 -33
  114. package/dist/runner/adapters/vitest/index.js +0 -267
  115. package/dist/runner/adapters/vitest/worker-runtime.d.ts +0 -25
  116. package/dist/runner/adapters/vitest/worker-runtime.js +0 -118
  117. package/dist/runner/adapters/vitest-worker-runtime.d.ts +0 -25
  118. package/dist/runner/adapters/vitest-worker-runtime.js +0 -118
  119. package/dist/runner/adapters/vitest.d.ts +0 -33
  120. package/dist/runner/adapters/vitest.js +0 -267
  121. package/dist/runner/pool/__tests__/index.spec.js +0 -83
  122. package/dist/runner/pool/__tests__/pool-plugin.spec.js +0 -59
  123. package/dist/runner/pool/__tests__/pool-redirect-loader.spec.js +0 -78
  124. package/dist/runner/pool/index.d.ts +0 -8
  125. package/dist/runner/pool/index.js +0 -9
  126. package/dist/runner/pool/jest/pool.d.ts +0 -52
  127. package/dist/runner/pool/jest/pool.js +0 -309
  128. package/dist/runner/pool/jest/worker.mjs +0 -60
  129. package/dist/runner/pool/jest-pool.d.ts +0 -52
  130. package/dist/runner/pool/jest-pool.js +0 -309
  131. package/dist/runner/pool/jest-worker.mjs +0 -60
  132. package/dist/runner/pool/plugin.d.ts +0 -18
  133. package/dist/runner/pool/plugin.js +0 -60
  134. package/dist/runner/pool/pool-plugin.d.ts +0 -18
  135. package/dist/runner/pool/pool-plugin.js +0 -60
  136. package/dist/runner/pool/pool-redirect-loader.d.ts +0 -19
  137. package/dist/runner/pool/pool-redirect-loader.js +0 -116
  138. package/dist/runner/pool/pool-redirect-loader.mjs +0 -146
  139. package/dist/runner/pool/redirect-loader.d.ts +0 -19
  140. package/dist/runner/pool/redirect-loader.js +0 -116
  141. package/dist/runner/pool/vitest/pool.d.ts +0 -70
  142. package/dist/runner/pool/vitest/pool.js +0 -376
  143. package/dist/runner/pool/vitest/worker.d.mts +0 -15
  144. package/dist/runner/pool/vitest/worker.mjs +0 -96
  145. package/dist/runner/pool/vitest-worker.d.mts +0 -15
  146. package/dist/runner/pool/vitest-worker.mjs +0 -96
  147. package/dist/runner/shared-module-redirect.d.ts +0 -56
  148. package/dist/runner/shared-module-redirect.js +0 -84
  149. package/dist/types/api.d.ts +0 -20
  150. /package/dist/{runner/__tests__/orchestrator.spec.d.ts → core/__tests__/sfc.spec.d.ts} +0 -0
  151. /package/dist/{runner/adapters/__tests__/jest.spec.d.ts → core/__tests__/variant-utils.spec.d.ts} +0 -0
  152. /package/dist/{runner/adapters/__tests__/vitest-worker-runtime.spec.d.ts → mutators/__tests__/operator.spec.d.ts} +0 -0
  153. /package/dist/{runner/adapters/__tests__/vitest.spec.d.ts → mutators/__tests__/return-value.spec.d.ts} +0 -0
  154. /package/dist/runner/{adapters/jest/__tests__/index.spec.d.ts → __tests__/args.spec.d.ts} +0 -0
  155. /package/dist/runner/{adapters/vitest/__tests__/index.spec.d.ts → __tests__/cache.spec.d.ts} +0 -0
  156. /package/dist/runner/{adapters/vitest/__tests__/worker-runtime.spec.d.ts → __tests__/changed.spec.d.ts} +0 -0
  157. /package/dist/runner/{pool/__tests__/index.spec.d.ts → __tests__/cleanup.spec.d.ts} +0 -0
  158. /package/dist/runner/{pool/__tests__/pool-plugin.spec.d.ts → __tests__/config.spec.d.ts} +0 -0
  159. /package/dist/runner/{pool/__tests__/pool-redirect-loader.spec.d.ts → __tests__/coverage-resolver.spec.d.ts} +0 -0
  160. /package/dist/runner/{pool/jest-worker.d.mts → __tests__/pool-executor.spec.d.ts} +0 -0
  161. /package/dist/runner/{pool/jest/worker.d.mts → __tests__/tasks.spec.d.ts} +0 -0
  162. /package/dist/{types/api.js → runner/__tests__/variants.spec.d.ts} +0 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Return value mutators.
3
+ *
4
+ * These mutators target `return` statements and replace the returned expression
5
+ * with a simpler or opposite value. They catch a different class of test gap
6
+ * from operator mutators: they reveal when callers never check what a function
7
+ * returns (e.g. no null-check, no assertion on the return value).
8
+ */
9
+ import * as t from '@babel/types';
10
+ import { traverse, getVisualColumn, parseSource, buildIgnoreLines, } from './utils.js';
11
+ /**
12
+ * Factory for return-value mutators.
13
+ *
14
+ * Traverses all `ReturnStatement` nodes in the source. For each node that has
15
+ * an argument (i.e. not a bare `return;`), calls `replacer` with the argument
16
+ * AST node. If `replacer` returns a non-null string, emits a mutation that
17
+ * splices that string in place of the original argument.
18
+ *
19
+ * @param name - Mutator name used in the registry and config include/exclude
20
+ * @param description - Human-readable description shown in reports
21
+ * @param replacer - Returns the replacement source text, or null to skip
22
+ */
23
+ function makeReturnMutator(name, description, replacer) {
24
+ return {
25
+ name,
26
+ description,
27
+ apply(src) {
28
+ const ast = parseSource(src);
29
+ const fileAst = ast;
30
+ const ignoreLines = buildIgnoreLines(fileAst.comments ?? []);
31
+ const outputs = [];
32
+ traverse(ast, {
33
+ ReturnStatement(path) {
34
+ const node = path.node;
35
+ if (!node.argument)
36
+ return; // bare return; — nothing to replace
37
+ const line = node.loc?.start.line;
38
+ if (line === undefined)
39
+ return;
40
+ if (ignoreLines.has(line))
41
+ return;
42
+ const replacement = replacer(node.argument);
43
+ if (replacement === null)
44
+ return;
45
+ const argStart = node.argument.start;
46
+ const argEnd = node.argument.end;
47
+ if (argStart == null || argEnd == null)
48
+ return;
49
+ const col = getVisualColumn(src, node.start ?? 0);
50
+ const code = src.slice(0, argStart) + replacement + src.slice(argEnd);
51
+ outputs.push({ line, col, code });
52
+ },
53
+ });
54
+ return outputs;
55
+ },
56
+ };
57
+ }
58
+ /* === Concrete return-value mutators === */
59
+ /**
60
+ * Replace any non-null return value with `null`.
61
+ * Reveals callers that never check for null returns.
62
+ */
63
+ export const returnToNull = makeReturnMutator('returnToNull', 'Replace return value with null', (node) => (t.isNullLiteral(node) ? null : 'null'));
64
+ /**
65
+ * Replace any non-undefined return value with `undefined`.
66
+ * Reveals callers that never guard against undefined returns.
67
+ */
68
+ export const returnToUndefined = makeReturnMutator('returnToUndefined', 'Replace return value with undefined', (node) => (t.isIdentifier(node, { name: 'undefined' }) ? null : 'undefined'));
69
+ /**
70
+ * Flip `return true` ↔ `return false`.
71
+ * Catches missing assertions on boolean-returning functions.
72
+ */
73
+ export const returnFlipBool = makeReturnMutator('returnFlipBool', 'Flip boolean return value (true ↔ false)', (node) => {
74
+ if (!t.isBooleanLiteral(node))
75
+ return null;
76
+ return node.value ? 'false' : 'true';
77
+ });
78
+ /**
79
+ * Replace a non-zero numeric return with `0`.
80
+ * Catches callers that never check the numeric return value.
81
+ */
82
+ export const returnZero = makeReturnMutator('returnZero', 'Replace numeric return value with 0', (node) => {
83
+ if (!t.isNumericLiteral(node))
84
+ return null;
85
+ return node.value === 0 ? null : '0';
86
+ });
87
+ /**
88
+ * Replace a non-empty string return with `''`.
89
+ * Catches callers that never check the string return value.
90
+ */
91
+ export const returnEmptyStr = makeReturnMutator('returnEmptyStr', "Replace string return value with ''", (node) => {
92
+ if (!t.isStringLiteral(node))
93
+ return null;
94
+ return node.value === '' ? null : "''";
95
+ });
96
+ /**
97
+ * Replace an array expression return with `[]`.
98
+ * Catches callers that never check for an empty array.
99
+ */
100
+ export const returnEmptyArr = makeReturnMutator('returnEmptyArr', 'Replace array return value with []', (node) => {
101
+ if (!t.isArrayExpression(node))
102
+ return null;
103
+ return node.elements.length === 0 ? null : '[]';
104
+ });
@@ -4,9 +4,16 @@
4
4
  * Provides functions for parsing, traversing, and analyzing code ASTs.
5
5
  * These utilities are used by operator mutators to locate and replace operators.
6
6
  */
7
+ import { type ParserOptions } from '@babel/parser';
7
8
  import * as t from '@babel/types';
8
9
  import type { OperatorTarget } from './types.js';
9
10
  export declare const traverse: typeof import('@babel/traverse').default;
11
+ /**
12
+ * Parser configuration for Babel.
13
+ * Enables support for TypeScript, JSX, decorators, and modern JavaScript features.
14
+ */
15
+ export declare const parserOptsTs: ParserOptions;
16
+ export declare const parserOptsFlow: ParserOptions;
10
17
  /**
11
18
  * Tab width used for converting character columns to visual columns.
12
19
  * This helps report correct column positions for terminals that render tabs.
@@ -21,6 +28,20 @@ export declare const TAB_WIDTH = 4;
21
28
  * @returns The visual column (1-based)
22
29
  */
23
30
  export declare function getVisualColumn(src: string, charOffset: number): number;
31
+ /**
32
+ * Build a set of line numbers that should be ignored for mutation based on
33
+ * disable comments (`mutineer-disable`, `mutineer-disable-line`,
34
+ * `mutineer-disable-next-line`).
35
+ *
36
+ * @param comments - The comment nodes from the parsed AST
37
+ * @returns Set of 1-based line numbers that should not be mutated
38
+ */
39
+ export declare function buildIgnoreLines(comments: readonly t.Comment[]): Set<number>;
40
+ /**
41
+ * Parse source with the TypeScript Babel plugin, falling back to the Flow
42
+ * plugin for Flow-typed React files that fail TS parsing.
43
+ */
44
+ export declare function parseSource(src: string): import("@babel/parser").ParseResult<t.File>;
24
45
  /**
25
46
  * Type guard to check if a node is a BinaryExpression or LogicalExpression.
26
47
  */
@@ -13,7 +13,7 @@ export const traverse = traverseModuleNormalized.default ?? traverseModuleNormal
13
13
  * Parser configuration for Babel.
14
14
  * Enables support for TypeScript, JSX, decorators, and modern JavaScript features.
15
15
  */
16
- const parserOptsTs = {
16
+ export const parserOptsTs = {
17
17
  sourceType: 'unambiguous',
18
18
  plugins: [
19
19
  'typescript',
@@ -28,7 +28,7 @@ const parserOptsTs = {
28
28
  ],
29
29
  tokens: true,
30
30
  };
31
- const parserOptsFlow = {
31
+ export const parserOptsFlow = {
32
32
  sourceType: 'unambiguous',
33
33
  plugins: [
34
34
  'flow',
@@ -74,33 +74,14 @@ export function getVisualColumn(src, charOffset) {
74
74
  return visualCol;
75
75
  }
76
76
  /**
77
- * Type guard to check if a node is a BinaryExpression or LogicalExpression.
78
- */
79
- export function isBinaryOrLogical(node) {
80
- return node.type === 'BinaryExpression' || node.type === 'LogicalExpression';
81
- }
82
- /**
83
- * Collect the operator tokens for a given operator and return their exact locations.
84
- * Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
85
- * to token positions for accurate column reporting.
77
+ * Build a set of line numbers that should be ignored for mutation based on
78
+ * disable comments (`mutineer-disable`, `mutineer-disable-line`,
79
+ * `mutineer-disable-next-line`).
86
80
  *
87
- * @param src - The source code
88
- * @param opValue - The operator to search for (e.g., '&&', '<=')
89
- * @returns Array of target locations for the operator
81
+ * @param comments - The comment nodes from the parsed AST
82
+ * @returns Set of 1-based line numbers that should not be mutated
90
83
  */
91
- export function collectOperatorTargets(src, opValue) {
92
- let ast;
93
- try {
94
- ast = parse(src, parserOptsTs);
95
- }
96
- catch {
97
- // Flow-typed React sources fail under TS parsing; fall back to Flow plugins.
98
- ast = parse(src, parserOptsFlow);
99
- }
100
- const fileAst = ast;
101
- const tokens = fileAst.tokens ?? [];
102
- const comments = fileAst.comments ?? [];
103
- const out = [];
84
+ export function buildIgnoreLines(comments) {
104
85
  const ignoreLines = new Set();
105
86
  for (const comment of comments) {
106
87
  const text = comment.value.trim();
@@ -120,6 +101,42 @@ export function collectOperatorTargets(src, opValue) {
120
101
  }
121
102
  }
122
103
  }
104
+ return ignoreLines;
105
+ }
106
+ /**
107
+ * Parse source with the TypeScript Babel plugin, falling back to the Flow
108
+ * plugin for Flow-typed React files that fail TS parsing.
109
+ */
110
+ export function parseSource(src) {
111
+ try {
112
+ return parse(src, parserOptsTs);
113
+ }
114
+ catch {
115
+ return parse(src, parserOptsFlow);
116
+ }
117
+ }
118
+ /**
119
+ * Type guard to check if a node is a BinaryExpression or LogicalExpression.
120
+ */
121
+ export function isBinaryOrLogical(node) {
122
+ return node.type === 'BinaryExpression' || node.type === 'LogicalExpression';
123
+ }
124
+ /**
125
+ * Collect the operator tokens for a given operator and return their exact locations.
126
+ * Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
127
+ * to token positions for accurate column reporting.
128
+ *
129
+ * @param src - The source code
130
+ * @param opValue - The operator to search for (e.g., '&&', '<=')
131
+ * @returns Array of target locations for the operator
132
+ */
133
+ export function collectOperatorTargets(src, opValue) {
134
+ const ast = parseSource(src);
135
+ const fileAst = ast;
136
+ const tokens = fileAst.tokens ?? [];
137
+ const comments = fileAst.comments ?? [];
138
+ const out = [];
139
+ const ignoreLines = buildIgnoreLines(comments);
123
140
  traverse(ast, {
124
141
  enter(p) {
125
142
  if (!isBinaryOrLogical(p.node))
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
3
+ describe('parseFlagNumber', () => {
4
+ it('parses valid integers', () => {
5
+ expect(parseFlagNumber('42', '--flag')).toBe(42);
6
+ });
7
+ it('parses valid floats', () => {
8
+ expect(parseFlagNumber('3.14', '--flag')).toBe(3.14);
9
+ });
10
+ it('parses zero', () => {
11
+ expect(parseFlagNumber('0', '--flag')).toBe(0);
12
+ });
13
+ it('parses negative numbers', () => {
14
+ expect(parseFlagNumber('-5', '--flag')).toBe(-5);
15
+ });
16
+ it('throws on non-numeric strings', () => {
17
+ expect(() => parseFlagNumber('abc', '--flag')).toThrow('Invalid value for --flag: abc');
18
+ });
19
+ it('parses empty string as 0', () => {
20
+ // Number('') === 0, which is finite
21
+ expect(parseFlagNumber('', '--flag')).toBe(0);
22
+ });
23
+ it('throws on NaN-producing values', () => {
24
+ expect(() => parseFlagNumber('not-a-number', '--flag')).toThrow('Invalid value for --flag: not-a-number');
25
+ });
26
+ it('throws on Infinity', () => {
27
+ expect(() => parseFlagNumber('Infinity', '--flag')).toThrow('Invalid value for --flag: Infinity');
28
+ });
29
+ });
30
+ describe('readNumberFlag', () => {
31
+ it('reads flag with separate value', () => {
32
+ expect(readNumberFlag(['--count', '5'], '--count')).toBe(5);
33
+ });
34
+ it('reads flag with = syntax', () => {
35
+ expect(readNumberFlag(['--count=10'], '--count')).toBe(10);
36
+ });
37
+ it('returns undefined when flag is not present', () => {
38
+ expect(readNumberFlag(['--other', '5'], '--count')).toBeUndefined();
39
+ });
40
+ it('throws when no value follows flag', () => {
41
+ expect(() => readNumberFlag(['--count'], '--count')).toThrow('Expected a numeric value after --count');
42
+ });
43
+ it('throws when value is not a number', () => {
44
+ expect(() => readNumberFlag(['--count', 'abc'], '--count')).toThrow('Invalid value for --count: abc');
45
+ });
46
+ });
47
+ describe('readStringFlag', () => {
48
+ it('reads flag with separate value', () => {
49
+ expect(readStringFlag(['--config', 'path.ts'], '--config')).toBe('path.ts');
50
+ });
51
+ it('reads flag with = syntax', () => {
52
+ expect(readStringFlag(['--config=path.ts'], '--config')).toBe('path.ts');
53
+ });
54
+ it('reads alias', () => {
55
+ expect(readStringFlag(['-c', 'path.ts'], '--config', '-c')).toBe('path.ts');
56
+ });
57
+ it('reads alias with = syntax', () => {
58
+ expect(readStringFlag(['-c=path.ts'], '--config', '-c')).toBe('path.ts');
59
+ });
60
+ it('returns undefined when flag is not present', () => {
61
+ expect(readStringFlag(['--other', 'val'], '--config')).toBeUndefined();
62
+ });
63
+ it('throws when no value follows flag', () => {
64
+ expect(() => readStringFlag(['--config'], '--config')).toThrow('Expected a value after --config');
65
+ });
66
+ it('throws when no value follows alias', () => {
67
+ expect(() => readStringFlag(['-c'], '--config', '-c')).toThrow('Expected a value after -c');
68
+ });
69
+ });
70
+ describe('validatePercent', () => {
71
+ it('returns undefined for undefined', () => {
72
+ expect(validatePercent(undefined, 'test')).toBeUndefined();
73
+ });
74
+ it('returns the value for valid percentages', () => {
75
+ expect(validatePercent(0, 'test')).toBe(0);
76
+ expect(validatePercent(50, 'test')).toBe(50);
77
+ expect(validatePercent(100, 'test')).toBe(100);
78
+ });
79
+ it('throws for negative values', () => {
80
+ expect(() => validatePercent(-1, 'test')).toThrow('Invalid test: expected value between 0 and 100 (received -1)');
81
+ });
82
+ it('throws for values over 100', () => {
83
+ expect(() => validatePercent(101, 'test')).toThrow('Invalid test: expected value between 0 and 100 (received 101)');
84
+ });
85
+ it('throws for non-finite values', () => {
86
+ expect(() => validatePercent(NaN, 'test')).toThrow('Invalid test: expected a number between 0 and 100');
87
+ expect(() => validatePercent(Infinity, 'test')).toThrow('Invalid test: expected a number between 0 and 100');
88
+ });
89
+ });
90
+ describe('parseConcurrency', () => {
91
+ it('returns default when flag not present', () => {
92
+ const result = parseConcurrency([]);
93
+ expect(result).toBeGreaterThanOrEqual(1);
94
+ });
95
+ it('parses explicit concurrency', () => {
96
+ expect(parseConcurrency(['--concurrency', '4'])).toBe(4);
97
+ });
98
+ it('clamps to minimum of 1', () => {
99
+ expect(parseConcurrency(['--concurrency', '0'])).toBeGreaterThanOrEqual(1);
100
+ });
101
+ it('handles invalid concurrency value gracefully', () => {
102
+ const result = parseConcurrency(['--concurrency', 'abc']);
103
+ expect(result).toBeGreaterThanOrEqual(1);
104
+ });
105
+ });
106
+ describe('parseProgressMode', () => {
107
+ it('returns bar by default', () => {
108
+ expect(parseProgressMode([])).toBe('bar');
109
+ });
110
+ it('returns list when specified', () => {
111
+ expect(parseProgressMode(['--progress', 'list'])).toBe('list');
112
+ });
113
+ it('returns quiet when specified', () => {
114
+ expect(parseProgressMode(['--progress', 'quiet'])).toBe('quiet');
115
+ });
116
+ it('returns bar for unknown values', () => {
117
+ expect(parseProgressMode(['--progress', 'unknown'])).toBe('bar');
118
+ });
119
+ it('defaults to bar when --progress has no following value', () => {
120
+ expect(parseProgressMode(['--progress'])).toBe('bar');
121
+ });
122
+ });
123
+ describe('parseCliOptions', () => {
124
+ const emptyCfg = {};
125
+ it('parses --changed flag', () => {
126
+ const opts = parseCliOptions(['--changed'], emptyCfg);
127
+ expect(opts.wantsChanged).toBe(true);
128
+ expect(opts.wantsChangedWithDeps).toBe(false);
129
+ });
130
+ it('parses --changed-with-deps flag', () => {
131
+ const opts = parseCliOptions(['--changed-with-deps'], emptyCfg);
132
+ expect(opts.wantsChangedWithDeps).toBe(true);
133
+ });
134
+ it('parses --only-covered-lines flag', () => {
135
+ const opts = parseCliOptions(['--only-covered-lines'], emptyCfg);
136
+ expect(opts.wantsOnlyCoveredLines).toBe(true);
137
+ });
138
+ it('reads onlyCoveredLines from config', () => {
139
+ const opts = parseCliOptions([], { onlyCoveredLines: true });
140
+ expect(opts.wantsOnlyCoveredLines).toBe(true);
141
+ });
142
+ it('parses --per-test-coverage flag', () => {
143
+ const opts = parseCliOptions(['--per-test-coverage'], emptyCfg);
144
+ expect(opts.wantsPerTestCoverage).toBe(true);
145
+ });
146
+ it('reads perTestCoverage from config', () => {
147
+ const opts = parseCliOptions([], { perTestCoverage: true });
148
+ expect(opts.wantsPerTestCoverage).toBe(true);
149
+ });
150
+ it('parses --coverage-file flag', () => {
151
+ const opts = parseCliOptions(['--coverage-file', 'coverage.json'], emptyCfg);
152
+ expect(opts.coverageFilePath).toBe('coverage.json');
153
+ });
154
+ it('reads coverageFile from config', () => {
155
+ const opts = parseCliOptions([], { coverageFile: 'cov.json' });
156
+ expect(opts.coverageFilePath).toBe('cov.json');
157
+ });
158
+ it('CLI coverage-file takes precedence over config', () => {
159
+ const opts = parseCliOptions(['--coverage-file', 'cli.json'], {
160
+ coverageFile: 'config.json',
161
+ });
162
+ expect(opts.coverageFilePath).toBe('cli.json');
163
+ });
164
+ it('parses --runner vitest', () => {
165
+ const opts = parseCliOptions(['--runner', 'vitest'], emptyCfg);
166
+ expect(opts.runner).toBe('vitest');
167
+ });
168
+ it('parses --runner jest', () => {
169
+ const opts = parseCliOptions(['--runner', 'jest'], emptyCfg);
170
+ expect(opts.runner).toBe('jest');
171
+ });
172
+ it('falls back to config runner', () => {
173
+ const opts = parseCliOptions([], { runner: 'jest' });
174
+ expect(opts.runner).toBe('jest');
175
+ });
176
+ it('defaults to vitest', () => {
177
+ const opts = parseCliOptions([], emptyCfg);
178
+ expect(opts.runner).toBe('vitest');
179
+ });
180
+ it('parses --config flag', () => {
181
+ const opts = parseCliOptions(['--config', 'my.config.ts'], emptyCfg);
182
+ expect(opts.configPath).toBe('my.config.ts');
183
+ });
184
+ it('parses -c alias', () => {
185
+ const opts = parseCliOptions(['-c', 'my.config.ts'], emptyCfg);
186
+ expect(opts.configPath).toBe('my.config.ts');
187
+ });
188
+ it('parses --min-kill-percent from CLI', () => {
189
+ const opts = parseCliOptions(['--min-kill-percent', '80'], emptyCfg);
190
+ expect(opts.minKillPercent).toBe(80);
191
+ });
192
+ it('reads minKillPercent from config', () => {
193
+ const opts = parseCliOptions([], { minKillPercent: 75 });
194
+ expect(opts.minKillPercent).toBe(75);
195
+ });
196
+ it('CLI min-kill-percent takes precedence over config', () => {
197
+ const opts = parseCliOptions(['--min-kill-percent', '90'], {
198
+ minKillPercent: 75,
199
+ });
200
+ expect(opts.minKillPercent).toBe(90);
201
+ });
202
+ it('rejects invalid --min-kill-percent', () => {
203
+ expect(() => parseCliOptions(['--min-kill-percent', '150'], emptyCfg)).toThrow('expected value between 0 and 100');
204
+ });
205
+ });
206
+ describe('extractConfigPath', () => {
207
+ it('extracts --config with separate value', () => {
208
+ expect(extractConfigPath(['--config', 'my.config.ts'])).toBe('my.config.ts');
209
+ });
210
+ it('extracts --config with = syntax', () => {
211
+ expect(extractConfigPath(['--config=my.config.ts'])).toBe('my.config.ts');
212
+ });
213
+ it('extracts -c alias with separate value', () => {
214
+ expect(extractConfigPath(['-c', 'my.config.ts'])).toBe('my.config.ts');
215
+ });
216
+ it('extracts -c alias with = syntax', () => {
217
+ expect(extractConfigPath(['-c=my.config.ts'])).toBe('my.config.ts');
218
+ });
219
+ it('returns undefined when no config flag is present', () => {
220
+ expect(extractConfigPath(['--changed', '--runner', 'vitest'])).toBeUndefined();
221
+ });
222
+ it('returns undefined for empty args', () => {
223
+ expect(extractConfigPath([])).toBeUndefined();
224
+ });
225
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { clearCacheOnStart, saveCacheAtomic, decodeCacheKey, keyForTests, hash, readMutantCache, } from '../cache.js';
6
+ let tmpDir;
7
+ beforeEach(async () => {
8
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-cache-'));
9
+ });
10
+ afterEach(async () => {
11
+ await fs.rm(tmpDir, { recursive: true, force: true });
12
+ });
13
+ describe('clearCacheOnStart', () => {
14
+ it('removes the cache file if it exists', async () => {
15
+ const cacheFile = path.join(tmpDir, '.mutate-cache.json');
16
+ await fs.writeFile(cacheFile, '{}');
17
+ await clearCacheOnStart(tmpDir);
18
+ await expect(fs.access(cacheFile)).rejects.toThrow();
19
+ });
20
+ it('does not throw if cache file does not exist', async () => {
21
+ await expect(clearCacheOnStart(tmpDir)).resolves.toBeUndefined();
22
+ });
23
+ });
24
+ describe('saveCacheAtomic', () => {
25
+ it('writes cache data to the file', async () => {
26
+ const cache = {
27
+ key1: {
28
+ status: 'killed',
29
+ file: 'foo.ts',
30
+ line: 1,
31
+ col: 0,
32
+ mutator: 'flipEQ',
33
+ },
34
+ };
35
+ await saveCacheAtomic(tmpDir, cache);
36
+ const content = await fs.readFile(path.join(tmpDir, '.mutate-cache.json'), 'utf8');
37
+ expect(JSON.parse(content)).toEqual(cache);
38
+ });
39
+ it('overwrites existing cache', async () => {
40
+ await saveCacheAtomic(tmpDir, { old: {} });
41
+ const newCache = {
42
+ new: {
43
+ status: 'escaped',
44
+ file: 'bar.ts',
45
+ line: 2,
46
+ col: 3,
47
+ mutator: 'andToOr',
48
+ },
49
+ };
50
+ await saveCacheAtomic(tmpDir, newCache);
51
+ const content = await fs.readFile(path.join(tmpDir, '.mutate-cache.json'), 'utf8');
52
+ expect(JSON.parse(content)).toEqual(newCache);
53
+ });
54
+ });
55
+ describe('decodeCacheKey', () => {
56
+ it('decodes a full cache key', () => {
57
+ const key = 'testsig:codesig:src/foo.ts:10,5:flipEQ';
58
+ const decoded = decodeCacheKey(key);
59
+ expect(decoded.file).toBe('src/foo.ts');
60
+ expect(decoded.line).toBe(10);
61
+ expect(decoded.col).toBe(5);
62
+ expect(decoded.mutator).toBe('flipEQ');
63
+ });
64
+ it('handles key with no colons', () => {
65
+ const decoded = decodeCacheKey('nodelimiters');
66
+ expect(decoded.mutator).toBe('unknown');
67
+ });
68
+ it('handles key with only one colon', () => {
69
+ const decoded = decodeCacheKey('only:one');
70
+ expect(decoded.mutator).toBe('one');
71
+ });
72
+ it('handles malformed position', () => {
73
+ const key = 'a:b:file:badpos:mutator';
74
+ const decoded = decodeCacheKey(key);
75
+ expect(decoded.mutator).toBe('mutator');
76
+ });
77
+ it('handles key with exactly two colons (no firstColon in rest)', () => {
78
+ // Key: "pos:mutator" after splitting last colon → rest = "pos", no colon in rest
79
+ // Full key: "10,5:mutator" → lastColon splits mutator, positionColon splits pos
80
+ // rest becomes empty string before positionColon
81
+ const decoded = decodeCacheKey('10,5:mutator');
82
+ expect(decoded.mutator).toBe('mutator');
83
+ expect(decoded.line).toBe(0); // positionColon = -1, returns early
84
+ });
85
+ it('handles key with exactly three colons (firstColon but no secondColon)', () => {
86
+ // "sig:10,5:mutator" → mutator='mutator', posRaw='10,5', rest='sig'
87
+ // firstColon in 'sig' = -1, returns early at line 70
88
+ const decoded = decodeCacheKey('sig:10,5:mutator');
89
+ expect(decoded.mutator).toBe('mutator');
90
+ expect(decoded.line).toBe(10);
91
+ expect(decoded.col).toBe(5);
92
+ });
93
+ it('handles key with four colons (firstColon but no secondColon in restAfterFirst)', () => {
94
+ // "tsig:csig:10,5:mutator" → mutator='mutator', posRaw='10,5', rest='tsig:csig'
95
+ // firstColon=4, restAfterFirst='csig', secondColon in 'csig' = -1, returns at line 73
96
+ const decoded = decodeCacheKey('tsig:csig:10,5:mutator');
97
+ expect(decoded.mutator).toBe('mutator');
98
+ expect(decoded.line).toBe(10);
99
+ expect(decoded.col).toBe(5);
100
+ });
101
+ });
102
+ describe('keyForTests', () => {
103
+ it('produces deterministic keys regardless of input order', () => {
104
+ const key1 = keyForTests(['b.test.ts', 'a.test.ts']);
105
+ const key2 = keyForTests(['a.test.ts', 'b.test.ts']);
106
+ expect(key1).toBe(key2);
107
+ });
108
+ it('produces different keys for different test sets', () => {
109
+ const key1 = keyForTests(['a.test.ts']);
110
+ const key2 = keyForTests(['b.test.ts']);
111
+ expect(key1).not.toBe(key2);
112
+ });
113
+ });
114
+ describe('hash', () => {
115
+ it('returns a 12 character hex string', () => {
116
+ const h = hash('test');
117
+ expect(h).toMatch(/^[0-9a-f]{12}$/);
118
+ });
119
+ it('returns the same hash for the same input', () => {
120
+ expect(hash('hello')).toBe(hash('hello'));
121
+ });
122
+ it('returns different hashes for different inputs', () => {
123
+ expect(hash('a')).not.toBe(hash('b'));
124
+ });
125
+ });
126
+ describe('readMutantCache', () => {
127
+ it('returns empty object when no cache file exists', async () => {
128
+ const result = await readMutantCache(tmpDir);
129
+ expect(result).toEqual({});
130
+ });
131
+ it('reads and normalizes object-format cache entries', async () => {
132
+ const cache = {
133
+ 'testsig:codesig:file.ts:1,0:flip': {
134
+ status: 'killed',
135
+ file: 'file.ts',
136
+ line: 1,
137
+ col: 0,
138
+ mutator: 'flip',
139
+ },
140
+ };
141
+ await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), JSON.stringify(cache));
142
+ const result = await readMutantCache(tmpDir);
143
+ expect(result['testsig:codesig:file.ts:1,0:flip']).toEqual({
144
+ status: 'killed',
145
+ file: 'file.ts',
146
+ line: 1,
147
+ col: 0,
148
+ mutator: 'flip',
149
+ });
150
+ });
151
+ it('reads and normalizes old string-format cache entries', async () => {
152
+ const cache = {
153
+ 'testsig:codesig:file.ts:1,0:flip': 'killed',
154
+ };
155
+ await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), JSON.stringify(cache));
156
+ const result = await readMutantCache(tmpDir);
157
+ const entry = result['testsig:codesig:file.ts:1,0:flip'];
158
+ expect(entry.status).toBe('killed');
159
+ expect(entry.mutator).toBe('flip');
160
+ });
161
+ it('returns empty object for invalid JSON', async () => {
162
+ await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), 'not json');
163
+ const result = await readMutantCache(tmpDir);
164
+ expect(result).toEqual({});
165
+ });
166
+ it('normalizes partial object entries with decoded fallbacks', async () => {
167
+ const cache = {
168
+ 'testsig:codesig:file.ts:5,3:mut': {
169
+ status: 'escaped',
170
+ },
171
+ };
172
+ await fs.writeFile(path.join(tmpDir, '.mutate-cache.json'), JSON.stringify(cache));
173
+ const result = await readMutantCache(tmpDir);
174
+ const entry = result['testsig:codesig:file.ts:5,3:mut'];
175
+ expect(entry.status).toBe('escaped');
176
+ expect(entry.line).toBe(5);
177
+ expect(entry.col).toBe(3);
178
+ expect(entry.mutator).toBe('mut');
179
+ });
180
+ });