flake-monster 0.4.0 → 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.4.0",
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
 
@@ -10,7 +10,7 @@ import { Reporter } from '../../core/reporter.js';
10
10
  import { detectRunner, parseTestOutput } from '../../core/parsers/index.js';
11
11
  import { analyzeFlakiness } from '../../core/flake-analyzer.js';
12
12
  import * as terminal from '../terminal.js';
13
- import { Spinner } from '../terminal.js';
13
+ import { StickyLine } from '../terminal.js';
14
14
 
15
15
  export function registerTestCommand(program) {
16
16
  program
@@ -85,17 +85,33 @@ export function registerTestCommand(program) {
85
85
  lastManifest = manifest;
86
86
  reporter.printInjectionStats(manifest);
87
87
 
88
- // Run tests with spinner
89
- const spinner = new Spinner(`Run ${i + 1}/${runs} seed=${runSeed}`);
90
- if (!reporter.quiet) spinner.start();
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();
92
+ const start = Date.now();
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
+ }
91
104
 
92
105
  let exitCode, stdout, stderr, durationMs;
93
- const start = Date.now();
94
106
  try {
95
- ({ exitCode, stdout, stderr } = await execAsync(testCmd, projectRoot));
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
+ }));
96
111
  durationMs = Date.now() - start;
97
112
  } finally {
98
- if (!reporter.quiet) spinner.stop();
113
+ if (stickyTimer) clearInterval(stickyTimer);
114
+ sticky.clear();
99
115
  }
100
116
 
101
117
  // Parse test output if we have a runner
@@ -115,11 +131,6 @@ export function registerTestCommand(program) {
115
131
  };
116
132
 
117
133
  reporter.printRunResult(result, runs);
118
- results.push(result);
119
-
120
- if (i < runs - 1) {
121
- reporter.printProgressTally(results, runs);
122
- }
123
134
  } else {
124
135
  // Create workspace
125
136
  const workspace = new ProjectWorkspace({
@@ -134,21 +145,37 @@ export function registerTestCommand(program) {
134
145
  await manifest.save(flakeDir);
135
146
  reporter.printInjectionStats(manifest);
136
147
 
137
- // Run tests with spinner
138
- const spinner = new Spinner(`Run ${i + 1}/${runs} seed=${runSeed}`);
139
- if (!reporter.quiet) spinner.start();
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();
152
+ const start = Date.now();
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
+ }
140
164
 
141
165
  let exitCode, stdout, stderr, durationMs;
142
- const start = Date.now();
143
166
  try {
144
- ({ exitCode, stdout, stderr } = await workspace.execAsync(testCmd));
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
+ }));
145
171
  durationMs = Date.now() - start;
146
172
  } finally {
147
- if (!reporter.quiet) spinner.stop();
173
+ if (stickyTimer) clearInterval(stickyTimer);
174
+ sticky.clear();
148
175
  }
149
176
 
150
- const failed = exitCode !== 0;
151
- const shouldKeep = (failed && options.keepOnFail) || options.keepAll;
177
+ const testFailed = exitCode !== 0;
178
+ const shouldKeep = (testFailed && options.keepOnFail) || options.keepAll;
152
179
 
153
180
  // Parse test output if we have a runner
154
181
  const parsed = runner ? parseTestOutput(runner, stdout) : null;
@@ -167,11 +194,6 @@ export function registerTestCommand(program) {
167
194
  };
168
195
 
169
196
  reporter.printRunResult(result, runs);
170
- results.push(result);
171
-
172
- if (i < runs - 1) {
173
- reporter.printProgressTally(results, runs);
174
- }
175
197
 
176
198
  // Cleanup
177
199
  if (!shouldKeep) {
@@ -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
  }
@@ -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) {