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 +1 -1
- package/src/adapters/javascript/index.js +5 -5
- package/src/cli/commands/test.js +67 -34
- package/src/cli/index.js +1 -1
- package/src/cli/terminal.js +186 -0
- package/src/core/reporter.js +133 -41
- package/src/core/workspace.js +74 -1
package/package.json
CHANGED
|
@@ -15,14 +15,14 @@ const RUNTIME_FILENAME = 'flake-monster.runtime.js';
|
|
|
15
15
|
* @returns {string}
|
|
16
16
|
*/
|
|
17
17
|
function computeRuntimeImportPath(filePath) {
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
if (
|
|
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
|
-
|
|
25
|
-
const ups = '../'.repeat(
|
|
24
|
+
const depth = dir.split('/').length;
|
|
25
|
+
const ups = '../'.repeat(depth);
|
|
26
26
|
return `${ups}${RUNTIME_FILENAME}`;
|
|
27
27
|
}
|
|
28
28
|
|
package/src/cli/commands/test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
const shouldKeep = (
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/core/reporter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
15
|
-
* @param {
|
|
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
|
-
|
|
37
|
+
printInjectionStats(manifest) {
|
|
20
38
|
if (this.quiet) return;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
|
60
|
-
* @param {
|
|
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
|
-
|
|
107
|
+
summarize(results, totalRuns, analysis = null, totalElapsedMs = null) {
|
|
64
108
|
if (this.quiet) return;
|
|
65
|
-
const
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/workspace.js
CHANGED
|
@@ -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
|
+
}
|