flake-monster 0.3.5 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flake-monster",
3
- "version": "0.3.5",
3
+ "version": "0.4.1",
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,33 +1,16 @@
1
1
  import { resolve } from 'node:path';
2
- import { execSync } from 'node:child_process';
3
2
  import { AdapterRegistry } from '../../adapters/registry.js';
4
3
  import { createJavaScriptAdapter } from '../../adapters/javascript/index.js';
5
4
  import { InjectorEngine } from '../../core/engine.js';
6
5
  import { FlakeProfile } from '../../core/profile.js';
7
6
  import { parseSeed, deriveSeed } from '../../core/seed.js';
8
- import { ProjectWorkspace, getFlakeMonsterDir } from '../../core/workspace.js';
7
+ import { ProjectWorkspace, getFlakeMonsterDir, execAsync } from '../../core/workspace.js';
9
8
  import { loadConfig, mergeWithCliOptions } from '../../core/config.js';
10
9
  import { Reporter } from '../../core/reporter.js';
11
10
  import { detectRunner, parseTestOutput } from '../../core/parsers/index.js';
12
11
  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
- }
12
+ import * as terminal from '../terminal.js';
13
+ import { StickyLine } from '../terminal.js';
31
14
 
32
15
  export function registerTestCommand(program) {
33
16
  program
@@ -68,7 +51,7 @@ export function registerTestCommand(program) {
68
51
  const registry = new AdapterRegistry();
69
52
  registry.register(createJavaScriptAdapter());
70
53
  const engine = new InjectorEngine(registry, profile);
71
- const reporter = new Reporter({ quiet: jsonOutput });
54
+ const reporter = new Reporter({ quiet: jsonOutput, terminal });
72
55
 
73
56
  reporter.log(`FlakeMonster test harness`);
74
57
  reporter.log(` Runs: ${runs} | Mode: ${profile.mode} | Base seed: ${baseSeed}`);
@@ -82,6 +65,7 @@ export function registerTestCommand(program) {
82
65
  }
83
66
  reporter.log('');
84
67
 
68
+ const harnessTotalStart = Date.now();
85
69
  const results = [];
86
70
  let lastManifest = null;
87
71
 
@@ -99,11 +83,36 @@ export function registerTestCommand(program) {
99
83
  const flakeDir = getFlakeMonsterDir(projectRoot);
100
84
  await manifest.save(flakeDir);
101
85
  lastManifest = manifest;
86
+ reporter.printInjectionStats(manifest);
102
87
 
103
- // Run tests in project root
88
+ // Run tests stream output with sticky status line
89
+ const passed = results.filter(r => r.exitCode === 0).length;
90
+ const failed = results.length - passed;
91
+ const sticky = new StickyLine();
104
92
  const start = Date.now();
105
- const { exitCode, stdout, stderr } = execInDir(testCmd, projectRoot);
106
- const durationMs = Date.now() - start;
93
+ let stickyTimer;
94
+
95
+ if (!reporter.quiet) {
96
+ const stickyContent = () => {
97
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
98
+ const bar = terminal.progressBar(i, runs);
99
+ return terminal.dim(` ${bar} ${passed} passed ${failed} failed `) + `Run ${i + 1}/${runs} ${elapsed}s`;
100
+ };
101
+ sticky.start(stickyContent());
102
+ stickyTimer = setInterval(() => sticky.update(stickyContent()), 200);
103
+ }
104
+
105
+ let exitCode, stdout, stderr, durationMs;
106
+ try {
107
+ ({ exitCode, stdout, stderr } = await execAsync(testCmd, projectRoot, {
108
+ onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
109
+ onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
110
+ }));
111
+ durationMs = Date.now() - start;
112
+ } finally {
113
+ if (stickyTimer) clearInterval(stickyTimer);
114
+ sticky.clear();
115
+ }
107
116
 
108
117
  // Parse test output if we have a runner
109
118
  const parsed = runner ? parseTestOutput(runner, stdout) : null;
@@ -122,7 +131,6 @@ export function registerTestCommand(program) {
122
131
  };
123
132
 
124
133
  reporter.printRunResult(result, runs);
125
- results.push(result);
126
134
  } else {
127
135
  // Create workspace
128
136
  const workspace = new ProjectWorkspace({
@@ -135,14 +143,39 @@ export function registerTestCommand(program) {
135
143
  const manifest = await engine.injectAll(workspace.root, globs, runSeed, merged.exclude);
136
144
  const flakeDir = getFlakeMonsterDir(workspace.root);
137
145
  await manifest.save(flakeDir);
146
+ reporter.printInjectionStats(manifest);
138
147
 
139
- // Run tests
148
+ // Run tests — stream output with sticky status line
149
+ const passed = results.filter(r => r.exitCode === 0).length;
150
+ const failed = results.length - passed;
151
+ const sticky = new StickyLine();
140
152
  const start = Date.now();
141
- const { exitCode, stdout, stderr } = workspace.exec(testCmd);
142
- const durationMs = Date.now() - start;
153
+ let stickyTimer;
154
+
155
+ if (!reporter.quiet) {
156
+ const stickyContent = () => {
157
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
158
+ const bar = terminal.progressBar(i, runs);
159
+ return terminal.dim(` ${bar} ${passed} passed ${failed} failed `) + `Run ${i + 1}/${runs} ${elapsed}s`;
160
+ };
161
+ sticky.start(stickyContent());
162
+ stickyTimer = setInterval(() => sticky.update(stickyContent()), 200);
163
+ }
164
+
165
+ let exitCode, stdout, stderr, durationMs;
166
+ try {
167
+ ({ exitCode, stdout, stderr } = await workspace.execAsync(testCmd, {
168
+ onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
169
+ onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
170
+ }));
171
+ durationMs = Date.now() - start;
172
+ } finally {
173
+ if (stickyTimer) clearInterval(stickyTimer);
174
+ sticky.clear();
175
+ }
143
176
 
144
- const failed = exitCode !== 0;
145
- const shouldKeep = (failed && options.keepOnFail) || options.keepAll;
177
+ const testFailed = exitCode !== 0;
178
+ const shouldKeep = (testFailed && options.keepOnFail) || options.keepAll;
146
179
 
147
180
  // Parse test output if we have a runner
148
181
  const parsed = runner ? parseTestOutput(runner, stdout) : null;
@@ -161,7 +194,6 @@ export function registerTestCommand(program) {
161
194
  };
162
195
 
163
196
  reporter.printRunResult(result, runs);
164
- results.push(result);
165
197
 
166
198
  // Cleanup
167
199
  if (!shouldKeep) {
@@ -172,12 +204,13 @@ export function registerTestCommand(program) {
172
204
 
173
205
  // Restore source files after all in-place runs
174
206
  if (inPlace && lastManifest) {
175
- await engine.restoreAll(projectRoot, lastManifest);
176
- reporter.log('\n Source files restored to original state.');
207
+ const restoreResult = await engine.restoreAll(projectRoot, lastManifest);
208
+ reporter.printRestorationResult(restoreResult);
177
209
  }
178
210
 
179
211
  // Run flakiness analysis if we have parsed results
180
212
  const analysis = runner ? analyzeFlakiness(results) : null;
213
+ const totalElapsedMs = Date.now() - harnessTotalStart;
181
214
 
182
215
  if (jsonOutput) {
183
216
  // JSON output for CI consumption
@@ -197,7 +230,7 @@ export function registerTestCommand(program) {
197
230
  };
198
231
  console.log(JSON.stringify(output));
199
232
  } else {
200
- reporter.summarize(results, runs);
233
+ reporter.summarize(results, runs, analysis, totalElapsedMs);
201
234
  }
202
235
 
203
236
  // Exit with failure if any run failed
package/src/cli/index.js CHANGED
@@ -9,7 +9,7 @@ export function createCli() {
9
9
  program
10
10
  .name('flake-monster')
11
11
  .description('Source-to-source test hardener, injects async delays to surface flaky tests')
12
- .version('0.3.1');
12
+ .version('0.4.0');
13
13
 
14
14
  registerInjectCommand(program);
15
15
  registerRestoreCommand(program);
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Terminal utilities for rich CLI output.
3
+ * Zero dependencies — raw ANSI escape codes + process.stdout.
4
+ */
5
+
6
+ // ── Color support detection ───────────────────────────────────────────
7
+
8
+ let _colorEnabled = null;
9
+
10
+ /**
11
+ * Check whether the terminal supports ANSI color codes.
12
+ * Respects NO_COLOR (https://no-color.org/) and FORCE_COLOR env vars.
13
+ */
14
+ export function supportsColor() {
15
+ if (_colorEnabled !== null) return _colorEnabled;
16
+ if (process.env.NO_COLOR !== undefined) {
17
+ _colorEnabled = false;
18
+ } else if (process.env.FORCE_COLOR !== undefined) {
19
+ _colorEnabled = true;
20
+ } else {
21
+ _colorEnabled = process.stdout.isTTY === true;
22
+ }
23
+ return _colorEnabled;
24
+ }
25
+
26
+ /** Reset the memoized color detection (for testing). */
27
+ export function resetColorCache() {
28
+ _colorEnabled = null;
29
+ }
30
+
31
+ /** Whether stdout is an interactive terminal. */
32
+ export function isTTY() {
33
+ return process.stdout.isTTY === true;
34
+ }
35
+
36
+ // ── ANSI wrappers ─────────────────────────────────────────────────────
37
+
38
+ function wrap(open, close) {
39
+ return (s) => supportsColor() ? `\x1b[${open}m${s}\x1b[${close}m` : s;
40
+ }
41
+
42
+ export const bold = wrap('1', '22');
43
+ export const dim = wrap('2', '22');
44
+ export const red = wrap('31', '39');
45
+ export const green = wrap('32', '39');
46
+ export const yellow = wrap('33', '39');
47
+ export const cyan = wrap('36', '39');
48
+
49
+ /**
50
+ * Strip ANSI escape codes from a string.
51
+ * @param {string} s
52
+ * @returns {string}
53
+ */
54
+ export function stripAnsi(s) {
55
+ // eslint-disable-next-line no-control-regex
56
+ return s.replace(/\x1b\[\d+m/g, '');
57
+ }
58
+
59
+ // ── Progress bar ──────────────────────────────────────────────────────
60
+
61
+ const BLOCK_FILLED = '\u2588'; // █
62
+ const BLOCK_EMPTY = '\u2591'; // ░
63
+
64
+ /**
65
+ * Render a text progress bar.
66
+ * @param {number} current
67
+ * @param {number} total
68
+ * @param {number} [width=20]
69
+ * @returns {string} e.g. "██████░░░░░░░░░░░░░░ 3/10"
70
+ */
71
+ export function progressBar(current, total, width = 20) {
72
+ const filled = total > 0 ? Math.round((current / total) * width) : 0;
73
+ const empty = width - filled;
74
+ return BLOCK_FILLED.repeat(filled) + BLOCK_EMPTY.repeat(empty) + ` ${current}/${total}`;
75
+ }
76
+
77
+ // ── Box drawing ───────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Wrap lines in a Unicode box.
81
+ * @param {string[]} lines
82
+ * @returns {string}
83
+ */
84
+ export function box(lines) {
85
+ const stripped = lines.map(stripAnsi);
86
+ const maxLen = Math.max(...stripped.map(l => l.length));
87
+ const pad = (line, strippedLine) => {
88
+ const diff = maxLen - strippedLine.length;
89
+ return line + ' '.repeat(diff);
90
+ };
91
+
92
+ const top = ' \u250c\u2500' + '\u2500'.repeat(maxLen) + '\u2500\u2510';
93
+ const bottom = ' \u2514\u2500' + '\u2500'.repeat(maxLen) + '\u2500\u2518';
94
+ const body = lines.map((line, i) => ` \u2502 ${pad(line, stripped[i])} \u2502`);
95
+
96
+ return [top, ...body, bottom].join('\n');
97
+ }
98
+
99
+ // ── Sticky line ───────────────────────────────────────────────────────
100
+
101
+ /**
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.
106
+ */
107
+ export class StickyLine {
108
+ constructor() {
109
+ this._content = '';
110
+ this._active = false;
111
+ }
112
+
113
+ /**
114
+ * Start showing the sticky line.
115
+ * @param {string} content - Initial content
116
+ */
117
+ start(content) {
118
+ this._active = true;
119
+ this._content = content;
120
+ if (isTTY()) {
121
+ process.stdout.write(this._content);
122
+ }
123
+ }
124
+
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
+ }
135
+
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);
144
+ return;
145
+ }
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);
157
+ }
158
+
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;
167
+ }
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');
174
+ }
175
+ process.stdout.write(this._content);
176
+ }
177
+
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 = '';
185
+ }
186
+ }
@@ -1,9 +1,29 @@
1
1
  /**
2
2
  * Formats test run results for terminal output.
3
+ * Accepts an optional terminal helpers object for colored/styled output.
3
4
  */
5
+
6
+ /** No-op terminal helpers (used when no real terminal is injected). */
7
+ const NOOP_TERMINAL = {
8
+ bold: (s) => s,
9
+ dim: (s) => s,
10
+ red: (s) => s,
11
+ green: (s) => s,
12
+ yellow: (s) => s,
13
+ cyan: (s) => s,
14
+ progressBar: (c, t) => `${c}/${t}`,
15
+ box: (lines) => lines.join('\n'),
16
+ };
17
+
4
18
  export class Reporter {
5
- constructor({ quiet = false } = {}) {
19
+ /**
20
+ * @param {Object} [options]
21
+ * @param {boolean} [options.quiet=false] - Suppress all output
22
+ * @param {Object} [options.terminal] - Terminal helpers (colors, progressBar, box)
23
+ */
24
+ constructor({ quiet = false, terminal = null } = {}) {
6
25
  this.quiet = quiet;
26
+ this.t = terminal || NOOP_TERMINAL;
7
27
  }
8
28
 
9
29
  log(...args) {
@@ -11,65 +31,137 @@ export class Reporter {
11
31
  }
12
32
 
13
33
  /**
14
- * Print a summary of all test runs.
15
- * @param {Object[]} results - Array of per-run results
16
- * Each: { runIndex, seed, exitCode, stdout, stderr, durationMs, workspacePath, kept }
17
- * @param {number} totalRuns
34
+ * Print injection scan stats after engine.injectAll().
35
+ * @param {import('./manifest.js').Manifest} manifest
18
36
  */
19
- summarize(results, totalRuns) {
37
+ printInjectionStats(manifest) {
20
38
  if (this.quiet) return;
21
- console.log('\n--- FlakeMonster Results ---\n');
22
-
23
- const failures = [];
39
+ const { dim, cyan } = this.t;
40
+ const fileCount = Object.keys(manifest.getFiles()).length;
41
+ const injections = manifest.getTotalInjections();
42
+ console.log(` ${cyan('\u2713')} ${injections} injection points across ${fileCount} file(s)`);
43
+ }
24
44
 
25
- for (const r of results) {
26
- const status = r.exitCode === 0 ? 'PASS' : 'FAIL';
27
- const dur = (r.durationMs / 1000).toFixed(1);
28
- let line = ` Run ${r.runIndex + 1}/${totalRuns}: ${status} (seed=${r.seed}, ${dur}s)`;
45
+ /**
46
+ * Print a single run result in real-time (during execution).
47
+ * @param {Object} result
48
+ * @param {number} totalRuns
49
+ */
50
+ printRunResult(result, totalRuns) {
51
+ if (this.quiet) return;
52
+ const { dim, red, green } = this.t;
53
+ const pass = result.exitCode === 0;
54
+ const icon = pass ? green('\u2713') : red('\u2717');
55
+ const status = pass ? green('PASS') : red('FAIL');
56
+ const dur = (result.durationMs / 1000).toFixed(1);
29
57
 
30
- if (r.exitCode !== 0 && r.kept) {
31
- line += `\n Workspace kept: ${r.workspacePath}`;
32
- }
58
+ let line = ` ${icon} Run ${result.runIndex + 1}/${totalRuns} ${status} seed=${dim(String(result.seed))} ${dur}s`;
33
59
 
34
- console.log(line);
60
+ if (result.parsed && result.tests && result.tests.length > 0) {
61
+ const passed = result.tests.filter((t) => t.status === 'passed').length;
62
+ const failed = result.tests.filter((t) => t.status === 'failed').length;
63
+ let counts = `${passed} passed`;
64
+ if (failed > 0) counts += `, ${red(String(failed) + ' failed')}`;
65
+ line += ` (${counts})`;
66
+ }
35
67
 
36
- if (r.exitCode !== 0) {
37
- failures.push(r);
38
- }
68
+ if (result.exitCode !== 0 && result.kept) {
69
+ line += ` ${dim('\u2014 workspace kept')}`;
39
70
  }
40
71
 
72
+ console.log(line);
73
+ }
74
+
75
+ /**
76
+ * Print a running tally / progress bar between runs.
77
+ * @param {Object[]} results - Results so far
78
+ * @param {number} totalRuns
79
+ */
80
+ printProgressTally(results, totalRuns) {
81
+ if (this.quiet) return;
82
+ const { dim, green, red } = this.t;
41
83
  const passed = results.filter((r) => r.exitCode === 0).length;
42
84
  const failed = results.length - passed;
43
-
44
- console.log(`\n Summary: ${passed}/${totalRuns} passed, ${failed}/${totalRuns} failed`);
45
-
46
- if (failures.length > 0) {
47
- const seeds = failures.map((f) => f.seed).join(', ');
48
- console.log(` Failing seeds: ${seeds}`);
49
- console.log(`\n Reproduce a failure:`);
50
- console.log(` flake-monster test --runs 1 --seed ${failures[0].seed} --cmd "<your test command>"`);
51
- } else {
52
- console.log('\n No flakes detected in this run.');
53
- }
54
-
85
+ const bar = this.t.progressBar(results.length, totalRuns);
86
+ console.log(` ${dim(bar)} ${green(String(passed) + ' passed')} ${failed > 0 ? red(String(failed) + ' failed') : dim('0 failed')}`);
55
87
  console.log('');
56
88
  }
57
89
 
58
90
  /**
59
- * Print a single run result in real-time (during execution).
60
- * @param {Object} result
91
+ * Print restoration confirmation.
92
+ * @param {{ filesRestored: number, injectionsRemoved: number }} stats
93
+ */
94
+ printRestorationResult({ filesRestored }) {
95
+ if (this.quiet) return;
96
+ const { cyan } = this.t;
97
+ console.log(`\n ${cyan('\u2713')} Source files restored (${filesRestored} files)`);
98
+ }
99
+
100
+ /**
101
+ * Print a rich summary of all test runs.
102
+ * @param {Object[]} results - Array of per-run results
61
103
  * @param {number} totalRuns
104
+ * @param {Object} [analysis] - Flakiness analysis from analyzeFlakiness()
105
+ * @param {number} [totalElapsedMs] - Total wall-clock time
62
106
  */
63
- printRunResult(result, totalRuns) {
107
+ summarize(results, totalRuns, analysis = null, totalElapsedMs = null) {
64
108
  if (this.quiet) return;
65
- const status = result.exitCode === 0 ? 'PASS' : 'FAIL';
66
- const dur = (result.durationMs / 1000).toFixed(1);
67
- let line = ` Run ${result.runIndex + 1}/${totalRuns}: ${status} (seed=${result.seed}, ${dur}s)`;
109
+ const { bold, dim, red, green, yellow } = this.t;
68
110
 
69
- if (result.exitCode !== 0 && result.kept) {
70
- line += ` workspace kept`;
111
+ const passed = results.filter((r) => r.exitCode === 0).length;
112
+ const failed = results.length - passed;
113
+ const failures = results.filter((r) => r.exitCode !== 0);
114
+
115
+ const lines = [];
116
+ lines.push(bold('FlakeMonster Results'));
117
+ lines.push('');
118
+ lines.push(`Runs: ${passed}/${totalRuns} passed, ${failed}/${totalRuns} failed`);
119
+
120
+ if (totalElapsedMs != null) {
121
+ const secs = totalElapsedMs / 1000;
122
+ if (secs >= 60) {
123
+ const mins = Math.floor(secs / 60);
124
+ const rem = Math.round(secs % 60);
125
+ lines.push(`Total time: ${mins}m ${rem}s`);
126
+ } else {
127
+ lines.push(`Total time: ${secs.toFixed(1)}s`);
128
+ }
71
129
  }
72
130
 
73
- console.log(line);
131
+ // Flaky test details
132
+ if (analysis && analysis.flakyTests.length > 0) {
133
+ lines.push('');
134
+ lines.push(yellow(`Flaky tests (${analysis.flakyTests.length}):`));
135
+ for (const t of analysis.flakyTests) {
136
+ const rate = (t.flakyRate * 100).toFixed(0);
137
+ const seeds = t.failedRuns
138
+ .map((i) => {
139
+ const r = results[i];
140
+ return r ? String(r.seed) : String(i);
141
+ })
142
+ .join(', ');
143
+ lines.push(` ${red(rate + '%')} ${t.name}`);
144
+ lines.push(` ${dim(`file: ${t.file || 'unknown'} seeds: ${seeds}`)}`);
145
+ }
146
+ }
147
+
148
+ if (analysis && analysis.alwaysFailingTests.length > 0) {
149
+ lines.push('');
150
+ lines.push(red(`Always failing (${analysis.alwaysFailingTests.length}):`));
151
+ for (const t of analysis.alwaysFailingTests) {
152
+ lines.push(` ${t.name} ${dim(`(${t.file || 'unknown'})`)}`);
153
+ }
154
+ }
155
+
156
+ if (failures.length > 0) {
157
+ lines.push('');
158
+ lines.push('Reproduce:');
159
+ lines.push(dim(` flake-monster test --runs 1 --seed ${failures[0].seed}`));
160
+ } else {
161
+ lines.push('');
162
+ lines.push(green('No flakes detected.'));
163
+ }
164
+
165
+ console.log('\n' + this.t.box(lines) + '\n');
74
166
  }
75
167
  }
@@ -1,6 +1,6 @@
1
1
  import { rm, mkdir, readdir, copyFile, stat } from 'node:fs/promises';
2
2
  import { join, relative, basename } from 'node:path';
3
- import { execSync } from 'node:child_process';
3
+ import { execSync, spawn } from 'node:child_process';
4
4
  import { randomBytes } from 'node:crypto';
5
5
 
6
6
  const FLAKE_MONSTER_DIR = '.flake-monster';
@@ -120,6 +120,19 @@ export class ProjectWorkspace {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * Execute a shell command inside the workspace asynchronously.
125
+ * Unlike exec(), this does not block the event loop, allowing spinners to animate.
126
+ * @param {string} command
127
+ * @param {Object} [options]
128
+ * @param {number} [options.timeout] - ms before killing the process
129
+ * @param {Object} [options.env] - additional env vars
130
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
131
+ */
132
+ execAsync(command, options = {}) {
133
+ return execAsync(command, this._root, options);
134
+ }
135
+
123
136
  /**
124
137
  * Delete the workspace directory.
125
138
  */
@@ -137,3 +150,63 @@ export class ProjectWorkspace {
137
150
  export function getFlakeMonsterDir(projectRoot) {
138
151
  return join(projectRoot, FLAKE_MONSTER_DIR);
139
152
  }
153
+
154
+ /**
155
+ * Execute a shell command asynchronously without blocking the event loop.
156
+ * @param {string} command
157
+ * @param {string} cwd - Working directory
158
+ * @param {Object} [options]
159
+ * @param {number} [options.timeout] - ms before killing the process
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)
163
+ * @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
164
+ */
165
+ export function execAsync(command, cwd, options = {}) {
166
+ return new Promise((resolve) => {
167
+ const { timeout, env, onStdout, onStderr } = options;
168
+ const child = spawn(command, {
169
+ cwd,
170
+ shell: true,
171
+ stdio: ['pipe', 'pipe', 'pipe'],
172
+ env: { ...process.env, ...env },
173
+ });
174
+
175
+ const stdoutChunks = [];
176
+ const stderrChunks = [];
177
+
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
+ });
186
+
187
+ let timer;
188
+ if (timeout) {
189
+ timer = setTimeout(() => {
190
+ child.kill('SIGTERM');
191
+ }, timeout);
192
+ }
193
+
194
+ child.on('close', (code) => {
195
+ if (timer) clearTimeout(timer);
196
+ resolve({
197
+ exitCode: code ?? 1,
198
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
199
+ stderr: Buffer.concat(stderrChunks).toString('utf-8'),
200
+ });
201
+ });
202
+
203
+ child.on('error', (err) => {
204
+ if (timer) clearTimeout(timer);
205
+ resolve({
206
+ exitCode: 1,
207
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
208
+ stderr: err.message,
209
+ });
210
+ });
211
+ });
212
+ }