flake-monster 0.4.0 → 0.4.3

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
@@ -57,7 +57,16 @@ flake-monster test --runs 5 --cmd "npm test"
57
57
  flake-monster test --cmd "npx playwright test" "src/**/*.js"
58
58
  ```
59
59
 
60
- **CI flake gate**, Block merges that introduce flaky tests.
60
+ **CI flake gate**, Block merges that introduce flaky tests. Use the [GitHub Action](https://github.com/growthboot/FlakeMonster-Action) for automatic PR comments:
61
+
62
+ ```yaml
63
+ # .github/workflows/flake-monster.yml
64
+ - uses: growthboot/FlakeMonster-Action@v1
65
+ with:
66
+ test-command: 'npm test'
67
+ ```
68
+
69
+ Or run the CLI directly in any CI environment:
61
70
 
62
71
  ```bash
63
72
  flake-monster test --runs 10 --cmd "npm test"
@@ -276,6 +285,10 @@ Recovery mode: scanning for injected lines...
276
285
 
277
286
  You see exactly which lines will be removed and why (`stamp`, `ident`, or `import`), then confirm before any files are modified.
278
287
 
288
+ ## Changelog
289
+
290
+ See [VERSIONS.md](VERSIONS.md) for detailed version history.
291
+
279
292
  ## License
280
293
 
281
294
  MIT
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flake-monster",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "Source-to-source test hardener that injects async delays to surface flaky tests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,14 +15,14 @@ const RUNTIME_FILENAME = 'flake-monster.runtime.js';
15
15
  * @returns {string}
16
16
  */
