flake-monster 0.3.5 → 0.4.0
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/cli/commands/test.js +41 -30
- package/src/cli/index.js +1 -1
- package/src/cli/terminal.js +157 -0
- package/src/core/reporter.js +133 -41
- package/src/core/workspace.js +66 -1
package/package.json
CHANGED
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 { Spinner } 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,20 @@ 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 with spinner
|
|
89
|
+
const spinner = new Spinner(`Run ${i + 1}/${runs} seed=${runSeed}`);
|
|
90
|
+
if (!reporter.quiet) spinner.start();
|
|
91
|
+
|
|
92
|
+
let exitCode, stdout, stderr, durationMs;
|
|
104
93
|
const start = Date.now();
|
|
105
|
-
|
|
106
|
-
|
|
94
|
+
try {
|
|
95
|
+
({ exitCode, stdout, stderr } = await execAsync(testCmd, projectRoot));
|
|
96
|
+
durationMs = Date.now() - start;
|
|
97
|
+
} finally {
|
|
98
|
+
if (!reporter.quiet) spinner.stop();
|
|
99
|
+
}
|
|
107
100
|
|
|
108
101
|
// Parse test output if we have a runner
|
|
109
102
|
const parsed = runner ? parseTestOutput(runner, stdout) : null;
|
|
@@ -123,6 +116,10 @@ export function registerTestCommand(program) {
|
|
|
123
116
|
|
|
124
117
|
reporter.printRunResult(result, runs);
|
|
125
118
|
results.push(result);
|
|
119
|
+
|
|
120
|
+
if (i < runs - 1) {
|
|
121
|
+
reporter.printProgressTally(results, runs);
|
|
122
|
+
}
|
|
126
123
|
} else {
|
|
127
124
|
// Create workspace
|
|
128
125
|
const workspace = new ProjectWorkspace({
|
|
@@ -135,11 +132,20 @@ export function registerTestCommand(program) {
|
|
|
135
132
|
const manifest = await engine.injectAll(workspace.root, globs, runSeed, merged.exclude);
|
|
136
133
|
const flakeDir = getFlakeMonsterDir(workspace.root);
|
|
137
134
|
await manifest.save(flakeDir);
|
|
135
|
+
reporter.printInjectionStats(manifest);
|
|
136
|
+
|
|
137
|
+
// Run tests with spinner
|
|
138
|
+
const spinner = new Spinner(`Run ${i + 1}/${runs} seed=${runSeed}`);
|
|
139
|
+
if (!reporter.quiet) spinner.start();
|
|
138
140
|
|
|
139
|
-
|
|
141
|
+
let exitCode, stdout, stderr, durationMs;
|
|
140
142
|
const start = Date.now();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
try {
|
|
144
|
+
({ exitCode, stdout, stderr } = await workspace.execAsync(testCmd));
|
|
145
|
+
durationMs = Date.now() - start;
|
|
146
|
+
} finally {
|
|
147
|
+
if (!reporter.quiet) spinner.stop();
|
|
148
|
+
}
|
|
143
149
|
|
|
144
150
|
const failed = exitCode !== 0;
|
|
145
151
|
const shouldKeep = (failed && options.keepOnFail) || options.keepAll;
|
|
@@ -163,6 +169,10 @@ export function registerTestCommand(program) {
|
|
|
163
169
|
reporter.printRunResult(result, runs);
|
|
164
170
|
results.push(result);
|
|
165
171
|
|
|
172
|
+
if (i < runs - 1) {
|
|
173
|
+
reporter.printProgressTally(results, runs);
|
|
174
|
+
}
|
|
175
|
+
|
|
166
176
|
// Cleanup
|
|
167
177
|
if (!shouldKeep) {
|
|
168
178
|
await workspace.destroy();
|
|
@@ -172,12 +182,13 @@ export function registerTestCommand(program) {
|
|
|
172
182
|
|
|
173
183
|
// Restore source files after all in-place runs
|
|
174
184
|
if (inPlace && lastManifest) {
|
|
175
|
-
await engine.restoreAll(projectRoot, lastManifest);
|
|
176
|
-
reporter.
|
|
185
|
+
const restoreResult = await engine.restoreAll(projectRoot, lastManifest);
|
|
186
|
+
reporter.printRestorationResult(restoreResult);
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
// Run flakiness analysis if we have parsed results
|
|
180
190
|
const analysis = runner ? analyzeFlakiness(results) : null;
|
|
191
|
+
const totalElapsedMs = Date.now() - harnessTotalStart;
|
|
181
192
|
|
|
182
193
|
if (jsonOutput) {
|
|
183
194
|
// JSON output for CI consumption
|
|
@@ -197,7 +208,7 @@ export function registerTestCommand(program) {
|
|
|
197
208
|
};
|
|
198
209
|
console.log(JSON.stringify(output));
|
|
199
210
|
} else {
|
|
200
|
-
reporter.summarize(results, runs);
|
|
211
|
+
reporter.summarize(results, runs, analysis, totalElapsedMs);
|
|
201
212
|
}
|
|
202
213
|
|
|
203
214
|
// 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,157 @@
|
|
|
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
|
+
// ── Spinner ───────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const SPINNER_FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
|
|
102
|
+
const SPINNER_INTERVAL = 80; // ms
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A simple terminal spinner with elapsed time display.
|
|
106
|
+
* On non-TTY terminals, prints a single static line instead.
|
|
107
|
+
*/
|
|
108
|
+
export class Spinner {
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} message - Text to show next to the spinner
|
|
111
|
+
*/
|
|
112
|
+
constructor(message) {
|
|
113
|
+
this.message = message;
|
|
114
|
+
this._timer = null;
|
|
115
|
+
this._frame = 0;
|
|
116
|
+
this._startTime = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Start the spinner animation. */
|
|
120
|
+
start() {
|
|
121
|
+
this._startTime = Date.now();
|
|
122
|
+
|
|
123
|
+
if (!isTTY()) {
|
|
124
|
+
process.stdout.write(` ${this.message} ...\n`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this._render();
|
|
129
|
+
this._timer = setInterval(() => this._render(), SPINNER_INTERVAL);
|
|
130
|
+
}
|
|
131
|
+
|
|
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;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isTTY()) {
|
|
142
|
+
// Clear the spinner line
|
|
143
|
+
process.stdout.write('\r\x1b[K');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return elapsed;
|
|
147
|
+
}
|
|
148
|
+
|
|
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`);
|
|
156
|
+
}
|
|
157
|
+
}
|
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,55 @@ 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
|
+
* @returns {Promise<{ exitCode: number, stdout: string, stderr: string }>}
|
|
162
|
+
*/
|
|
163
|
+
export function execAsync(command, cwd, options = {}) {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
const { timeout, env } = options;
|
|
166
|
+
const child = spawn(command, {
|
|
167
|
+
cwd,
|
|
168
|
+
shell: true,
|
|
169
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
170
|
+
env: { ...process.env, ...env },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const stdoutChunks = [];
|
|
174
|
+
const stderrChunks = [];
|
|
175
|
+
|
|
176
|
+
child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
|
|
177
|
+
child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
|
|
178
|
+
|
|
179
|
+
let timer;
|
|
180
|
+
if (timeout) {
|
|
181
|
+
timer = setTimeout(() => {
|
|
182
|
+
child.kill('SIGTERM');
|
|
183
|
+
}, timeout);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
child.on('close', (code) => {
|
|
187
|
+
if (timer) clearTimeout(timer);
|
|
188
|
+
resolve({
|
|
189
|
+
exitCode: code ?? 1,
|
|
190
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
|
191
|
+
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
child.on('error', (err) => {
|
|
196
|
+
if (timer) clearTimeout(timer);
|
|
197
|
+
resolve({
|
|
198
|
+
exitCode: 1,
|
|
199
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
|
200
|
+
stderr: err.message,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|