flake-monster 0.4.1 → 0.4.4
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/cli/commands/inject.js +29 -0
- package/src/cli/commands/test.js +23 -3
- package/src/cli/index.js +7 -1
- package/src/core/parsers/index.js +5 -0
- package/src/core/parsers/playwright.js +98 -0
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
|
@@ -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,6 +5,7 @@ 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';
|
|
@@ -47,15 +48,32 @@ export function registerTestCommand(program) {
|
|
|
47
48
|
? detectRunner(testCmd)
|
|
48
49
|
: options.runner;
|
|
49
50
|
|
|
51
|
+
// node:test needs --test-reporter json for machine-parseable output
|
|
52
|
+
const effectiveCmd = (runner === 'node-test' && !testCmd.includes('--test-reporter'))
|
|
53
|
+
? testCmd.replace(/node\s+--test/, 'node --test --test-reporter json')
|
|
54
|
+
: testCmd;
|
|
55
|
+
|
|
50
56
|
const profile = FlakeProfile.fromConfig(merged);
|
|
51
57
|
const registry = new AdapterRegistry();
|
|
52
58
|
registry.register(createJavaScriptAdapter());
|
|
53
59
|
const engine = new InjectorEngine(registry, profile);
|
|
54
60
|
const reporter = new Reporter({ quiet: jsonOutput, terminal });
|
|
55
61
|
|
|
62
|
+
// Guard against stale injection from a previous interrupted run
|
|
63
|
+
if (inPlace) {
|
|
64
|
+
const flakeDir = getFlakeMonsterDir(projectRoot);
|
|
65
|
+
const staleManifest = await Manifest.load(flakeDir);
|
|
66
|
+
if (staleManifest) {
|
|
67
|
+
reporter.log(`Stale injection detected (seed: ${staleManifest.seed}, mode: ${staleManifest.mode}). Restoring before proceeding...`);
|
|
68
|
+
await engine.restoreAll(projectRoot, staleManifest);
|
|
69
|
+
await Manifest.delete(flakeDir);
|
|
70
|
+
reporter.log('Stale injections removed. Source files are clean.\n');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
reporter.log(`FlakeMonster test harness`);
|
|
57
75
|
reporter.log(` Runs: ${runs} | Mode: ${profile.mode} | Base seed: ${baseSeed}`);
|
|
58
|
-
reporter.log(` Command: ${
|
|
76
|
+
reporter.log(` Command: ${effectiveCmd}`);
|
|
59
77
|
reporter.log(` Patterns: ${globs.join(', ')}`);
|
|
60
78
|
if (runner) {
|
|
61
79
|
reporter.log(` Runner: ${runner}`);
|
|
@@ -104,7 +122,7 @@ export function registerTestCommand(program) {
|
|
|
104
122
|
|
|
105
123
|
let exitCode, stdout, stderr, durationMs;
|
|
106
124
|
try {
|
|
107
|
-
({ exitCode, stdout, stderr } = await execAsync(
|
|
125
|
+
({ exitCode, stdout, stderr } = await execAsync(effectiveCmd, projectRoot, {
|
|
108
126
|
onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
|
|
109
127
|
onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
|
|
110
128
|
}));
|
|
@@ -130,6 +148,7 @@ export function registerTestCommand(program) {
|
|
|
130
148
|
tests: parsed?.tests ?? [],
|
|
131
149
|
};
|
|
132
150
|
|
|
151
|
+
results.push(result);
|
|
133
152
|
reporter.printRunResult(result, runs);
|
|
134
153
|
} else {
|
|
135
154
|
// Create workspace
|
|
@@ -164,7 +183,7 @@ export function registerTestCommand(program) {
|
|
|
164
183
|
|
|
165
184
|
let exitCode, stdout, stderr, durationMs;
|
|
166
185
|
try {
|
|
167
|
-
({ exitCode, stdout, stderr } = await workspace.execAsync(
|
|
186
|
+
({ exitCode, stdout, stderr } = await workspace.execAsync(effectiveCmd, {
|
|
168
187
|
onStdout: reporter.quiet ? undefined : (chunk) => sticky.writeAbove(chunk),
|
|
169
188
|
onStderr: reporter.quiet ? undefined : (chunk) => sticky.writeAboveStderr(chunk),
|
|
170
189
|
}));
|
|
@@ -193,6 +212,7 @@ export function registerTestCommand(program) {
|
|
|
193
212
|
tests: parsed?.tests ?? [],
|
|
194
213
|
};
|
|
195
214
|
|
|
215
|
+
results.push(result);
|
|
196
216
|
reporter.printRunResult(result, runs);
|
|
197
217
|
|
|
198
218
|
// Cleanup
|
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);
|
|
@@ -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
|
+
}
|