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 CHANGED
@@ -1,6 +1,7 @@
1
1
  # flake-ledger
2
2
 
3
3
  [![CI](https://github.com/WJDdidi-1/flake-ledger/actions/workflows/ci.yml/badge.svg)](https://github.com/WJDdidi-1/flake-ledger/actions/workflows/ci.yml)
4
+ [![version](https://img.shields.io/github/package-json/v/WJDdidi-1/flake-ledger)](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(child, context.suiteName, context.sourcePath, output);
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) return 'passed';
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flake-ledger",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Ranks flaky tests from JUnit XML across CI runs, including passed-on-retry.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",