17
17
  function computeRuntimeImportPath(filePath) {
18
- // Count directory depth
19
- const parts = filePath.split('/').filter(Boolean);
20
- if (parts.length <= 1) {
18
+ const normalized = posix.normalize(filePath);
19
+ const dir = posix.dirname(normalized);
20
+ if (dir === '.') {
21
21
  // File is at root level
22
22
  return `./${RUNTIME_FILENAME}`;
23
23
  }
24
- // Go up (parts.length - 1) directories
25
- const ups = '../'.repeat(parts.length - 1);
24
+ const depth = dir.split('/').length;
25
+ const ups = '../'.repeat(depth);
26
26
  return `${ups}${RUNTIME_FILENAME}`;
27
27
  }
28
28
 
@@ -1,12 +1,24 @@
1
1
  import { resolve } from 'node:path';
2
+ import { createInterface } from 'node:readline';
2
3
  import { AdapterRegistry } from '../../adapters/registry.js';
3
4
  import { createJavaScriptAdapter } from '../../adapters/javascript/index.js';
4
5
  import { InjectorEngine } from '../../core/engine.js';
5
6
  import { FlakeProfile } from '../../core/profile.js';
6
7
  import { parseSeed } from '../../core/seed.js';
7
8
  import { ProjectWorkspace, getFlakeMonsterDir } from '../../core/workspace.js';
9
+ import { Manifest } from '../../core/manifest.js';
8
10
  import { loadConfig, mergeWithCliOptions } from '../../core/config.js';
9
11
 
12
+ function confirm(question) {
13
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
14
+ return new Promise((resolve) => {
15
+ rl.question(question, (answer) => {
16
+ rl.close();
17
+ resolve(answer.trim().toLowerCase() === 'y');
18
+ });
19
+ });
20
+ }
21
+
10
22
  export function registerInjectCommand(program) {
11
23
  program
12
24
  .command('inject')
@@ -31,6 +43,23 @@ export function registerInjectCommand(program) {
31
43
  registry.register(createJavaScriptAdapter());
32
44
  const engine = new InjectorEngine(registry, profile);
33
45
 
46
+ // Guard against double injection
47
+ const flakeDirCheck = getFlakeMonsterDir(projectRoot);
48
+ const existingManifest = await Manifest.load(flakeDirCheck);
49
+ if (existingManifest) {
50
+ console.log(
51
+ `Active injection detected (seed: ${existingManifest.seed}, mode: ${existingManifest.mode}, injected at: ${existingManifest.createdAt}).`
52
+ );
53
+ const proceed = await confirm('Restore source files before re-injecting? (y/N) ');
54
+ if (!proceed) {
55
+ console.log('Aborted. Run `flake-monster restore` manually to clean up.');
56
+ process.exit(1);
57
+ }
58
+ await engine.restoreAll(projectRoot, existingManifest);
59
+ await Manifest.delete(flakeDirCheck);
60
+ console.log('Previous injections removed. Proceeding with fresh injection.\n');
61
+ }
62
+
34
63
  const useWorkspace = options.workspace;
35
64
  let targetDir = projectRoot;
36
65
  let workspace = null;
@@ -5,12 +5,13 @@ import { InjectorEngine } from '../../core/engine.js';
5
5
  import { FlakeProfile } from '../../core/profile.js';
6
6
  import { parseSeed, deriveSeed } from '../../core/seed.js';
7
7
  import { ProjectWorkspace, getFlakeMonsterDir, execAsync } from '../../core/workspace.js';
8
+ import { Manifest } from '../../core/manifest.js';
8
9
  import { loadConfig, mergeWithCliOptions } from '../../core/config.js';
9
10
  import { Reporter } from '../../core/reporter.js';
10
11
  import { detectRunner, parseTestOutput } from '../../core/parsers/index.js';
11
12
  import { analyzeFlakiness } from '../../core/flake-analyzer.js';
12
13
  import * as terminal from '../terminal.js';
13
- import { Spinner } from '../terminal.js';
14
+ import { StickyLine } from '../terminal.js';
14
15
 
15
16
  export function registerTestCommand(program) {
16
17
  program
@@ -53,6 +54,18 @@ export function registerTestCommand(program) {
53
54
  const engine = new InjectorEngine(registry, profile);
54
55
  const reporter = new Reporter({ quiet: jsonOutput, terminal });
55
56
 
57
+ // Guard against stale injection from a previous interrupted run
58
+ if (inPlace) {
59
+ const flakeDir = getFlakeMonsterDir(projectRoot);
60
+ const staleManifest = await Manifest.load(flakeDir);
61
+ if (staleManifest) {
62
+ reporter.log(`Stale injection detected (seed: ${staleManifest.seed}, mode: ${staleManifest.mode}). Restoring before proceeding...`);
63
+ await engine.restoreAll(projectRoot, staleManifest);
64
+ await Manifest.delete(flakeDir);
65
+ reporter.log('Stale injections removed. Source files are clean.\n');
66
+ }
67
+ }
68
+
56
69
  reporter.log(`FlakeMonster test harness`);
57
70
  reporter.log(` Runs: ${runs} | Mode: ${profile.mode} | Base seed: ${baseSeed}`);
58
71
  reporter.log(` Command: ${testCmd}`);
@@ -85,17 +98,33 @@ export function registerTestCommand(program) {
85
98
  lastManifest = manifest;
86
99
  reporter.printInjectionStats(manifest);
87
100
 
88
- // Run tests with spinner
89
- const spinner = new Spinner(`Run ${i + 1}/${runs} seed=${runSeed}`);
90
- if (!reporter.quiet) spinner.start();
101
+ // Run tests — stream output with sticky status line
102
+ const passed = results.filter(r => r.exitCode === 0).length;
103
+ const failed = results.length - passed;
104
+ const sticky = new StickyLine();
105
+ const start = Date.now();
106
+ let stickyTimer;
107
+
108
+ if (!reporter.quiet) {
109
+ const stickyContent = () => {
110
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
111
+ const bar = terminal.progressBar(i, runs);
112
+ return terminal.dim(` ${bar} ${passed} passed ${failed} failed `) + `Run ${i + 1}/${runs} ${elapsed}s`;
113
+ };
114
+ sticky.start(stickyContent());
115
+ stickyTimer = setInterval(() => sticky.update(stickyContent()), 200);
116
+ }
91
117
 
92
118
  let exitCode, stdout, stderr, durationMs;
93
- const start = Date.now();
94
119
  try {
95
- ({ exitCode, stdout, stderr } = await execAsync(testCmd, projectRoot));
120
+ ({ exitCode, stdout, stderr } = await execAsync(testCmd, projectRoot, {
121
+ onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
122
+ onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
123
+ }));
96
124
  durationMs = Date.now() - start;
97
125
  } finally {
98
- if (!reporter.quiet) spinner.stop();
126
+ if (stickyTimer) clearInterval(stickyTimer);
127
+ sticky.clear();
99
128
  }
100
129
 
101
130
  // Parse test output if we have a runner
@@ -114,12 +143,8 @@ export function registerTestCommand(program) {
114
143
  tests: parsed?.tests ?? [],
115
144
  };
116
145
 
117
- reporter.printRunResult(result, runs);
118
146
  results.push(result);
119
-
120
- if (i < runs - 1) {
121
- reporter.printProgressTally(results, runs);
122
- }
147
+ reporter.printRunResult(result, runs);
123
148
  } else {
124
149
  // Create workspace
125
150
  const workspace = new ProjectWorkspace({
@@ -134,21 +159,37 @@ export function registerTestCommand(program) {
134
159
  await manifest.save(flakeDir);
135
160
  reporter.printInjectionStats(manifest);
136
161
 
137
- // Run tests with spinner
138
- const spinner = new Spinner(`Run ${i + 1}/${runs} seed=${runSeed}`);
139
- if (!reporter.quiet) spinner.start();
162
+ // Run tests — stream output with sticky status line
163
+ const passed = results.filter(r => r.exitCode === 0).length;
164
+ const failed = results.length - passed;
165
+ const sticky = new StickyLine();
166
+ const start = Date.now();
167
+ let stickyTimer;
168
+
169
+ if (!reporter.quiet) {
170
+ const stickyContent = () => {
171
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
172
+ const bar = terminal.progressBar(i, runs);
173
+ return terminal.dim(` ${bar} ${passed} passed ${failed} failed `) + `Run ${i + 1}/${runs} ${elapsed}s`;
174
+ };
175
+ sticky.start(stickyContent());
176
+ stickyTimer = setInterval(() => sticky.update(stickyContent()), 200);
177
+ }
140
178
 
141
179
  let exitCode, stdout, stderr, durationMs;
142
- const start = Date.now();
143
180
  try {
144
- ({ exitCode, stdout, stderr } = await workspace.execAsync(testCmd));
181
+ ({ exitCode, stdout, stderr } = await workspace.execAsync(testCmd, {
182
+ onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
183
+ onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
184
+ }));
145
185
  durationMs = Date.now() - start;
146
186
  } finally {
147
- if (!reporter.quiet) spinner.stop();
187
+ if (stickyTimer) clearInterval(stickyTimer);
188
+ sticky.clear();
148
189
  }
149
190
 
150
- const failed = exitCode !== 0;
151
- const shouldKeep = (failed && options.keepOnFail) || options.keepAll;
191
+ const testFailed = exitCode !== 0;
192
+ const shouldKeep = (testFailed && options.keepOnFail) || options.keepAll;
152
193
 
153
194
  // Parse test output if we have a runner
154
195
  const parsed = runner ? parseTestOutput(runner, stdout) : null;
@@ -166,12 +207,8 @@ export function registerTestCommand(program) {
166
207
  tests: parsed?.tests ?? [],
167
208
  };
168
209
 
169
- reporter.printRunResult(result, runs);
170
210
  results.push(result);
171
-
172
- if (i < runs - 1) {
173
- reporter.printProgressTally(results, runs);
174
- }
211
+ reporter.printRunResult(result, runs);
175
212
 
176
213
  // Cleanup
177
214
  if (!shouldKeep) {
package/src/cli/index.js CHANGED
@@ -1,15 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { resolve, dirname } from 'node:path';
1
4
  import { Command } from 'commander';
2
5
  import { registerInjectCommand } from './commands/inject.js';
3
6
  import { registerRestoreCommand } from './commands/restore.js';
4
7
  import { registerTestCommand } from './commands/test.js';
5
8
 
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
11
+
6
12
  export function createCli() {
7
13
  const program = new Command();
8
14
 
9
15
  program
10
16
  .name('flake-monster')
11
17
  .description('Source-to-source test hardener, injects async delays to surface flaky tests')
12
- .version('0.4.0');
18
+ .version(pkg.version);
13
19
 
14
20
  registerInjectCommand(program);
15
21
  registerRestoreCommand(program);
@@ -96,62 +96,91 @@ export function box(lines) {
96
96
  return [top, ...body, bottom].join('\n');
97
97
  }
98
98
 
99
- // ── Spinner ───────────────────────────────────────────────────────────
100
-
101
- const SPINNER_FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
102
- const SPINNER_INTERVAL = 80; // ms
99
+ // ── Sticky line ───────────────────────────────────────────────────────
103
100
 
104
101
  /**
105
- * A simple terminal spinner with elapsed time display.
106
- * On non-TTY terminals, prints a single static line instead.
102
+ * A persistent status line that stays at the bottom of the terminal
103
+ * while other output scrolls above it.
104
+ *
105
+ * On non-TTY terminals, does nothing — output flows normally.
107
106
  */
108
- export class Spinner {
107
+ export class StickyLine {
108
+ constructor() {
109
+ this._content = '';
110
+ this._active = false;
111
+ }
112
+
109
113
  /**
110
- * @param {string} message - Text to show next to the spinner
114
+ * Start showing the sticky line.
115
+ * @param {string} content - Initial content
111
116
  */
112
- constructor(message) {
113
- this.message = message;
114
- this._timer = null;
115
- this._frame = 0;
116
- this._startTime = null;
117
+ start(content) {
118
+ this._active = true;
119
+ this._content = content;
120
+ if (isTTY()) {
121
+ process.stdout.write(this._content);
122
+ }
117
123
  }
118
124
 
119
- /** Start the spinner animation. */
120
- start() {
121
- this._startTime = Date.now();
125
+ /**
126
+ * Update the sticky line content (e.g. elapsed timer).
127
+ * @param {string} content
128
+ */
129
+ update(content) {
130
+ this._content = content;
131
+ if (this._active && isTTY()) {
132
+ process.stdout.write('\r\x1b[K' + this._content);
133
+ }
134
+ }
122
135
 
123
- if (!isTTY()) {
124
- process.stdout.write(` ${this.message} ...\n`);
136
+ /**
137
+ * Write output above the sticky line.
138
+ * Clears the sticky, writes the chunk, then re-renders.
139
+ * @param {Buffer|string} chunk
140
+ */
141
+ writeAbove(chunk) {
142
+ if (!this._active || !isTTY()) {
143
+ process.stdout.write(chunk);
125
144
  return;
126
145
  }
127
-
128
- this._render();
129
- this._timer = setInterval(() => this._render(), SPINNER_INTERVAL);
146
+ // Clear sticky line
147
+ process.stdout.write('\r\x1b[K');
148
+ // Write the actual output
149
+ process.stdout.write(chunk);
150
+ // Ensure sticky gets its own line
151
+ const str = chunk.toString();
152
+ if (str.length > 0 && !str.endsWith('\n')) {
153
+ process.stdout.write('\n');
154
+ }
155
+ // Re-render sticky
156
+ process.stdout.write(this._content);
130
157
  }
131
158
 
132
- /** Stop the spinner and clear the line. Returns elapsed ms. */
133
- stop() {
134
- const elapsed = this._startTime ? Date.now() - this._startTime : 0;
135
-
136
- if (this._timer) {
137
- clearInterval(this._timer);
138
- this._timer = null;
159
+ /**
160
+ * Write stderr output above the sticky line.
161
+ * @param {Buffer|string} chunk
162
+ */
163
+ writeAboveStderr(chunk) {
164
+ if (!this._active || !isTTY()) {
165
+ process.stderr.write(chunk);
166
+ return;
139
167
  }
140
-
141
- if (isTTY()) {
142
- // Clear the spinner line
143
- process.stdout.write('\r\x1b[K');
168
+ // Clear sticky on stdout, write stderr, re-render sticky
169
+ process.stdout.write('\r\x1b[K');
170
+ process.stderr.write(chunk);
171
+ const str = chunk.toString();
172
+ if (str.length > 0 && !str.endsWith('\n')) {
173
+ process.stderr.write('\n');
144
174
  }
145
-
146
- return elapsed;
175
+ process.stdout.write(this._content);
147
176
  }
148
177
 
149
- /** @private */
150
- _render() {
151
- const frame = SPINNER_FRAMES[this._frame % SPINNER_FRAMES.length];
152
- this._frame++;
153
- const elapsed = ((Date.now() - this._startTime) / 1000).toFixed(1);
154
- const elapsedStr = dim(`(${elapsed}s)`);
155
- process.stdout.write(`\r ${frame} ${this.message} ${elapsedStr}\x1b[K`);
178
+ /** Clear the sticky line and deactivate. */
179
+ clear() {
180
+ if (this._active && isTTY()) {
181
+ process.stdout.write('\r\x1b[K');
182
+ }
183
+ this._active = false;
184
+ this._content = '';
156
185
  }
157
186
  }
@@ -1,10 +1,12 @@
1
1
  import { parseJestOutput } from './jest.js';
2
2
  import { parseNodeTestOutput } from './node-test.js';
3
+ import { parsePlaywrightOutput } from './playwright.js';
3
4
  import { parseTapOutput } from './tap.js';
4
5
 
5
6
  const parsers = {
6
7
  jest: parseJestOutput,
7
8
  'node-test': parseNodeTestOutput,
9
+ playwright: parsePlaywrightOutput,
8
10
  tap: parseTapOutput,
9
11
  };
10
12
 
@@ -19,6 +21,9 @@ export function detectRunner(testCommand) {
19
21
  if (testCommand.includes('node --test') || testCommand.includes('node:test')) {
20
22
  return 'node-test';
21
23
  }
24
+ if (testCommand.includes('playwright')) {
25
+ return 'playwright';
26
+ }
22
27
  return 'tap';
23
28
  }
24
29
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Parses Playwright JSON reporter output (from `--reporter=json`).
3
+ *
4
+ * Playwright JSON schema:
5
+ * {
6
+ * suites: [{
7
+ * title: string,
8
+ * file: string,
9
+ * suites: [...], // nested suites
10
+ * specs: [{
11
+ * title: string,
12
+ * file: string,
13
+ * tests: [{
14
+ * projectName: string,
15
+ * results: [{
16
+ * status: "passed" | "failed" | "timedOut" | "skipped" | "interrupted",
17
+ * duration: number (ms),
18
+ * error?: { message: string, stack?: string }
19
+ * }],
20
+ * status: "expected" | "unexpected" | "flaky" | "skipped"
21
+ * }]
22
+ * }]
23
+ * }]
24
+ * }
25
+ *
26
+ * Each spec may have multiple tests (one per project/browser).
27
+ * Each test may have multiple results (retries), but we use the
28
+ * overall test.status to determine passed/failed/skipped.
29
+ * Duration is summed from all results for that test.
30
+ */
31
+ export function parsePlaywrightOutput(stdout) {
32
+ try {
33
+ const data = JSON.parse(stdout);
34
+ if (!data.suites || !Array.isArray(data.suites)) {
35
+ return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
36
+ }
37
+
38
+ const tests = [];
39
+ collectTests(data.suites, [], tests);
40
+
41
+ return {
42
+ parsed: true,
43
+ tests,
44
+ totalPassed: tests.filter(t => t.status === 'passed').length,
45
+ totalFailed: tests.filter(t => t.status === 'failed').length,
46
+ totalSkipped: tests.filter(t => t.status === 'skipped').length,
47
+ };
48
+ } catch {
49
+ return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Recursively walk suites and collect leaf test entries.
55
+ */
56
+ function collectTests(suites, titlePath, out) {
57
+ for (const suite of suites) {
58
+ const currentPath = suite.title ? [...titlePath, suite.title] : titlePath;
59
+
60
+ for (const spec of suite.specs || []) {
61
+ for (const test of spec.tests || []) {
62
+ const nameParts = [...currentPath, spec.title].filter(Boolean);
63
+ const name = nameParts.join(' > ');
64
+
65
+ const status =
66
+ test.status === 'skipped' ? 'skipped' :
67
+ test.status === 'expected' ? 'passed' : 'failed';
68
+
69
+ const results = test.results || [];
70
+ const durationMs = results.reduce((sum, r) => sum + (r.duration ?? 0), 0);
71
+
72
+ // Find the first error message from results
73
+ let failureMessage = null;
74
+ if (status === 'failed') {
75
+ for (const r of results) {
76
+ if (r.error) {
77
+ failureMessage = r.error.message || r.error.stack || String(r.error);
78
+ break;
79
+ }
80
+ }
81
+ }
82
+
83
+ out.push({
84
+ name,
85
+ file: spec.file || suite.file || null,
86
+ status,
87
+ durationMs: results.length > 0 ? durationMs : null,
88
+ failureMessage,
89
+ });
90
+ }
91
+ }
92
+
93
+ // Recurse into nested suites
94
+ if (suite.suites) {
95
+ collectTests(suite.suites, currentPath, out);
96
+ }
97
+ }
98
+ }
@@ -158,11 +158,13 @@ export function getFlakeMonsterDir(projectRoot) {
158
158
  * @param {Object} [options]
159
159
  * @param {number} [options.timeout] - ms before killing the process
160
160
  * @param {Object} [options.env] - additional env vars
161
+ * @param {Function} [options.onStdout] - Callback for each stdout chunk (receives Buffer)
162
+ * @param {Function} [options.onStderr] - Callback for each stderr chunk (receives Buffer)
161
163
  * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
162
164
  */
163
165
  export function execAsync(command, cwd, options = {}) {
164
166
  return new Promise((resolve) => {
165
- const { timeout, env } = options;
167
+ const { timeout, env, onStdout, onStderr } = options;
166
168
  const child = spawn(command, {
167
169
  cwd,
168
170
  shell: true,
@@ -173,8 +175,14 @@ export function execAsync(command, cwd, options = {}) {
173
175
  const stdoutChunks = [];
174
176
  const stderrChunks = [];
175
177
 
176
- child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
177
- child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
178
+ child.stdout.on('data', (chunk) => {
179
+ stdoutChunks.push(chunk);
180
+ if (onStdout) onStdout(chunk);
181
+ });
182
+ child.stderr.on('data', (chunk) => {
183
+ stderrChunks.push(chunk);
184
+ if (onStderr) onStderr(chunk);
185
+ });
178
186
 
179
187
  let timer;
180
188
  if (timeout) {