flake-monster 0.3.3 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli/commands/restore.js +46 -13
- package/src/core/engine.js +84 -0
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@ A source-to-source test hardener that finds flaky tests by injecting async delay
|
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
7
|
-
Automated tests run unrealistically fast. API calls resolve instantly against local mocks, database queries return in microseconds, commands are fired
|
|
7
|
+
Automated tests run unrealistically fast. API calls resolve instantly against local mocks, database queries return in microseconds, commands are fired faster than you can blink an eye, and every async operation completes in the exact same order every time. In production, none of that is true. Network latency varies, services respond unpredictably, computer performance varies, devices glitch, and users interact at human speed. Tests that pass in this perfectly-timed environment can hide real bugs, race conditions, missing `await`s, and unguarded state mutations, that only surface when timing shifts even slightly.
|
|
8
8
|
|
|
9
9
|
FlakeMonster closes that gap. It **deliberately injects async delays** between statements in your `async` functions and at the module top level (using top-level `await`), forcing the event loop to yield where it normally wouldn't. Tests that depend on everything happening in a precise order will start failing, and that's the point. A test that only passes because it runs too fast to trigger its own race condition **should not be passing**.
|
|
10
10
|
|
|
11
|
-
The goal isn't to slow your tests down. The goal is to
|
|
11
|
+
The goal isn't to slow your tests down. The goal is to increase the likelihood of timing glitches surfacing so you can catch flakes before they hit CI.
|
|
12
12
|
|
|
13
13
|
Every run uses a **deterministic seed**, so when a test fails you get output like:
|
|
14
14
|
|
package/package.json
CHANGED
|
@@ -61,20 +61,49 @@ export function registerRestoreCommand(program) {
|
|
|
61
61
|
const flakeDir = getFlakeMonsterDir(targetDir);
|
|
62
62
|
|
|
63
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
64
|
|
|
70
|
-
const
|
|
65
|
+
const mode = manifest ? manifest.mode : (config.mode || 'medium');
|
|
66
|
+
const profile = FlakeProfile.fromConfig({ ...config, mode });
|
|
71
67
|
const engine = new InjectorEngine(registry, profile);
|
|
68
|
+
const globs = config.include || ['**/*.js', '**/*.mjs'];
|
|
69
|
+
const exclude = config.exclude || ['**/node_modules/**', '**/dist/**', '**/build/**'];
|
|
70
|
+
|
|
71
|
+
if (!manifest && !options.recover) {
|
|
72
|
+
// No manifest — quick-scan to see if there's leftover injected code
|
|
73
|
+
const scanResults = await engine.scanByGlobs(targetDir, globs, exclude);
|
|
74
|
+
|
|
75
|
+
if (scanResults.length === 0) {
|
|
76
|
+
console.log('No manifest found and no injected code detected. Nothing to restore.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Injected code found — offer interactive recovery
|
|
81
|
+
let totalMatches = 0;
|
|
82
|
+
for (const { matches } of scanResults) totalMatches += matches.length;
|
|
83
|
+
|
|
84
|
+
console.log(`No manifest found, but detected ${totalMatches} injected line(s) across ${scanResults.length} file(s).`);
|
|
85
|
+
const proceed = await confirm('Run recovery to clean them up? (y/N) ');
|
|
86
|
+
if (!proceed) {
|
|
87
|
+
console.log('Aborted. No files were modified.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// User confirmed — fall through to recovery
|
|
92
|
+
options.recover = true;
|
|
93
|
+
}
|
|
72
94
|
|
|
73
95
|
if (options.recover) {
|
|
74
96
|
// Recovery mode: scan first, show results, then confirm
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
97
|
+
// Works with or without a manifest
|
|
98
|
+
let scanResults;
|
|
99
|
+
|
|
100
|
+
if (manifest) {
|
|
101
|
+
console.log('Recovery mode: scanning manifest files for injected lines...');
|
|
102
|
+
scanResults = await engine.scanAll(targetDir, manifest);
|
|
103
|
+
} else {
|
|
104
|
+
console.log('Scanning all source files...');
|
|
105
|
+
scanResults = await engine.scanByGlobs(targetDir, globs, exclude);
|
|
106
|
+
}
|
|
78
107
|
|
|
79
108
|
if (scanResults.length === 0) {
|
|
80
109
|
console.log('No injected lines found. Files appear clean.');
|
|
@@ -89,10 +118,14 @@ export function registerRestoreCommand(program) {
|
|
|
89
118
|
return;
|
|
90
119
|
}
|
|
91
120
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
if (manifest) {
|
|
122
|
+
const { filesRestored, injectionsRemoved } = await engine.restoreAll(targetDir, manifest);
|
|
123
|
+
await Manifest.delete(flakeDir);
|
|
124
|
+
console.log(`\n Recovered ${filesRestored} file(s), removed ${injectionsRemoved} line(s)`);
|
|
125
|
+
} else {
|
|
126
|
+
const { filesRestored, injectionsRemoved } = await engine.restoreByGlobs(targetDir, globs, exclude);
|
|
127
|
+
console.log(`\n Recovered ${filesRestored} file(s), removed ${injectionsRemoved} line(s)`);
|
|
128
|
+
}
|
|
96
129
|
} else {
|
|
97
130
|
const { filesRestored, injectionsRemoved } = await engine.restoreAll(targetDir, manifest);
|
|
98
131
|
await Manifest.delete(flakeDir);
|
package/src/core/engine.js
CHANGED
|
@@ -102,6 +102,90 @@ export class InjectorEngine {
|
|
|
102
102
|
return results;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Scan files by glob patterns for recovery matches (no manifest needed).
|
|
107
|
+
* Discovers files via fast-glob and routes them through registered adapters.
|
|
108
|
+
* @param {string} rootDir
|
|
109
|
+
* @param {string[]} globs - File patterns to scan
|
|
110
|
+
* @param {string[]} [exclude=[]] - Glob patterns to exclude
|
|
111
|
+
* @returns {Promise<{ file: string, matches: { line: number, content: string, reason: string }[] }[]>}
|
|
112
|
+
*/
|
|
113
|
+
async scanByGlobs(rootDir, globs, exclude = []) {
|
|
114
|
+
const results = [];
|
|
115
|
+
const files = await fg(globs, { cwd: rootDir, absolute: false, ignore: exclude });
|
|
116
|
+
|
|
117
|
+
for (const filePath of files) {
|
|
118
|
+
const adapter = this.registry.getAdapterForFile(filePath);
|
|
119
|
+
if (!adapter || !adapter.scan) continue;
|
|
120
|
+
|
|
121
|
+
const absPath = join(rootDir, filePath);
|
|
122
|
+
let source;
|
|
123
|
+
try {
|
|
124
|
+
source = await readFile(absPath, 'utf-8');
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const matches = adapter.scan(source);
|
|
130
|
+
if (matches.length > 0) {
|
|
131
|
+
results.push({ file: filePath, matches });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Remove injections from files discovered by glob patterns (no manifest needed).
|
|
140
|
+
* @param {string} rootDir
|
|
141
|
+
* @param {string[]} globs - File patterns to process
|
|
142
|
+
* @param {string[]} [exclude=[]] - Glob patterns to exclude
|
|
143
|
+
* @returns {Promise<{ filesRestored: number, injectionsRemoved: number }>}
|
|
144
|
+
*/
|
|
145
|
+
async restoreByGlobs(rootDir, globs, exclude = []) {
|
|
146
|
+
let filesRestored = 0;
|
|
147
|
+
let injectionsRemoved = 0;
|
|
148
|
+
|
|
149
|
+
const files = await fg(globs, { cwd: rootDir, absolute: false, ignore: exclude });
|
|
150
|
+
|
|
151
|
+
for (const filePath of files) {
|
|
152
|
+
const adapter = this.registry.getAdapterForFile(filePath);
|
|
153
|
+
if (!adapter) continue;
|
|
154
|
+
|
|
155
|
+
const absPath = join(rootDir, filePath);
|
|
156
|
+
let source;
|
|
157
|
+
try {
|
|
158
|
+
source = await readFile(absPath, 'utf-8');
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const result = adapter.remove(source);
|
|
164
|
+
if (result.removedCount === 0) continue;
|
|
165
|
+
|
|
166
|
+
await writeFile(absPath, result.source, 'utf-8');
|
|
167
|
+
filesRestored++;
|
|
168
|
+
injectionsRemoved += result.removedCount;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Clean up runtime files
|
|
172
|
+
const { unlink } = await import('node:fs/promises');
|
|
173
|
+
const runtimeFiles = await fg(['**/flake-monster.runtime.*'], {
|
|
174
|
+
cwd: rootDir,
|
|
175
|
+
absolute: false,
|
|
176
|
+
ignore: exclude,
|
|
177
|
+
});
|
|
178
|
+
for (const rf of runtimeFiles) {
|
|
179
|
+
try {
|
|
180
|
+
await unlink(join(rootDir, rf));
|
|
181
|
+
} catch {
|
|
182
|
+
// Already gone
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { filesRestored, injectionsRemoved };
|
|
187
|
+
}
|
|
188
|
+
|
|
105
189
|
/**
|
|
106
190
|
* Remove all injections from files listed in the manifest.
|
|
107
191
|
* @param {string} rootDir
|