flake-monster 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +281 -0
- package/bin/flake-monster.js +6 -0
- package/package.json +48 -0
- package/src/adapters/adapter-interface.js +86 -0
- package/src/adapters/javascript/codegen.js +13 -0
- package/src/adapters/javascript/index.js +76 -0
- package/src/adapters/javascript/injector.js +438 -0
- package/src/adapters/javascript/parser.js +19 -0
- package/src/adapters/javascript/remover.js +128 -0
- package/src/adapters/registry.js +64 -0
- package/src/cli/commands/inject.js +64 -0
- package/src/cli/commands/restore.js +107 -0
- package/src/cli/commands/test.js +215 -0
- package/src/cli/index.js +19 -0
- package/src/core/config.js +57 -0
- package/src/core/engine.js +156 -0
- package/src/core/flake-analyzer.js +64 -0
- package/src/core/manifest.js +137 -0
- package/src/core/parsers/index.js +40 -0
- package/src/core/parsers/jest.js +52 -0
- package/src/core/parsers/node-test.js +64 -0
- package/src/core/parsers/tap.js +92 -0
- package/src/core/profile.js +72 -0
- package/src/core/reporter.js +75 -0
- package/src/core/seed.js +59 -0
- package/src/core/workspace.js +139 -0
- package/src/runtime/javascript/flake-monster.runtime.js +5 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { AdapterRegistry } from '../../adapters/registry.js';
|
|
3
|
+
import { createJavaScriptAdapter } from '../../adapters/javascript/index.js';
|
|
4
|
+
import { InjectorEngine } from '../../core/engine.js';
|
|
5
|
+
import { FlakeProfile } from '../../core/profile.js';
|
|
6
|
+
import { parseSeed } from '../../core/seed.js';
|
|
7
|
+
import { ProjectWorkspace, getFlakeMonsterDir } from '../../core/workspace.js';
|
|
8
|
+
import { loadConfig, mergeWithCliOptions } from '../../core/config.js';
|
|
9
|
+
|
|
10
|
+
export function registerInjectCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('inject')
|
|
13
|
+
.description('Inject async delays into source files')
|
|
14
|
+
.argument('[globs...]', 'File patterns to process', ['src/**/*.js'])
|
|
15
|
+
.option('-m, --mode <mode>', 'Injection density: light, medium, hardcore', 'medium')
|
|
16
|
+
.option('-s, --seed <seed>', 'Random seed for deterministic delays (or "auto")', 'auto')
|
|
17
|
+
.option('--in-place', 'Modify files in-place (default)', true)
|
|
18
|
+
.option('--workspace', 'Create a workspace copy instead of modifying files in-place', false)
|
|
19
|
+
.option('--min-delay <ms>', 'Minimum delay in milliseconds', '0')
|
|
20
|
+
.option('--max-delay <ms>', 'Maximum delay in milliseconds', '50')
|
|
21
|
+
.action(async (globs, options) => {
|
|
22
|
+
try {
|
|
23
|
+
const projectRoot = resolve('.');
|
|
24
|
+
const config = await loadConfig(projectRoot);
|
|
25
|
+
const merged = mergeWithCliOptions(config, options);
|
|
26
|
+
const seed = parseSeed(options.seed);
|
|
27
|
+
|
|
28
|
+
const profile = FlakeProfile.fromConfig(merged);
|
|
29
|
+
const registry = new AdapterRegistry();
|
|
30
|
+
registry.register(createJavaScriptAdapter());
|
|
31
|
+
const engine = new InjectorEngine(registry, profile);
|
|
32
|
+
|
|
33
|
+
const useWorkspace = options.workspace;
|
|
34
|
+
let targetDir = projectRoot;
|
|
35
|
+
let workspace = null;
|
|
36
|
+
|
|
37
|
+
if (useWorkspace) {
|
|
38
|
+
workspace = new ProjectWorkspace({ sourceDir: projectRoot, runId: `inject-seed-${seed}` });
|
|
39
|
+
await workspace.create();
|
|
40
|
+
targetDir = workspace.root;
|
|
41
|
+
console.log(`Workspace created: ${workspace.root}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const manifest = await engine.injectAll(targetDir, globs, seed);
|
|
45
|
+
const flakeDir = getFlakeMonsterDir(useWorkspace ? targetDir : projectRoot);
|
|
46
|
+
await manifest.save(flakeDir);
|
|
47
|
+
|
|
48
|
+
const totalFiles = Object.keys(manifest.getFiles()).length;
|
|
49
|
+
const totalInjections = manifest.getTotalInjections();
|
|
50
|
+
|
|
51
|
+
console.log(`\nInjected ${totalInjections} delays into ${totalFiles} file(s)`);
|
|
52
|
+
console.log(`Mode: ${profile.mode} | Seed: ${seed}`);
|
|
53
|
+
|
|
54
|
+
if (useWorkspace) {
|
|
55
|
+
console.log(`\nWorkspace: ${workspace.root}`);
|
|
56
|
+
console.log('Run your tests against the workspace, then clean up with:');
|
|
57
|
+
console.log(` flake-monster restore`);
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(`Error: ${err.message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { AdapterRegistry } from '../../adapters/registry.js';
|
|
4
|
+
import { createJavaScriptAdapter } from '../../adapters/javascript/index.js';
|
|
5
|
+
import { InjectorEngine } from '../../core/engine.js';
|
|
6
|
+
import { FlakeProfile } from '../../core/profile.js';
|
|
7
|
+
import { Manifest } from '../../core/manifest.js';
|
|
8
|
+
import { getFlakeMonsterDir } from '../../core/workspace.js';
|
|
9
|
+
import { loadConfig } from '../../core/config.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Prompt the user for a yes/no answer.
|
|
13
|
+
* @param {string} question
|
|
14
|
+
* @returns {Promise<boolean>}
|
|
15
|
+
*/
|
|
16
|
+
function confirm(question) {
|
|
17
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
rl.question(question, (answer) => {
|
|
20
|
+
rl.close();
|
|
21
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Print recovery scan results to the terminal.
|
|
28
|
+
* @param {{ file: string, matches: { line: number, content: string, reason: string }[] }[]} scanResults
|
|
29
|
+
*/
|
|
30
|
+
function printScanResults(scanResults) {
|
|
31
|
+
let totalMatches = 0;
|
|
32
|
+
|
|
33
|
+
for (const { file, matches } of scanResults) {
|
|
34
|
+
console.log(`\n ${file} (${matches.length} match${matches.length === 1 ? '' : 'es'}):`);
|
|
35
|
+
for (const m of matches) {
|
|
36
|
+
const tag = m.reason === 'stamp' ? 'stamp' : m.reason === 'identifier' ? 'ident' : 'import';
|
|
37
|
+
console.log(` L${m.line} [${tag}] ${m.content.trim()}`);
|
|
38
|
+
totalMatches++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`\n Total: ${totalMatches} line(s) across ${scanResults.length} file(s)`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerRestoreCommand(program) {
|
|
46
|
+
program
|
|
47
|
+
.command('restore')
|
|
48
|
+
.description('Remove all injected delays and restore original source')
|
|
49
|
+
.option('--in-place', 'Restore in-place modified files (default)', true)
|
|
50
|
+
.option('--recover', 'Interactive scan and confirm, use when traces of injected code remain after a normal restore', false)
|
|
51
|
+
.option('--dir <path>', 'Directory to restore (defaults to project root)')
|
|
52
|
+
.action(async (options) => {
|
|
53
|
+
try {
|
|
54
|
+
const projectRoot = resolve('.');
|
|
55
|
+
const config = await loadConfig(projectRoot);
|
|
56
|
+
|
|
57
|
+
const registry = new AdapterRegistry();
|
|
58
|
+
registry.register(createJavaScriptAdapter());
|
|
59
|
+
|
|
60
|
+
const targetDir = options.dir ? resolve(options.dir) : projectRoot;
|
|
61
|
+
const flakeDir = getFlakeMonsterDir(targetDir);
|
|
62
|
+
|
|
63
|
+
const manifest = await Manifest.load(flakeDir);
|
|
64
|
+
if (!manifest) {
|
|
65
|
+
console.log('No manifest found. Nothing to restore.');
|
|
66
|
+
console.log(`Looked in: ${flakeDir}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const profile = FlakeProfile.fromConfig({ ...config, mode: manifest.mode });
|
|
71
|
+
const engine = new InjectorEngine(registry, profile);
|
|
72
|
+
|
|
73
|
+
if (options.recover) {
|
|
74
|
+
// Recovery mode: scan first, show results, then confirm
|
|
75
|
+
console.log('Recovery mode: scanning for injected lines...');
|
|
76
|
+
|
|
77
|
+
const scanResults = await engine.scanAll(targetDir, manifest);
|
|
78
|
+
|
|
79
|
+
if (scanResults.length === 0) {
|
|
80
|
+
console.log('No injected lines found. Files appear clean.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
printScanResults(scanResults);
|
|
85
|
+
|
|
86
|
+
const proceed = await confirm('\n Remove these lines? (y/N) ');
|
|
87
|
+
if (!proceed) {
|
|
88
|
+
console.log(' Aborted. No files were modified.');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { filesRestored, injectionsRemoved } = await engine.restoreAll(targetDir, manifest);
|
|
93
|
+
await Manifest.delete(flakeDir);
|
|
94
|
+
|
|
95
|
+
console.log(`\n Recovered ${filesRestored} file(s), removed ${injectionsRemoved} line(s)`);
|
|
96
|
+
} else {
|
|
97
|
+
const { filesRestored, injectionsRemoved } = await engine.restoreAll(targetDir, manifest);
|
|
98
|
+
await Manifest.delete(flakeDir);
|
|
99
|
+
|
|
100
|
+
console.log(`Restored ${filesRestored} file(s), removed ${injectionsRemoved} injection(s)`);
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`Error: ${err.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { AdapterRegistry } from '../../adapters/registry.js';
|
|
4
|
+
import { createJavaScriptAdapter } from '../../adapters/javascript/index.js';
|
|
5
|
+
import { InjectorEngine } from '../../core/engine.js';
|
|
6
|
+
import { FlakeProfile } from '../../core/profile.js';
|
|
7
|
+
import { parseSeed, deriveSeed } from '../../core/seed.js';
|
|
8
|
+
import { ProjectWorkspace, getFlakeMonsterDir } from '../../core/workspace.js';
|
|
9
|
+
import { loadConfig, mergeWithCliOptions } from '../../core/config.js';
|
|
10
|
+
import { Reporter } from '../../core/reporter.js';
|
|
11
|
+
import { detectRunner, parseTestOutput } from '../../core/parsers/index.js';
|
|
12
|
+
import { analyzeFlakiness } from '../../core/flake-analyzer.js';
|
|
13
|
+
|
|
14
|
+
function execInDir(command, cwd) {
|
|
15
|
+
try {
|
|
16
|
+
const stdout = execSync(command, {
|
|
17
|
+
cwd,
|
|
18
|
+
env: { ...process.env },
|
|
19
|
+
encoding: 'utf-8',
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
+
});
|
|
22
|
+
return { exitCode: 0, stdout, stderr: '' };
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return {
|
|
25
|
+
exitCode: err.status ?? 1,
|
|
26
|
+
stdout: err.stdout || '',
|
|
27
|
+
stderr: err.stderr || '',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerTestCommand(program) {
|
|
33
|
+
program
|
|
34
|
+
.command('test')
|
|
35
|
+
.description('Run tests multiple times with injected delays to find flakes')
|
|
36
|
+
.option('-r, --runs <n>', 'Number of test runs', '10')
|
|
37
|
+
.option('-m, --mode <mode>', 'Injection density: light, medium, hardcore', 'medium')
|
|
38
|
+
.option('-s, --seed <seed>', 'Base seed (or "auto")', 'auto')
|
|
39
|
+
.option('-c, --cmd <command>', 'Test command to execute', 'npm test')
|
|
40
|
+
.option('--in-place', 'Modify source files directly (default)', true)
|
|
41
|
+
.option('--workspace', 'Use workspace copies instead of modifying source files directly', false)
|
|
42
|
+
.option('--keep-on-fail', 'Keep workspace on test failure for inspection', false)
|
|
43
|
+
.option('--keep-all', 'Keep all workspaces (pass or fail)', false)
|
|
44
|
+
.option('--min-delay <ms>', 'Minimum delay in milliseconds', '0')
|
|
45
|
+
.option('--max-delay <ms>', 'Maximum delay in milliseconds', '50')
|
|
46
|
+
.option('-f, --format <format>', 'Output format: text or json', 'text')
|
|
47
|
+
.option('--runner <runner>', 'Test runner: jest, node-test, tap, or auto', 'auto')
|
|
48
|
+
.argument('[globs...]', 'File patterns to process', ['src/**/*.js'])
|
|
49
|
+
.action(async (globs, options) => {
|
|
50
|
+
try {
|
|
51
|
+
const projectRoot = resolve('.');
|
|
52
|
+
const config = await loadConfig(projectRoot);
|
|
53
|
+
const merged = mergeWithCliOptions(config, options);
|
|
54
|
+
|
|
55
|
+
const baseSeed = parseSeed(options.seed);
|
|
56
|
+
const runs = Number(options.runs);
|
|
57
|
+
const testCmd = options.cmd;
|
|
58
|
+
const inPlace = !options.workspace;
|
|
59
|
+
const jsonOutput = options.format === 'json';
|
|
60
|
+
|
|
61
|
+
// Resolve which runner parser to use
|
|
62
|
+
const runner = options.runner === 'auto'
|
|
63
|
+
? detectRunner(testCmd)
|
|
64
|
+
: options.runner;
|
|
65
|
+
|
|
66
|
+
const profile = FlakeProfile.fromConfig(merged);
|
|
67
|
+
const registry = new AdapterRegistry();
|
|
68
|
+
registry.register(createJavaScriptAdapter());
|
|
69
|
+
const engine = new InjectorEngine(registry, profile);
|
|
70
|
+
const reporter = new Reporter({ quiet: jsonOutput });
|
|
71
|
+
|
|
72
|
+
reporter.log(`FlakeMonster test harness`);
|
|
73
|
+
reporter.log(` Runs: ${runs} | Mode: ${profile.mode} | Base seed: ${baseSeed}`);
|
|
74
|
+
reporter.log(` Command: ${testCmd}`);
|
|
75
|
+
reporter.log(` Patterns: ${globs.join(', ')}`);
|
|
76
|
+
if (runner) {
|
|
77
|
+
reporter.log(` Runner: ${runner}`);
|
|
78
|
+
}
|
|
79
|
+
if (inPlace) {
|
|
80
|
+
reporter.log(' Mode: in-place (source files will be modified and restored)');
|
|
81
|
+
}
|
|
82
|
+
reporter.log('');
|
|
83
|
+
|
|
84
|
+
const results = [];
|
|
85
|
+
let lastManifest = null;
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < runs; i++) {
|
|
88
|
+
const runSeed = deriveSeed(baseSeed, `run:${i}`);
|
|
89
|
+
|
|
90
|
+
if (inPlace) {
|
|
91
|
+
// Restore previous run's injections before re-injecting
|
|
92
|
+
if (lastManifest) {
|
|
93
|
+
await engine.restoreAll(projectRoot, lastManifest);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Inject directly into source
|
|
97
|
+
const manifest = await engine.injectAll(projectRoot, globs, runSeed);
|
|
98
|
+
const flakeDir = getFlakeMonsterDir(projectRoot);
|
|
99
|
+
await manifest.save(flakeDir);
|
|
100
|
+
lastManifest = manifest;
|
|
101
|
+
|
|
102
|
+
// Run tests in project root
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
const { exitCode, stdout, stderr } = execInDir(testCmd, projectRoot);
|
|
105
|
+
const durationMs = Date.now() - start;
|
|
106
|
+
|
|
107
|
+
// Parse test output if we have a runner
|
|
108
|
+
const parsed = runner ? parseTestOutput(runner, stdout) : null;
|
|
109
|
+
|
|
110
|
+
const result = {
|
|
111
|
+
runIndex: i,
|
|
112
|
+
seed: runSeed,
|
|
113
|
+
exitCode,
|
|
114
|
+
stdout,
|
|
115
|
+
stderr,
|
|
116
|
+
durationMs,
|
|
117
|
+
workspacePath: projectRoot,
|
|
118
|
+
kept: false,
|
|
119
|
+
parsed: parsed?.parsed ?? false,
|
|
120
|
+
tests: parsed?.tests ?? [],
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
reporter.printRunResult(result, runs);
|
|
124
|
+
results.push(result);
|
|
125
|
+
} else {
|
|
126
|
+
// Create workspace
|
|
127
|
+
const workspace = new ProjectWorkspace({
|
|
128
|
+
sourceDir: projectRoot,
|
|
129
|
+
runId: `run-${i}-seed-${runSeed}`,
|
|
130
|
+
});
|
|
131
|
+
await workspace.create();
|
|
132
|
+
|
|
133
|
+
// Inject
|
|
134
|
+
const manifest = await engine.injectAll(workspace.root, globs, runSeed);
|
|
135
|
+
const flakeDir = getFlakeMonsterDir(workspace.root);
|
|
136
|
+
await manifest.save(flakeDir);
|
|
137
|
+
|
|
138
|
+
// Run tests
|
|
139
|
+
const start = Date.now();
|
|
140
|
+
const { exitCode, stdout, stderr } = workspace.exec(testCmd);
|
|
141
|
+
const durationMs = Date.now() - start;
|
|
142
|
+
|
|
143
|
+
const failed = exitCode !== 0;
|
|
144
|
+
const shouldKeep = (failed && options.keepOnFail) || options.keepAll;
|
|
145
|
+
|
|
146
|
+
// Parse test output if we have a runner
|
|
147
|
+
const parsed = runner ? parseTestOutput(runner, stdout) : null;
|
|
148
|
+
|
|
149
|
+
const result = {
|
|
150
|
+
runIndex: i,
|
|
151
|
+
seed: runSeed,
|
|
152
|
+
exitCode,
|
|
153
|
+
stdout,
|
|
154
|
+
stderr,
|
|
155
|
+
durationMs,
|
|
156
|
+
workspacePath: workspace.root,
|
|
157
|
+
kept: shouldKeep,
|
|
158
|
+
parsed: parsed?.parsed ?? false,
|
|
159
|
+
tests: parsed?.tests ?? [],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
reporter.printRunResult(result, runs);
|
|
163
|
+
results.push(result);
|
|
164
|
+
|
|
165
|
+
// Cleanup
|
|
166
|
+
if (!shouldKeep) {
|
|
167
|
+
await workspace.destroy();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Restore source files after all in-place runs
|
|
173
|
+
if (inPlace && lastManifest) {
|
|
174
|
+
await engine.restoreAll(projectRoot, lastManifest);
|
|
175
|
+
reporter.log('\n Source files restored to original state.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Run flakiness analysis if we have parsed results
|
|
179
|
+
const analysis = runner ? analyzeFlakiness(results) : null;
|
|
180
|
+
|
|
181
|
+
if (jsonOutput) {
|
|
182
|
+
// JSON output for CI consumption
|
|
183
|
+
const output = {
|
|
184
|
+
version: 1,
|
|
185
|
+
baseSeed,
|
|
186
|
+
runs: results.map(r => ({
|
|
187
|
+
runIndex: r.runIndex,
|
|
188
|
+
seed: r.seed,
|
|
189
|
+
exitCode: r.exitCode,
|
|
190
|
+
durationMs: r.durationMs,
|
|
191
|
+
parsed: r.parsed,
|
|
192
|
+
totalPassed: r.tests.filter(t => t.status === 'passed').length,
|
|
193
|
+
totalFailed: r.tests.filter(t => t.status === 'failed').length,
|
|
194
|
+
})),
|
|
195
|
+
analysis: analysis ?? { totalTests: 0, flakyTests: [], stableTests: [], alwaysFailingTests: [] },
|
|
196
|
+
};
|
|
197
|
+
console.log(JSON.stringify(output));
|
|
198
|
+
} else {
|
|
199
|
+
reporter.summarize(results, runs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Exit with failure if any run failed
|
|
203
|
+
const anyFailed = results.some((r) => r.exitCode !== 0);
|
|
204
|
+
if (anyFailed) process.exit(1);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// If in-place mode fails mid-run, still try to restore
|
|
207
|
+
if (!options?.workspace) {
|
|
208
|
+
console.error('\nError during in-place test run. Attempting to restore source files...');
|
|
209
|
+
console.error('If restoration fails, run: flake-monster restore');
|
|
210
|
+
}
|
|
211
|
+
console.error(`Error: ${err.message}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { registerInjectCommand } from './commands/inject.js';
|
|
3
|
+
import { registerRestoreCommand } from './commands/restore.js';
|
|
4
|
+
import { registerTestCommand } from './commands/test.js';
|
|
5
|
+
|
|
6
|
+
export function createCli() {
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('flake-monster')
|
|
11
|
+
.description('Source-to-source test hardener, injects async delays to surface flaky tests')
|
|
12
|
+
.version('0.1.0');
|
|
13
|
+
|
|
14
|
+
registerInjectCommand(program);
|
|
15
|
+
registerRestoreCommand(program);
|
|
16
|
+
registerTestCommand(program);
|
|
17
|
+
|
|
18
|
+
return program;
|
|
19
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILENAMES = ['.flakemonsterrc.json', 'flakemonster.config.json'];
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
include: ['src/**/*.js'],
|
|
8
|
+
exclude: ['**/node_modules/**', '**/dist/**', '**/build/**'],
|
|
9
|
+
mode: 'medium',
|
|
10
|
+
minDelayMs: 0,
|
|
11
|
+
maxDelayMs: 50,
|
|
12
|
+
distribution: 'uniform',
|
|
13
|
+
testCommand: 'npm test',
|
|
14
|
+
runs: 10,
|
|
15
|
+
keepOnFail: true,
|
|
16
|
+
skipTryCatch: false,
|
|
17
|
+
skipGenerators: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load config from project root, merging with defaults.
|
|
22
|
+
* CLI flags override config file values.
|
|
23
|
+
* @param {string} projectRoot
|
|
24
|
+
* @returns {Promise<Object>}
|
|
25
|
+
*/
|
|
26
|
+
export async function loadConfig(projectRoot) {
|
|
27
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(join(projectRoot, filename), 'utf-8');
|
|
30
|
+
const fileConfig = JSON.parse(raw);
|
|
31
|
+
return { ...DEFAULTS, ...fileConfig };
|
|
32
|
+
} catch {
|
|
33
|
+
// File doesn't exist or isn't valid JSON — try next
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { ...DEFAULTS };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Merge loaded config with CLI options (CLI wins).
|
|
41
|
+
* @param {Object} config
|
|
42
|
+
* @param {Object} cliOptions
|
|
43
|
+
* @returns {Object}
|
|
44
|
+
*/
|
|
45
|
+
export function mergeWithCliOptions(config, cliOptions) {
|
|
46
|
+
const merged = { ...config };
|
|
47
|
+
if (cliOptions.mode) merged.mode = cliOptions.mode;
|
|
48
|
+
if (cliOptions.seed) merged.seed = cliOptions.seed;
|
|
49
|
+
if (cliOptions.minDelay) merged.minDelayMs = Number(cliOptions.minDelay);
|
|
50
|
+
if (cliOptions.maxDelay) merged.maxDelayMs = Number(cliOptions.maxDelay);
|
|
51
|
+
if (cliOptions.cmd) merged.testCommand = cliOptions.cmd;
|
|
52
|
+
if (cliOptions.runs) merged.runs = Number(cliOptions.runs);
|
|
53
|
+
if (cliOptions.keepOnFail) merged.keepOnFail = true;
|
|
54
|
+
if (cliOptions.keepAll) merged.keepAll = true;
|
|
55
|
+
if (cliOptions.skipTryCatch) merged.skipTryCatch = true;
|
|
56
|
+
return merged;
|
|
57
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, dirname } from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { Manifest, hashContent } from './manifest.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Language-agnostic injection orchestrator.
|
|
8
|
+
* Routes files to the correct adapter and manages manifests.
|
|
9
|
+
* Never touches ASTs directly, all parsing/manipulation is in adapters.
|
|
10
|
+
*/
|
|
11
|
+
export class InjectorEngine {
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('../adapters/registry.js').AdapterRegistry} registry
|
|
14
|
+
* @param {import('./profile.js').FlakeProfile} profile
|
|
15
|
+
*/
|
|
16
|
+
constructor(registry, profile) {
|
|
17
|
+
this.registry = registry;
|
|
18
|
+
this.profile = profile;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Inject delays into all matching files in a directory.
|
|
23
|
+
* @param {string} rootDir - Directory to process (workspace or project root)
|
|
24
|
+
* @param {string[]} globs - File patterns to process
|
|
25
|
+
* @param {number} seed
|
|
26
|
+
* @returns {Promise<Manifest>}
|
|
27
|
+
*/
|
|
28
|
+
async injectAll(rootDir, globs, seed) {
|
|
29
|
+
const manifest = new Manifest();
|
|
30
|
+
manifest.seed = seed;
|
|
31
|
+
manifest.mode = this.profile.mode;
|
|
32
|
+
|
|
33
|
+
// Resolve globs to file list
|
|
34
|
+
const files = await fg(globs, { cwd: rootDir, absolute: false });
|
|
35
|
+
|
|
36
|
+
const adaptersUsed = new Set();
|
|
37
|
+
let totalInjections = 0;
|
|
38
|
+
|
|
39
|
+
for (const filePath of files) {
|
|
40
|
+
const adapter = this.registry.getAdapterForFile(filePath);
|
|
41
|
+
if (!adapter) continue;
|
|
42
|
+
|
|
43
|
+
const absPath = join(rootDir, filePath);
|
|
44
|
+
const source = await readFile(absPath, 'utf-8');
|
|
45
|
+
const originalHash = hashContent(source);
|
|
46
|
+
|
|
47
|
+
const options = this.profile.toInjectOptions(filePath, seed);
|
|
48
|
+
const result = adapter.inject(source, options);
|
|
49
|
+
|
|
50
|
+
if (result.points.length === 0) continue;
|
|
51
|
+
|
|
52
|
+
await writeFile(absPath, result.source, 'utf-8');
|
|
53
|
+
const modifiedHash = hashContent(result.source);
|
|
54
|
+
|
|
55
|
+
manifest.addFile(filePath, adapter.id, originalHash, modifiedHash, result);
|
|
56
|
+
totalInjections += result.points.length;
|
|
57
|
+
adaptersUsed.add(adapter.id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Copy runtime files for each adapter used
|
|
61
|
+
for (const adapterId of adaptersUsed) {
|
|
62
|
+
const adapter = this.registry.getAdapter(adapterId);
|
|
63
|
+
const info = adapter.getRuntimeInfo();
|
|
64
|
+
const destPath = join(rootDir, info.runtimeFileName);
|
|
65
|
+
await copyFile(info.runtimeSourcePath, destPath);
|
|
66
|
+
manifest.addRuntimeFile(info.runtimeFileName);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return manifest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Scan files for recovery matches without modifying anything.
|
|
74
|
+
* Returns per-file match results for preview before recovery.
|
|
75
|
+
* @param {string} rootDir
|
|
76
|
+
* @param {Manifest} manifest
|
|
77
|
+
* @returns {Promise<{ file: string, matches: { line: number, content: string, reason: string }[] }[]>}
|
|
78
|
+
*/
|
|
79
|
+
async scanAll(rootDir, manifest) {
|
|
80
|
+
const results = [];
|
|
81
|
+
const files = manifest.getFiles();
|
|
82
|
+
|
|
83
|
+
for (const [filePath, entry] of Object.entries(files)) {
|
|
84
|
+
const adapter = this.registry.getAdapter(entry.adapter);
|
|
85
|
+
if (!adapter || !adapter.scan) continue;
|
|
86
|
+
|
|
87
|
+
const absPath = join(rootDir, filePath);
|
|
88
|
+
let source;
|
|
89
|
+
try {
|
|
90
|
+
source = await readFile(absPath, 'utf-8');
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const matches = adapter.scan(source);
|
|
96
|
+
if (matches.length > 0) {
|
|
97
|
+
results.push({ file: filePath, matches });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Remove all injections from files listed in the manifest.
|
|
106
|
+
* @param {string} rootDir
|
|
107
|
+
* @param {Manifest} manifest
|
|
108
|
+
* @returns {Promise<{ filesRestored: number, injectionsRemoved: number }>}
|
|
109
|
+
*/
|
|
110
|
+
async restoreAll(rootDir, manifest) {
|
|
111
|
+
let filesRestored = 0;
|
|
112
|
+
let injectionsRemoved = 0;
|
|
113
|
+
|
|
114
|
+
const files = manifest.getFiles();
|
|
115
|
+
|
|
116
|
+
for (const [filePath, entry] of Object.entries(files)) {
|
|
117
|
+
const adapter = this.registry.getAdapter(entry.adapter);
|
|
118
|
+
if (!adapter) {
|
|
119
|
+
console.warn(`No adapter found for "${entry.adapter}", skipping ${filePath}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const absPath = join(rootDir, filePath);
|
|
124
|
+
let source;
|
|
125
|
+
try {
|
|
126
|
+
source = await readFile(absPath, 'utf-8');
|
|
127
|
+
} catch {
|
|
128
|
+
console.warn(`File not found: ${filePath}, skipping`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Verify file hasn't been manually modified
|
|
133
|
+
const currentHash = hashContent(source);
|
|
134
|
+
if (!manifest.isFileUnmodified(filePath, currentHash)) {
|
|
135
|
+
console.warn(`Warning: ${filePath} was modified after injection. Restoring anyway.`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = adapter.remove(source);
|
|
139
|
+
await writeFile(absPath, result.source, 'utf-8');
|
|
140
|
+
filesRestored++;
|
|
141
|
+
injectionsRemoved += result.removedCount;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Remove runtime files
|
|
145
|
+
const { unlink } = await import('node:fs/promises');
|
|
146
|
+
for (const runtimeFile of manifest.runtimeFiles) {
|
|
147
|
+
try {
|
|
148
|
+
await unlink(join(rootDir, runtimeFile));
|
|
149
|
+
} catch {
|
|
150
|
+
// Already gone
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { filesRestored, injectionsRemoved };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analyzes test results across multiple runs to classify tests as
|
|
3
|
+
* flaky, stable-pass, or always-failing.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object[]} results - Array of run results, each with:
|
|
6
|
+
* { runIndex, seed, exitCode, parsed, tests: [{ name, status }] }
|
|
7
|
+
* @returns {{ totalTests, flakyTests, stableTests, alwaysFailingTests }}
|
|
8
|
+
*/
|
|
9
|
+
export function analyzeFlakiness(results) {
|
|
10
|
+
// Build map: testName → { passedRuns: number[], failedRuns: number[] }
|
|
11
|
+
const testMap = new Map();
|
|
12
|
+
|
|
13
|
+
for (const run of results) {
|
|
14
|
+
if (!run.parsed || !run.tests) continue;
|
|
15
|
+
|
|
16
|
+
for (const test of run.tests) {
|
|
17
|
+
if (test.status === 'skipped') continue;
|
|
18
|
+
|
|
19
|
+
if (!testMap.has(test.name)) {
|
|
20
|
+
testMap.set(test.name, { file: test.file, passedRuns: [], failedRuns: [] });
|
|
21
|
+
}
|
|
22
|
+
const entry = testMap.get(test.name);
|
|
23
|
+
if (test.status === 'passed') {
|
|
24
|
+
entry.passedRuns.push(run.runIndex);
|
|
25
|
+
} else if (test.status === 'failed') {
|
|
26
|
+
entry.failedRuns.push(run.runIndex);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const flakyTests = [];
|
|
32
|
+
const stableTests = [];
|
|
33
|
+
const alwaysFailingTests = [];
|
|
34
|
+
|
|
35
|
+
for (const [name, entry] of testMap) {
|
|
36
|
+
const hasPasses = entry.passedRuns.length > 0;
|
|
37
|
+
const hasFailures = entry.failedRuns.length > 0;
|
|
38
|
+
|
|
39
|
+
if (hasPasses && hasFailures) {
|
|
40
|
+
const totalRuns = entry.passedRuns.length + entry.failedRuns.length;
|
|
41
|
+
flakyTests.push({
|
|
42
|
+
name,
|
|
43
|
+
file: entry.file,
|
|
44
|
+
passedRuns: entry.passedRuns,
|
|
45
|
+
failedRuns: entry.failedRuns,
|
|
46
|
+
flakyRate: entry.failedRuns.length / totalRuns,
|
|
47
|
+
});
|
|
48
|
+
} else if (hasPasses) {
|
|
49
|
+
stableTests.push({ name, file: entry.file, verdict: 'stable-pass' });
|
|
50
|
+
} else if (hasFailures) {
|
|
51
|
+
alwaysFailingTests.push({ name, file: entry.file, verdict: 'always-failing' });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Sort flaky tests by flaky rate descending
|
|
56
|
+
flakyTests.sort((a, b) => b.flakyRate - a.flakyRate);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
totalTests: testMap.size,
|
|
60
|
+
flakyTests,
|
|
61
|
+
stableTests,
|
|
62
|
+
alwaysFailingTests,
|
|
63
|
+
};
|
|
64
|
+
}
|