flake-monster 0.3.2 → 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 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.2",
3
+ "version": "0.3.4",
4
4
  "description": "Source-to-source test hardener that injects async delays to surface flaky tests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -405,13 +405,20 @@ export function computeRuntimeImportInsertion(ast, source, runtimeImportPath) {
405
405
  }
406
406
  }
407
407
 
408
- // Find the newline after the last import (or start of file)
409
- let insertOffset = lastImportEnd;
410
- while (insertOffset < source.length && source[insertOffset] !== '\n') {
411
- insertOffset++;
412
- }
413
- if (insertOffset < source.length) {
414
- insertOffset++; // past the \n
408
+ let insertOffset;
409
+ if (lastImportEnd === 0) {
410
+ // No existing imports insert at the very start of the file
411
+ // (not after the first line, which may be inside a block comment)
412
+ insertOffset = 0;
413
+ } else {
414
+ // Find the newline after the last import
415
+ insertOffset = lastImportEnd;
416
+ while (insertOffset < source.length && source[insertOffset] !== '\n') {
417
+ insertOffset++;
418
+ }
419
+ if (insertOffset < source.length) {
420
+ insertOffset++; // past the \n
421
+ }
415
422
  }
416
423
 
417
424
  const text = `import { ${DELAY_OBJECT} } from '${runtimeImportPath}';\n`;
@@ -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 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
+ 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
- console.log('Recovery mode: scanning for injected lines...');
76
-
77
- const scanResults = await engine.scanAll(targetDir, manifest);
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
- 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)`);
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);
@@ -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