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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flake-monster",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Source-to-source test hardener that injects async delays to surface flaky tests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 { 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 in project root
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
- const { exitCode, stdout, stderr } = execInDir(testCmd, projectRoot);
106
- const durationMs = Date.now() - start;
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
- // Run tests
141
+ let exitCode, stdout, stderr, durationMs;
140
142
  const start = Date.now();
141
- const { exitCode, stdout, stderr } = workspace.exec(testCmd);
142
- const durationMs = Date.now() - start;
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.log('\n Source files restored to original state.');
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.3.1');
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
+ }
@@ -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,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
+ }