flake-monster 0.4.0 → 0.4.3
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/README.md +14 -1
- package/bin/flake-monster.js +0 -0
- package/package.json +1 -1
- package/src/adapters/javascript/index.js +5 -5
- package/src/cli/commands/inject.js +29 -0
- package/src/cli/commands/test.js +62 -25
- package/src/cli/index.js +7 -1
- package/src/cli/terminal.js +70 -41
- package/src/core/parsers/index.js +5 -0
- package/src/core/parsers/playwright.js +98 -0
- package/src/core/workspace.js +11 -3
package/README.md
CHANGED
|
@@ -57,7 +57,16 @@ flake-monster test --runs 5 --cmd "npm test"
|
|
|
57
57
|
flake-monster test --cmd "npx playwright test" "src/**/*.js"
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
**CI flake gate**, Block merges that introduce flaky tests.
|
|
60
|
+
**CI flake gate**, Block merges that introduce flaky tests. Use the [GitHub Action](https://github.com/growthboot/FlakeMonster-Action) for automatic PR comments:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
# .github/workflows/flake-monster.yml
|
|
64
|
+
- uses: growthboot/FlakeMonster-Action@v1
|
|
65
|
+
with:
|
|
66
|
+
test-command: 'npm test'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or run the CLI directly in any CI environment:
|
|
61
70
|
|
|
62
71
|
```bash
|
|
63
72
|
flake-monster test --runs 10 --cmd "npm test"
|
|
@@ -276,6 +285,10 @@ Recovery mode: scanning for injected lines...
|
|
|
276
285
|
|
|
277
286
|
You see exactly which lines will be removed and why (`stamp`, `ident`, or `import`), then confirm before any files are modified.
|
|
278
287
|
|
|
288
|
+
## Changelog
|
|
289
|
+
|
|
290
|
+
See [VERSIONS.md](VERSIONS.md) for detailed version history.
|
|
291
|
+
|
|
279
292
|
## License
|
|
280
293
|
|
|
281
294
|
MIT
|
package/bin/flake-monster.js
CHANGED
|
File without changes
|
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
|
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
2
3
|
import { AdapterRegistry } from '../../adapters/registry.js';
|
|
3
4
|
import { createJavaScriptAdapter } from '../../adapters/javascript/index.js';
|
|
4
5
|
import { InjectorEngine } from '../../core/engine.js';
|
|
5
6
|
import { FlakeProfile } from '../../core/profile.js';
|
|
6
7
|
import { parseSeed } from '../../core/seed.js';
|
|
7
8
|
import { ProjectWorkspace, getFlakeMonsterDir } from '../../core/workspace.js';
|
|
9
|
+
import { Manifest } from '../../core/manifest.js';
|
|
8
10
|
import { loadConfig, mergeWithCliOptions } from '../../core/config.js';
|
|
9
11
|
|
|
12
|
+
function confirm(question) {
|
|
13
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
rl.question(question, (answer) => {
|
|
16
|
+
rl.close();
|
|
17
|
+
resolve(answer.trim().toLowerCase() === 'y');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
export function registerInjectCommand(program) {
|
|
11
23
|
program
|
|
12
24
|
.command('inject')
|
|
@@ -31,6 +43,23 @@ export function registerInjectCommand(program) {
|
|
|
31
43
|
registry.register(createJavaScriptAdapter());
|
|
32
44
|
const engine = new InjectorEngine(registry, profile);
|
|
33
45
|
|
|
46
|
+
// Guard against double injection
|
|
47
|
+
const flakeDirCheck = getFlakeMonsterDir(projectRoot);
|
|
48
|
+
const existingManifest = await Manifest.load(flakeDirCheck);
|
|
49
|
+
if (existingManifest) {
|
|
50
|
+
console.log(
|
|
51
|
+
`Active injection detected (seed: ${existingManifest.seed}, mode: ${existingManifest.mode}, injected at: ${existingManifest.createdAt}).`
|
|
52
|
+
);
|
|
53
|
+
const proceed = await confirm('Restore source files before re-injecting? (y/N) ');
|
|
54
|
+
if (!proceed) {
|
|
55
|
+
console.log('Aborted. Run `flake-monster restore` manually to clean up.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
await engine.restoreAll(projectRoot, existingManifest);
|
|
59
|
+
await Manifest.delete(flakeDirCheck);
|
|
60
|
+
console.log('Previous injections removed. Proceeding with fresh injection.\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
34
63
|
const useWorkspace = options.workspace;
|
|
35
64
|
let targetDir = projectRoot;
|
|
36
65
|
let workspace = null;
|
package/src/cli/commands/test.js
CHANGED
|
@@ -5,12 +5,13 @@ import { InjectorEngine } from '../../core/engine.js';
|
|
|
5
5
|
import { FlakeProfile } from '../../core/profile.js';
|
|
6
6
|
import { parseSeed, deriveSeed } from '../../core/seed.js';
|
|
7
7
|
import { ProjectWorkspace, getFlakeMonsterDir, execAsync } from '../../core/workspace.js';
|
|
8
|
+
import { Manifest } from '../../core/manifest.js';
|
|
8
9
|
import { loadConfig, mergeWithCliOptions } from '../../core/config.js';
|
|
9
10
|
import { Reporter } from '../../core/reporter.js';
|
|
10
11
|
import { detectRunner, parseTestOutput } from '../../core/parsers/index.js';
|
|
11
12
|
import { analyzeFlakiness } from '../../core/flake-analyzer.js';
|
|
12
13
|
import * as terminal from '../terminal.js';
|
|
13
|
-
import {
|
|
14
|
+
import { StickyLine } from '../terminal.js';
|
|
14
15
|
|
|
15
16
|
export function registerTestCommand(program) {
|
|
16
17
|
program
|
|
@@ -53,6 +54,18 @@ export function registerTestCommand(program) {
|
|
|
53
54
|
const engine = new InjectorEngine(registry, profile);
|
|
54
55
|
const reporter = new Reporter({ quiet: jsonOutput, terminal });
|
|
55
56
|
|
|
57
|
+
// Guard against stale injection from a previous interrupted run
|
|
58
|
+
if (inPlace) {
|
|
59
|
+
const flakeDir = getFlakeMonsterDir(projectRoot);
|
|
60
|
+
const staleManifest = await Manifest.load(flakeDir);
|
|
61
|
+
if (staleManifest) {
|
|
62
|
+
reporter.log(`Stale injection detected (seed: ${staleManifest.seed}, mode: ${staleManifest.mode}). Restoring before proceeding...`);
|
|
63
|
+
await engine.restoreAll(projectRoot, staleManifest);
|
|
64
|
+
await Manifest.delete(flakeDir);
|
|
65
|
+
reporter.log('Stale injections removed. Source files are clean.\n');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
reporter.log(`FlakeMonster test harness`);
|
|
57
70
|
reporter.log(` Runs: ${runs} | Mode: ${profile.mode} | Base seed: ${baseSeed}`);
|
|
58
71
|
reporter.log(` Command: ${testCmd}`);
|
|
@@ -85,17 +98,33 @@ export function registerTestCommand(program) {
|
|
|
85
98
|
lastManifest = manifest;
|
|
86
99
|
reporter.printInjectionStats(manifest);
|
|
87
100
|
|
|
88
|
-
// Run tests with
|
|
89
|
-
const
|
|
90
|
-
|
|
101
|
+
// Run tests — stream output with sticky status line
|
|
102
|
+
const passed = results.filter(r => r.exitCode === 0).length;
|
|
103
|
+
const failed = results.length - passed;
|
|
104
|
+
const sticky = new StickyLine();
|
|
105
|
+
const start = Date.now();
|
|
106
|
+
let stickyTimer;
|
|
107
|
+
|
|
108
|
+
if (!reporter.quiet) {
|
|
109
|
+
const stickyContent = () => {
|
|
110
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
111
|
+
const bar = terminal.progressBar(i, runs);
|
|
112
|
+
return terminal.dim(` ${bar} ${passed} passed ${failed} failed `) + `Run ${i + 1}/${runs} ${elapsed}s`;
|
|
113
|
+
};
|
|
114
|
+
sticky.start(stickyContent());
|
|
115
|
+
stickyTimer = setInterval(() => sticky.update(stickyContent()), 200);
|
|
116
|
+
}
|
|
91
117
|
|
|
92
118
|
let exitCode, stdout, stderr, durationMs;
|
|
93
|
-
const start = Date.now();
|
|
94
119
|
try {
|
|
95
|
-
({ exitCode, stdout, stderr } = await execAsync(testCmd, projectRoot
|
|
120
|
+
({ exitCode, stdout, stderr } = await execAsync(testCmd, projectRoot, {
|
|
121
|
+
onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
|
|
122
|
+
onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
|
|
123
|
+
}));
|
|
96
124
|
durationMs = Date.now() - start;
|
|
97
125
|
} finally {
|
|
98
|
-
if (
|
|
126
|
+
if (stickyTimer) clearInterval(stickyTimer);
|
|
127
|
+
sticky.clear();
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
// Parse test output if we have a runner
|
|
@@ -114,12 +143,8 @@ export function registerTestCommand(program) {
|
|
|
114
143
|
tests: parsed?.tests ?? [],
|
|
115
144
|
};
|
|
116
145
|
|
|
117
|
-
reporter.printRunResult(result, runs);
|
|
118
146
|
results.push(result);
|
|
119
|
-
|
|
120
|
-
if (i < runs - 1) {
|
|
121
|
-
reporter.printProgressTally(results, runs);
|
|
122
|
-
}
|
|
147
|
+
reporter.printRunResult(result, runs);
|
|
123
148
|
} else {
|
|
124
149
|
// Create workspace
|
|
125
150
|
const workspace = new ProjectWorkspace({
|
|
@@ -134,21 +159,37 @@ export function registerTestCommand(program) {
|
|
|
134
159
|
await manifest.save(flakeDir);
|
|
135
160
|
reporter.printInjectionStats(manifest);
|
|
136
161
|
|
|
137
|
-
// Run tests with
|
|
138
|
-
const
|
|
139
|
-
|
|
162
|
+
// Run tests — stream output with sticky status line
|
|
163
|
+
const passed = results.filter(r => r.exitCode === 0).length;
|
|
164
|
+
const failed = results.length - passed;
|
|
165
|
+
const sticky = new StickyLine();
|
|
166
|
+
const start = Date.now();
|
|
167
|
+
let stickyTimer;
|
|
168
|
+
|
|
169
|
+
if (!reporter.quiet) {
|
|
170
|
+
const stickyContent = () => {
|
|
171
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
172
|
+
const bar = terminal.progressBar(i, runs);
|
|
173
|
+
return terminal.dim(` ${bar} ${passed} passed ${failed} failed `) + `Run ${i + 1}/${runs} ${elapsed}s`;
|
|
174
|
+
};
|
|
175
|
+
sticky.start(stickyContent());
|
|
176
|
+
stickyTimer = setInterval(() => sticky.update(stickyContent()), 200);
|
|
177
|
+
}
|
|
140
178
|
|
|
141
179
|
let exitCode, stdout, stderr, durationMs;
|
|
142
|
-
const start = Date.now();
|
|
143
180
|
try {
|
|
144
|
-
({ exitCode, stdout, stderr } = await workspace.execAsync(testCmd
|
|
181
|
+
({ exitCode, stdout, stderr } = await workspace.execAsync(testCmd, {
|
|
182
|
+
onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
|
|
183
|
+
onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
|
|
184
|
+
}));
|
|
145
185
|
durationMs = Date.now() - start;
|
|
146
186
|
} finally {
|
|
147
|
-
if (
|
|
187
|
+
if (stickyTimer) clearInterval(stickyTimer);
|
|
188
|
+
sticky.clear();
|
|
148
189
|
}
|
|
149
190
|
|
|
150
|
-
const
|
|
151
|
-
const shouldKeep = (
|
|
191
|
+
const testFailed = exitCode !== 0;
|
|
192
|
+
const shouldKeep = (testFailed && options.keepOnFail) || options.keepAll;
|
|
152
193
|
|
|
153
194
|
// Parse test output if we have a runner
|
|
154
195
|
const parsed = runner ? parseTestOutput(runner, stdout) : null;
|
|
@@ -166,12 +207,8 @@ export function registerTestCommand(program) {
|
|
|
166
207
|
tests: parsed?.tests ?? [],
|
|
167
208
|
};
|
|
168
209
|
|
|
169
|
-
reporter.printRunResult(result, runs);
|
|
170
210
|
results.push(result);
|
|
171
|
-
|
|
172
|
-
if (i < runs - 1) {
|
|
173
|
-
reporter.printProgressTally(results, runs);
|
|
174
|
-
}
|
|
211
|
+
reporter.printRunResult(result, runs);
|
|
175
212
|
|
|
176
213
|
// Cleanup
|
|
177
214
|
if (!shouldKeep) {
|
package/src/cli/index.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
1
4
|
import { Command } from 'commander';
|
|
2
5
|
import { registerInjectCommand } from './commands/inject.js';
|
|
3
6
|
import { registerRestoreCommand } from './commands/restore.js';
|
|
4
7
|
import { registerTestCommand } from './commands/test.js';
|
|
5
8
|
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
|
|
11
|
+
|
|
6
12
|
export function createCli() {
|
|
7
13
|
const program = new Command();
|
|
8
14
|
|
|
9
15
|
program
|
|
10
16
|
.name('flake-monster')
|
|
11
17
|
.description('Source-to-source test hardener, injects async delays to surface flaky tests')
|
|
12
|
-
.version(
|
|
18
|
+
.version(pkg.version);
|
|
13
19
|
|
|
14
20
|
registerInjectCommand(program);
|
|
15
21
|
registerRestoreCommand(program);
|
package/src/cli/terminal.js
CHANGED
|
@@ -96,62 +96,91 @@ export function box(lines) {
|
|
|
96
96
|
return [top, ...body, bottom].join('\n');
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// ──
|
|
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
|
|
106
|
-
*
|
|
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
|
|
107
|
+
export class StickyLine {
|
|
108
|
+
constructor() {
|
|
109
|
+
this._content = '';
|
|
110
|
+
this._active = false;
|
|
111
|
+
}
|
|
112
|
+
|
|
109
113
|
/**
|
|
110
|
-
*
|
|
114
|
+
* Start showing the sticky line.
|
|
115
|
+
* @param {string} content - Initial content
|
|
111
116
|
*/
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
this.
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
/**
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { parseJestOutput } from './jest.js';
|
|
2
2
|
import { parseNodeTestOutput } from './node-test.js';
|
|
3
|
+
import { parsePlaywrightOutput } from './playwright.js';
|
|
3
4
|
import { parseTapOutput } from './tap.js';
|
|
4
5
|
|
|
5
6
|
const parsers = {
|
|
6
7
|
jest: parseJestOutput,
|
|
7
8
|
'node-test': parseNodeTestOutput,
|
|
9
|
+
playwright: parsePlaywrightOutput,
|
|
8
10
|
tap: parseTapOutput,
|
|
9
11
|
};
|
|
10
12
|
|
|
@@ -19,6 +21,9 @@ export function detectRunner(testCommand) {
|
|
|
19
21
|
if (testCommand.includes('node --test') || testCommand.includes('node:test')) {
|
|
20
22
|
return 'node-test';
|
|
21
23
|
}
|
|
24
|
+
if (testCommand.includes('playwright')) {
|
|
25
|
+
return 'playwright';
|
|
26
|
+
}
|
|
22
27
|
return 'tap';
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses Playwright JSON reporter output (from `--reporter=json`).
|
|
3
|
+
*
|
|
4
|
+
* Playwright JSON schema:
|
|
5
|
+
* {
|
|
6
|
+
* suites: [{
|
|
7
|
+
* title: string,
|
|
8
|
+
* file: string,
|
|
9
|
+
* suites: [...], // nested suites
|
|
10
|
+
* specs: [{
|
|
11
|
+
* title: string,
|
|
12
|
+
* file: string,
|
|
13
|
+
* tests: [{
|
|
14
|
+
* projectName: string,
|
|
15
|
+
* results: [{
|
|
16
|
+
* status: "passed" | "failed" | "timedOut" | "skipped" | "interrupted",
|
|
17
|
+
* duration: number (ms),
|
|
18
|
+
* error?: { message: string, stack?: string }
|
|
19
|
+
* }],
|
|
20
|
+
* status: "expected" | "unexpected" | "flaky" | "skipped"
|
|
21
|
+
* }]
|
|
22
|
+
* }]
|
|
23
|
+
* }]
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* Each spec may have multiple tests (one per project/browser).
|
|
27
|
+
* Each test may have multiple results (retries), but we use the
|
|
28
|
+
* overall test.status to determine passed/failed/skipped.
|
|
29
|
+
* Duration is summed from all results for that test.
|
|
30
|
+
*/
|
|
31
|
+
export function parsePlaywrightOutput(stdout) {
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(stdout);
|
|
34
|
+
if (!data.suites || !Array.isArray(data.suites)) {
|
|
35
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tests = [];
|
|
39
|
+
collectTests(data.suites, [], tests);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
parsed: true,
|
|
43
|
+
tests,
|
|
44
|
+
totalPassed: tests.filter(t => t.status === 'passed').length,
|
|
45
|
+
totalFailed: tests.filter(t => t.status === 'failed').length,
|
|
46
|
+
totalSkipped: tests.filter(t => t.status === 'skipped').length,
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recursively walk suites and collect leaf test entries.
|
|
55
|
+
*/
|
|
56
|
+
function collectTests(suites, titlePath, out) {
|
|
57
|
+
for (const suite of suites) {
|
|
58
|
+
const currentPath = suite.title ? [...titlePath, suite.title] : titlePath;
|
|
59
|
+
|
|
60
|
+
for (const spec of suite.specs || []) {
|
|
61
|
+
for (const test of spec.tests || []) {
|
|
62
|
+
const nameParts = [...currentPath, spec.title].filter(Boolean);
|
|
63
|
+
const name = nameParts.join(' > ');
|
|
64
|
+
|
|
65
|
+
const status =
|
|
66
|
+
test.status === 'skipped' ? 'skipped' :
|
|
67
|
+
test.status === 'expected' ? 'passed' : 'failed';
|
|
68
|
+
|
|
69
|
+
const results = test.results || [];
|
|
70
|
+
const durationMs = results.reduce((sum, r) => sum + (r.duration ?? 0), 0);
|
|
71
|
+
|
|
72
|
+
// Find the first error message from results
|
|
73
|
+
let failureMessage = null;
|
|
74
|
+
if (status === 'failed') {
|
|
75
|
+
for (const r of results) {
|
|
76
|
+
if (r.error) {
|
|
77
|
+
failureMessage = r.error.message || r.error.stack || String(r.error);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
out.push({
|
|
84
|
+
name,
|
|
85
|
+
file: spec.file || suite.file || null,
|
|
86
|
+
status,
|
|
87
|
+
durationMs: results.length > 0 ? durationMs : null,
|
|
88
|
+
failureMessage,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Recurse into nested suites
|
|
94
|
+
if (suite.suites) {
|
|
95
|
+
collectTests(suite.suites, currentPath, out);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/core/workspace.js
CHANGED
|
@@ -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) =>
|
|
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
|
+
});
|
|
178
186
|
|
|
179
187
|
let timer;
|
|
180
188
|
if (timeout) {
|