flake-ledger 0.1.0 → 0.1.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/README.md +89 -7
- package/lib/junit.mjs +22 -8
- package/lib/scan.mjs +27 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# flake-ledger
|
|
2
2
|
|
|
3
3
|
[](https://github.com/WJDdidi-1/flake-ledger/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/WJDdidi-1/flake-ledger/releases)
|
|
4
5
|
|
|
5
6
|
Ranks flaky tests from JUnit XML across CI runs, including passed-on-retry.
|
|
6
7
|
|
|
@@ -114,6 +115,61 @@ com.example.CheckoutTest::appliesCoupon
|
|
|
114
115
|
`--fail-on` and `--min-runs` are opt-in; without them the command is
|
|
115
116
|
report-only and exits 0.
|
|
116
117
|
|
|
118
|
+
## Measured on real test runners
|
|
119
|
+
|
|
120
|
+
The checked-in example is hand-written. To check the parser against output it
|
|
121
|
+
did not create, the same CLI was run against JUnit XML produced by pytest
|
|
122
|
+
9.2.0 with pytest-rerunfailures 16.7 and by Playwright 1.65.0. Each fixture had
|
|
123
|
+
ten runs, one directory per run; the test files, configs, and commands are in
|
|
124
|
+
[`docs/real-world.md`](https://github.com/WJDdidi-1/flake-ledger/blob/main/docs/real-world.md).
|
|
125
|
+
|
|
126
|
+
pytest-rerunfailures writes one `<testcase>` per attempt, so a test that failed
|
|
127
|
+
once and then passed on retry appears as two passing testcases. From the
|
|
128
|
+
fixture directory, `flake-ledger scan runs` printed:
|
|
129
|
+
|
|
130
|
+
```console
|
|
131
|
+
$ flake-ledger scan runs
|
|
132
|
+
TEST ID RUNS ATTEMPTS UNSTABLE PASS-ON-RETRY FAILED RATE SCORE CLASS
|
|
133
|
+
test_checkout::test_always_failing 10 20 10 0 10 100.0% 0.722 failed-on-retry
|
|
134
|
+
test_checkout::test_applies_coupon 10 20 10 10 0 100.0% 0.722 passed-on-retry
|
|
135
|
+
test_checkout::test_cross_run_flake 10 10 5 0 5 50.0% 0.237 cross-run flake
|
|
136
|
+
test_checkout::test_skipped 10 10 0 0 0 0.0% 0.000 skipped
|
|
137
|
+
test_checkout::test_stable 10 10 0 0 0 0.0% 0.000 stable
|
|
138
|
+
|
|
139
|
+
10 runs, 5 tests, 70 attempts, 10 XML files.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The recovered test is `passed-on-retry` in all 10 runs, the alternating test is
|
|
143
|
+
`cross-run flake` in 5 of 10, and the stable and skipped tests stay in their
|
|
144
|
+
classes.
|
|
145
|
+
|
|
146
|
+
Playwright's JUnit reporter writes `<flakyError>`/`<rerunError>` only when
|
|
147
|
+
`includeRetries: true` is set. With that option, two projects (chromium and
|
|
148
|
+
firefox) and ten runs produced:
|
|
149
|
+
|
|
150
|
+
```console
|
|
151
|
+
$ flake-ledger scan runs
|
|
152
|
+
TEST ID RUNS ATTEMPTS UNSTABLE PASS-ON-RETRY FAILED RATE SCORE CLASS
|
|
153
|
+
checkout.spec.js::failures › always failing 10 40 10 0 10 100.0% 0.722 always-failing
|
|
154
|
+
checkout.spec.js::checkout › applies coupon 10 40 10 10 0 100.0% 0.722 passed-on-retry
|
|
155
|
+
checkout.spec.js::cross-run flake 10 20 5 0 5 50.0% 0.237 cross-run flake
|
|
156
|
+
checkout.spec.js::skipped 10 20 0 0 0 0.0% 0.000 skipped
|
|
157
|
+
checkout.spec.js::stable 10 20 0 0 0 0.0% 0.000 stable
|
|
158
|
+
|
|
159
|
+
10 runs, 5 tests, 140 attempts, 10 XML files.
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The recovered test is `passed-on-retry` in all 10 runs. `stable` has 20 attempts
|
|
163
|
+
because both projects run it, but it is still `stable`: duplicate executions in
|
|
164
|
+
separate Playwright project suites are not retries.
|
|
165
|
+
|
|
166
|
+
**Playwright caveat:** with the default JUnit reporter, Playwright writes only
|
|
167
|
+
the final attempt for each test, so the retry is not in the XML at all. With the
|
|
168
|
+
same ten runs, the row for `checkout.spec.js::checkout › applies coupon` had 20
|
|
169
|
+
attempts, 0 passed-on-retry runs, and class `stable`. `flake-ledger` cannot
|
|
170
|
+
recover that retry from the file; set `includeRetries: true` (or
|
|
171
|
+
`PLAYWRIGHT_JUNIT_INCLUDE_RETRIES=1`) if you want retry evidence in the ledger.
|
|
172
|
+
|
|
117
173
|
## Who it is for
|
|
118
174
|
|
|
119
175
|
Maintainers of multi-runner CI suites (pytest, Playwright, xUnit,
|
|
@@ -128,13 +184,6 @@ free, offline, cross-runner ledger plus a quarantine file.
|
|
|
128
184
|
|
|
129
185
|
## Install
|
|
130
186
|
|
|
131
|
-
From npm:
|
|
132
|
-
|
|
133
|
-
```sh
|
|
134
|
-
npm install --global flake-ledger
|
|
135
|
-
flake-ledger --version
|
|
136
|
-
```
|
|
137
|
-
|
|
138
187
|
From a checkout:
|
|
139
188
|
|
|
140
189
|
```sh
|
|
@@ -144,6 +193,14 @@ npm install --global .
|
|
|
144
193
|
flake-ledger --version
|
|
145
194
|
```
|
|
146
195
|
|
|
196
|
+
The npm package is published by the release workflow when a `v*` tag is
|
|
197
|
+
pushed. The latest release is `v0.1.1`:
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
npm install --global flake-ledger@0.1.1
|
|
201
|
+
flake-ledger --version # 0.1.1
|
|
202
|
+
```
|
|
203
|
+
|
|
147
204
|
To try it without a global install, run the checked-in five-run example:
|
|
148
205
|
|
|
149
206
|
```sh
|
|
@@ -226,6 +283,13 @@ Supported inputs are Surefire and compatible JUnit XML, including
|
|
|
226
283
|
attributes, and repeated `<testcase>` elements for the same test. A malformed
|
|
227
284
|
XML file produces a warning and does not hide healthy files.
|
|
228
285
|
|
|
286
|
+
Real runners differ in how much of that they emit. pytest-rerunfailures
|
|
287
|
+
recovers a test by writing the passing `<testcase>` again, so repeated passing
|
|
288
|
+
testcases in one suite count as retry evidence. Playwright writes
|
|
289
|
+
`<flakyError>`/`<rerunError>` only with `includeRetries: true`; without it its
|
|
290
|
+
JUnit XML contains only the final attempt. The measured output from both
|
|
291
|
+
runners is in [`docs/real-world.md`](https://github.com/WJDdidi-1/flake-ledger/blob/main/docs/real-world.md).
|
|
292
|
+
|
|
229
293
|
## What it reports
|
|
230
294
|
|
|
231
295
|
`scan` collapses all of those attempts into one record per `classname::name`
|
|
@@ -316,6 +380,10 @@ readable path through the same CLI: `npm run example` runs `scan`, `explain`,
|
|
|
316
380
|
`--quarantine`, and the CI gate against it, and exits nonzero if the table,
|
|
317
381
|
timeline, quarantine list, or exit codes change.
|
|
318
382
|
|
|
383
|
+
The real-runner measurement in [`docs/real-world.md`](https://github.com/WJDdidi-1/flake-ledger/blob/main/docs/real-world.md)
|
|
384
|
+
records the exact commands and output from pytest-rerunfailures and Playwright,
|
|
385
|
+
including the Playwright `includeRetries` caveat.
|
|
386
|
+
|
|
319
387
|
CI runs the suite on Ubuntu, macOS (Intel), and Windows for Node.js 18, 20, 22,
|
|
320
388
|
and 24. Each job also packs the tarball, installs it into a clean directory,
|
|
321
389
|
and runs the installed binary, so the published package is tested rather than
|
|
@@ -328,6 +396,20 @@ npm test
|
|
|
328
396
|
node bin/flake-ledger.mjs scan path/to/runs
|
|
329
397
|
```
|
|
330
398
|
|
|
399
|
+
## Releases
|
|
400
|
+
|
|
401
|
+
Versions are SemVer tags (`vMAJOR.MINOR.PATCH`) and must match `version` in
|
|
402
|
+
`package.json`. Pushing a `v*` tag runs the publish workflow, which tests,
|
|
403
|
+
packs the tarball, installs it into a clean directory, and publishes it to npm
|
|
404
|
+
with provenance. The latest release is `v0.1.1`, matching `version` `0.1.1` in
|
|
405
|
+
`package.json`.
|
|
406
|
+
|
|
407
|
+
The changelog for each version lives in [`CHANGELOG.md`](CHANGELOG.md), the
|
|
408
|
+
per-release GitHub Release bodies are in
|
|
409
|
+
[`docs/releases`](https://github.com/WJDdidi-1/flake-ledger/tree/main/docs/releases),
|
|
410
|
+
and the maintainer steps are in
|
|
411
|
+
[`docs/RELEASING.md`](https://github.com/WJDdidi-1/flake-ledger/blob/main/docs/RELEASING.md).
|
|
412
|
+
|
|
331
413
|
## Status
|
|
332
414
|
|
|
333
415
|
Early MVP. The features above are the scope; anything not listed is not built
|
package/lib/junit.mjs
CHANGED
|
@@ -241,7 +241,7 @@ function testcaseIdentity(node, suiteName) {
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
function addTestcase(node, suiteName, sourcePath, output) {
|
|
244
|
+
function addTestcase(node, suiteName, sourcePath, output, suiteKey) {
|
|
245
245
|
const identity = testcaseIdentity(node, suiteName);
|
|
246
246
|
const attempts = attemptsForTestcase(node);
|
|
247
247
|
output.testcaseCount += 1;
|
|
@@ -251,6 +251,7 @@ function addTestcase(node, suiteName, sourcePath, output) {
|
|
|
251
251
|
...attempt,
|
|
252
252
|
source: sourcePath,
|
|
253
253
|
suite: suiteName,
|
|
254
|
+
suiteKey,
|
|
254
255
|
});
|
|
255
256
|
}
|
|
256
257
|
}
|
|
@@ -264,13 +265,25 @@ function visitContainer(node, context, output) {
|
|
|
264
265
|
} else if (tag === 'testsuites') {
|
|
265
266
|
visitContainer(child, context, output);
|
|
266
267
|
} else if (tag === 'testcase') {
|
|
267
|
-
addTestcase(
|
|
268
|
+
addTestcase(
|
|
269
|
+
child,
|
|
270
|
+
context.suiteName,
|
|
271
|
+
context.sourcePath,
|
|
272
|
+
output,
|
|
273
|
+
context.suiteKey || `${context.sourcePath}#root`,
|
|
274
|
+
);
|
|
268
275
|
}
|
|
269
276
|
}
|
|
270
277
|
}
|
|
271
278
|
|
|
272
279
|
function visitSuite(node, context, output) {
|
|
273
280
|
const ownName = getAttribute(node, 'name') || context.suiteName || null;
|
|
281
|
+
// A report can contain several <testsuite> elements with the same name:
|
|
282
|
+
// Playwright, for example, writes one per project (hostname). The key keeps
|
|
283
|
+
// repeated testcases from one suite together while keeping duplicate runs of
|
|
284
|
+
// the same test in different projects apart.
|
|
285
|
+
const suiteKey = `${context.sourcePath}#suite-${context.suiteCounter.value}`;
|
|
286
|
+
context.suiteCounter.value += 1;
|
|
274
287
|
output.suites.push(suiteMetadata(node, context.sourcePath));
|
|
275
288
|
const timestamp = getAttribute(node, 'timestamp');
|
|
276
289
|
if (timestamp) {
|
|
@@ -285,11 +298,11 @@ function visitSuite(node, context, output) {
|
|
|
285
298
|
if (!isElement(child)) continue;
|
|
286
299
|
const tag = lower(localName(child.name));
|
|
287
300
|
if (tag === 'testsuite') {
|
|
288
|
-
visitSuite(child, { ...context, suiteName: ownName }, output);
|
|
301
|
+
visitSuite(child, { ...context, suiteName: ownName, suiteKey }, output);
|
|
289
302
|
} else if (tag === 'testsuites') {
|
|
290
|
-
visitContainer(child, { ...context, suiteName: ownName }, output);
|
|
303
|
+
visitContainer(child, { ...context, suiteName: ownName, suiteKey }, output);
|
|
291
304
|
} else if (tag === 'testcase') {
|
|
292
|
-
addTestcase(child, ownName, context.sourcePath, output);
|
|
305
|
+
addTestcase(child, ownName, context.sourcePath, output, suiteKey);
|
|
293
306
|
}
|
|
294
307
|
}
|
|
295
308
|
}
|
|
@@ -311,16 +324,17 @@ export function parseJUnit(source, sourcePath = '<junit.xml>') {
|
|
|
311
324
|
timestamp: null,
|
|
312
325
|
timestampMs: null,
|
|
313
326
|
};
|
|
327
|
+
const suiteCounter = { value: 0 };
|
|
314
328
|
|
|
315
329
|
for (const root of elementChildren(document)) {
|
|
316
330
|
if (!isElement(root)) continue;
|
|
317
331
|
const tag = lower(localName(root.name));
|
|
318
332
|
if (tag === 'testsuite') {
|
|
319
|
-
visitSuite(root, { suiteName: null, sourcePath }, output);
|
|
333
|
+
visitSuite(root, { suiteName: null, sourcePath, suiteCounter }, output);
|
|
320
334
|
} else if (tag === 'testsuites') {
|
|
321
|
-
visitContainer(root, { suiteName: null, sourcePath }, output);
|
|
335
|
+
visitContainer(root, { suiteName: null, sourcePath, suiteCounter }, output);
|
|
322
336
|
} else if (tag === 'testcase') {
|
|
323
|
-
addTestcase(root, null, sourcePath, output);
|
|
337
|
+
addTestcase(root, null, sourcePath, output, `${sourcePath}#root`);
|
|
324
338
|
}
|
|
325
339
|
}
|
|
326
340
|
|
package/lib/scan.mjs
CHANGED
|
@@ -207,6 +207,24 @@ function statusesOf(attempts) {
|
|
|
207
207
|
return attempts.map((attempt) => attempt.status);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
function attemptGroupKey(attempt) {
|
|
211
|
+
// Newer scanners attach suiteKey, which distinguishes separate <testsuite>
|
|
212
|
+
// elements (Playwright projects) from repeated testcases in one suite.
|
|
213
|
+
// Fall back to the source file so older callers with plain attempt objects
|
|
214
|
+
// still merge duplicates from the same report.
|
|
215
|
+
return attempt.suiteKey || attempt.source || '';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasRepeatedAttemptInSameSuite(attempts) {
|
|
219
|
+
const seen = new Set();
|
|
220
|
+
for (const attempt of attempts) {
|
|
221
|
+
const key = attemptGroupKey(attempt);
|
|
222
|
+
if (seen.has(key)) return true;
|
|
223
|
+
seen.add(key);
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
210
228
|
function outcomeForAttempts(attempts) {
|
|
211
229
|
const statuses = statusesOf(attempts);
|
|
212
230
|
const failures = statuses.filter((status) => status === 'failed' || status === 'error');
|
|
@@ -219,7 +237,15 @@ function outcomeForAttempts(attempts) {
|
|
|
219
237
|
}
|
|
220
238
|
if (failures.length > 0) return failures[failures.length - 1];
|
|
221
239
|
if (attempts.some((attempt) => attempt.retried)) return 'passed-on-retry';
|
|
222
|
-
if (passes.length > 0)
|
|
240
|
+
if (passes.length > 0) {
|
|
241
|
+
// pytest-rerunfailures writes one <testcase> per attempt but does not put
|
|
242
|
+
// the earlier failure in the XML: a recovered test appears as the same
|
|
243
|
+
// passing testcase twice. Repeated passes in one suite are therefore retry
|
|
244
|
+
// evidence. The same test in two different Playwright project suites is a
|
|
245
|
+
// duplicate execution, not a retry, so only repeats within one suite count.
|
|
246
|
+
if (hasRepeatedAttemptInSameSuite(attempts)) return 'passed-on-retry';
|
|
247
|
+
return 'passed';
|
|
248
|
+
}
|
|
223
249
|
return statuses.length > 0 ? 'skipped' : 'unknown';
|
|
224
250
|
}
|
|
225
251
|
|