flake-monster 0.3.3 → 0.3.5

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 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 fasters than you can blink and 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.
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 increasing flake likelyhood random timing glitches so that you can catch the test flakes on your first tests.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flake-monster",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Source-to-source test hardener that injects async delays to surface flaky tests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,20 +61,54 @@ 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 profile = FlakeProfile.fromConfig({ ...config, mode: manifest.mode });
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
+ // When no manifest exists, recovery must scan broadly to find all
69
+ // leftover injections — the narrow config.include default (e.g. src/**)
70
+ // may not cover files that were injected with custom CLI globs.
71
+ const globs = manifest
72
+ ? (config.include || ['**/*.js', '**/*.mjs'])
73
+ : ['**/*.js', '**/*.mjs'];
74
+ const exclude = config.exclude || ['**/node_modules/**', '**/dist/**', '**/build/**'];
75
+
76
+ if (!manifest && !options.recover) {
77
+ // No manifest — quick-scan to see if there's leftover injected code
78
+ const scanResults = await engine.scanByGlobs(targetDir, globs, exclude);
79
+
80
+ if (scanResults.length === 0) {
81
+ console.log('No manifest found and no injected code detected. Nothing to restore.');
82
+ return;
83
+ }
84
+
85
+ // Injected code found — offer interactive recovery
86
+ let totalMatches = 0;
87
+ for (const { matches } of scanResults) totalMatches += matches.length;
88
+
89
+ console.log(`No manifest found, but detected ${totalMatches} injected line(s) across ${scanResults.length} file(s).`);
90
+ const proceed = await confirm('Run recovery to clean them up? (y/N) ');
91
+ if (!proceed) {
92
+ console.log('Aborted. No files were modified.');
93
+ return;
94
+ }
95
+
96
+ // User confirmed — fall through to recovery
97
+ options.recover = true;
98
+ }
72
99
 
73
100
  if (options.recover) {
74
101
  // 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);
102
+ // Works with or without a manifest
103
+ let scanResults;
104
+
105
+ if (manifest) {
106
+ console.log('Recovery mode: scanning manifest files for injected lines...');
107
+ scanResults = await engine.scanAll(targetDir, manifest);
108
+ } else {
109
+ console.log('Scanning all source files...');
110
+ scanResults = await engine.scanByGlobs(targetDir, globs, exclude);
111
+ }
78
112
 
79
113
  if (scanResults.length === 0) {
80
114
  console.log('No injected lines found. Files appear clean.');
@@ -89,10 +123,14 @@ export function registerRestoreCommand(program) {
89
123
  return;
90
124
  }
91
125
 
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)`);
126
+ if (manifest) {
127
+ const { filesRestored, injectionsRemoved } = await engine.restoreAll(targetDir, manifest);
128
+ await Manifest.delete(flakeDir);
129
+ console.log(`\n Recovered ${filesRestored} file(s), removed ${injectionsRemoved} line(s)`);
130
+ } else {
131
+ const { filesRestored, injectionsRemoved } = await engine.restoreByGlobs(targetDir, globs, exclude);
132
+ console.log(`\n Recovered ${filesRestored} file(s), removed ${injectionsRemoved} line(s)`);
133
+ }
96
134
  } else {
97
135
  const { filesRestored, injectionsRemoved } = await engine.restoreAll(targetDir, manifest);
98
136
  await Manifest.delete(flakeDir);
@@ -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