flake-ledger 0.0.0-stage → 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.
@@ -0,0 +1,25 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <testsuites>
3
+ <testsuite name="com.example.CheckoutTest" timestamp="2026-01-05T10:20:00">
4
+ <testcase classname="com.example.CheckoutTest" name="appliesCoupon">
5
+ <flakyFailure message="expected 10% discount, got 0">
6
+ <stackTrace>org.opentest4j.AssertionFailedError: expected 10% discount, got 0
7
+ at CheckoutTest.appliesCoupon(CheckoutTest.java:42)</stackTrace>
8
+ </flakyFailure>
9
+ </testcase>
10
+ </testsuite>
11
+ <testsuite name="com.example.LoginTest" timestamp="2026-01-05T10:20:00">
12
+ <testcase classname="com.example.LoginTest" name="rejectsExpiredToken"/>
13
+ </testsuite>
14
+ <testsuite name="com.example.SearchTest" timestamp="2026-01-05T10:20:00">
15
+ <testcase classname="com.example.SearchTest" name="filtersByPrice"/>
16
+ </testsuite>
17
+ <testsuite name="com.example.CartTest" timestamp="2026-01-05T10:20:00">
18
+ <testcase classname="com.example.CartTest" name="addsItem"/>
19
+ </testsuite>
20
+ <testsuite name="com.example.ReportTest" timestamp="2026-01-05T10:20:00">
21
+ <testcase classname="com.example.ReportTest" name="exportsCsv">
22
+ <skipped message="CSV export not configured"/>
23
+ </testcase>
24
+ </testsuite>
25
+ </testsuites>
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <testsuites>
3
+ <testsuite name="com.example.CheckoutTest" timestamp="2026-01-05T10:30:00">
4
+ <testcase classname="com.example.CheckoutTest" name="appliesCoupon"/>
5
+ </testsuite>
6
+ <testsuite name="com.example.LoginTest" timestamp="2026-01-05T10:30:00">
7
+ <testcase classname="com.example.LoginTest" name="rejectsExpiredToken">
8
+ <failure message="expected 401, got 200">
9
+ <stackTrace>org.opentest4j.AssertionFailedError: expected 401, got 200</stackTrace>
10
+ </failure>
11
+ </testcase>
12
+ </testsuite>
13
+ <testsuite name="com.example.SearchTest" timestamp="2026-01-05T10:30:00">
14
+ <testcase classname="com.example.SearchTest" name="filtersByPrice"/>
15
+ </testsuite>
16
+ <testsuite name="com.example.CartTest" timestamp="2026-01-05T10:30:00">
17
+ <testcase classname="com.example.CartTest" name="addsItem"/>
18
+ </testsuite>
19
+ <testsuite name="com.example.ReportTest" timestamp="2026-01-05T10:30:00">
20
+ <testcase classname="com.example.ReportTest" name="exportsCsv">
21
+ <skipped message="CSV export not configured"/>
22
+ </testcase>
23
+ </testsuite>
24
+ </testsuites>
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <testsuites>
3
+ <testsuite name="com.example.CheckoutTest" timestamp="2026-01-05T10:40:00">
4
+ <testcase classname="com.example.CheckoutTest" name="appliesCoupon"/>
5
+ </testsuite>
6
+ <testsuite name="com.example.LoginTest" timestamp="2026-01-05T10:40:00">
7
+ <testcase classname="com.example.LoginTest" name="rejectsExpiredToken"/>
8
+ </testsuite>
9
+ <testsuite name="com.example.SearchTest" timestamp="2026-01-05T10:40:00">
10
+ <testcase classname="com.example.SearchTest" name="filtersByPrice">
11
+ <failure message="expected price 10, got 12">
12
+ <stackTrace>org.opentest4j.AssertionFailedError: expected price 10, got 12</stackTrace>
13
+ </failure>
14
+ </testcase>
15
+ </testsuite>
16
+ <testsuite name="com.example.CartTest" timestamp="2026-01-05T10:40:00">
17
+ <testcase classname="com.example.CartTest" name="addsItem"/>
18
+ </testsuite>
19
+ <testsuite name="com.example.ReportTest" timestamp="2026-01-05T10:40:00">
20
+ <testcase classname="com.example.ReportTest" name="exportsCsv">
21
+ <skipped message="CSV export not configured"/>
22
+ </testcase>
23
+ </testsuite>
24
+ </testsuites>
package/lib/index.mjs ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Programmatic surface for flake-ledger.
3
+ *
4
+ * The CLI is the primary interface, but keeping the scanner and parsers
5
+ * importable makes it easy to embed the ledger in a larger CI report without
6
+ * shelling out.
7
+ */
8
+ export {
9
+ ScanError,
10
+ discoverJUnitFiles,
11
+ groupFilesByRun,
12
+ scanRuns,
13
+ } from './scan.mjs';
14
+ export { parseJUnit, parseJUnitFileContents } from './junit.mjs';
15
+ export { parseXml, XmlParseError, decodeEntities, localName, textContent } from './xml.mjs';
16
+ export {
17
+ FLAKY_CLASSIFICATIONS,
18
+ flakyTests,
19
+ formatExplain,
20
+ formatPercent,
21
+ formatTable,
22
+ isFlaky,
23
+ toJson,
24
+ } from './report.mjs';
package/lib/junit.mjs ADDED
@@ -0,0 +1,346 @@
1
+ import {
2
+ elementChildren,
3
+ getAttribute,
4
+ isElement,
5
+ localName,
6
+ parseXml,
7
+ textContent,
8
+ } from './xml.mjs';
9
+
10
+ const FAILURE_KINDS = new Set(['failure', 'error']);
11
+ const FLAKY_KINDS = new Set(['flakyfailure', 'flakyerror']);
12
+ const RERUN_KINDS = new Set(['rerunfailure', 'rerunerror']);
13
+ const SKIP_KINDS = new Set(['skipped', 'disabled']);
14
+
15
+ function lower(value) {
16
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
17
+ }
18
+
19
+ function numericAttribute(node, name) {
20
+ const raw = getAttribute(node, name);
21
+ if (raw === undefined || raw === '') return null;
22
+ const parsed = Number(raw);
23
+ return Number.isFinite(parsed) ? parsed : null;
24
+ }
25
+
26
+ const RETRY_ATTRIBUTE_NAMES = [
27
+ 'retry',
28
+ 'retries',
29
+ 'rerun',
30
+ 'reruns',
31
+ 'retryCount',
32
+ 'rerunCount',
33
+ 'retry_count',
34
+ 'rerun_count',
35
+ ];
36
+
37
+ function retryEvidence(node) {
38
+ // There is no JUnit standard for retries. Some reporters add retry="1",
39
+ // retries="2", rerun="1" or a *Count attribute to a testcase that only
40
+ // passed after a rerun. getAttribute is case-insensitive for namespaces,
41
+ // so both camelCase and snake_case spellings are covered.
42
+ for (const name of RETRY_ATTRIBUTE_NAMES) {
43
+ const raw = getAttribute(node, name);
44
+ if (raw === undefined || raw === '') continue;
45
+ const trimmed = String(raw).trim();
46
+ const numeric = Number(trimmed);
47
+ if (Number.isFinite(numeric)) {
48
+ if (numeric > 0) return true;
49
+ continue;
50
+ }
51
+ if (/^(true|yes|y)$/i.test(trimmed)) return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ function normalizeStatus(value) {
57
+ const status = lower(value);
58
+ if (status === 'passed' || status === 'pass' || status === 'success' || status === 'ok' || status === 'resolved') {
59
+ return 'passed';
60
+ }
61
+ if (status === 'failed' || status === 'failure' || status === 'fail' || status === 'broken') {
62
+ return 'failed';
63
+ }
64
+ if (status === 'error' || status === 'errored' || status === 'errored-out') return 'error';
65
+ if (
66
+ status === 'skipped' ||
67
+ status === 'skip' ||
68
+ status === 'disabled' ||
69
+ status === 'pending' ||
70
+ status === 'notrun' ||
71
+ status === 'not-run' ||
72
+ status === 'ignored'
73
+ ) {
74
+ return 'skipped';
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function statusKindForTag(node) {
80
+ const tag = lower(localName(node.name));
81
+ if (tag === 'error' || tag === 'flakyerror' || tag === 'rerunerror') return 'error';
82
+ return 'failed';
83
+ }
84
+
85
+ function attemptFromNode(node, status) {
86
+ const details = textContent(node).replace(/\r\n/g, '\n').trim();
87
+ const messageAttribute = getAttribute(node, 'message');
88
+ let message = messageAttribute === undefined ? '' : String(messageAttribute).trim();
89
+ if (!message && details) {
90
+ const firstLine = details.split('\n').find((line) => line.trim() !== '');
91
+ message = firstLine ? firstLine.trim() : '';
92
+ }
93
+ return {
94
+ status,
95
+ message: message || null,
96
+ type: getAttribute(node, 'type') || null,
97
+ details: details || null,
98
+ time: numericAttribute(node, 'time'),
99
+ retried: false,
100
+ };
101
+ }
102
+
103
+ function skippedAttempt(node) {
104
+ const message = getAttribute(node, 'message');
105
+ return {
106
+ status: 'skipped',
107
+ message: message === undefined || String(message).trim() === '' ? null : String(message).trim(),
108
+ type: getAttribute(node, 'type') || null,
109
+ details: textContent(node).trim() || null,
110
+ time: numericAttribute(node, 'time'),
111
+ retried: false,
112
+ };
113
+ }
114
+
115
+ function statusAttempt(node, status) {
116
+ return {
117
+ status,
118
+ message: null,
119
+ type: null,
120
+ details: null,
121
+ time: numericAttribute(node, 'time'),
122
+ retried: status === 'passed' && retryEvidence(node),
123
+ };
124
+ }
125
+
126
+ function passedAttempt(node) {
127
+ const attempt = statusAttempt(node, 'passed');
128
+ // Reaching here means retry evidence (flakyFailure/rerunFailure/attribute)
129
+ // describes the earlier attempts even when the recorder did not copy the
130
+ // retry marker onto the testcase itself.
131
+ attempt.retried = true;
132
+ return attempt;
133
+ }
134
+
135
+ /**
136
+ * Turn one <testcase> element into one or more attempts.
137
+ *
138
+ * Surefire writes a passed-on-retry test as a passing testcase carrying a
139
+ * <flakyFailure>/<flakyError> child describing the earlier failure. It writes
140
+ * a retry that ultimately failed as <rerunFailure>/<rerunError>. Tools that
141
+ * emit one testcase per attempt simply produce one attempt here; the scan
142
+ * layer merges all of them into a single test record.
143
+ */
144
+ function attemptsForTestcase(node) {
145
+ const children = elementChildren(node);
146
+ const failures = [];
147
+ const flaky = [];
148
+ const reruns = [];
149
+ const skips = [];
150
+ for (const child of children) {
151
+ const tag = lower(localName(child.name));
152
+ if (FAILURE_KINDS.has(tag)) failures.push(child);
153
+ else if (FLAKY_KINDS.has(tag)) flaky.push(child);
154
+ else if (RERUN_KINDS.has(tag)) reruns.push(child);
155
+ else if (SKIP_KINDS.has(tag)) skips.push(child);
156
+ }
157
+
158
+ const declared = normalizeStatus(getAttribute(node, 'status') || getAttribute(node, 'result'));
159
+ const attempts = [];
160
+
161
+ if (failures.length > 0) {
162
+ for (const child of flaky) attempts.push(attemptFromNode(child, statusKindForTag(child)));
163
+ // Surefire can emit rerunFailure alongside the final <failure>. Keep the
164
+ // earlier evidence without letting it replace the final outcome.
165
+ for (const child of reruns) attempts.push(attemptFromNode(child, statusKindForTag(child)));
166
+ // The final <failure>/<error> child is the outcome that matters. Adding
167
+ // every failure child would count an error as an extra retry.
168
+ const finalNode = failures[0];
169
+ attempts.push(attemptFromNode(finalNode, statusKindForTag(finalNode)));
170
+ return attempts;
171
+ }
172
+
173
+ if (flaky.length > 0) {
174
+ for (const child of flaky) attempts.push(attemptFromNode(child, statusKindForTag(child)));
175
+ for (const child of reruns) attempts.push(attemptFromNode(child, statusKindForTag(child)));
176
+ if (declared === 'skipped') {
177
+ attempts.push(skippedAttempt(skips[0] || node));
178
+ } else if (declared && declared !== 'passed') {
179
+ attempts.push(statusAttempt(node, declared));
180
+ } else if (declared === 'passed' || reruns.length === 0) {
181
+ // flakyFailure alone means the final attempt passed. An explicit passed
182
+ // status after rerunFailure means the rerun recovered the test too.
183
+ attempts.push(passedAttempt(node));
184
+ }
185
+ return attempts;
186
+ }
187
+
188
+ if (reruns.length > 0) {
189
+ for (const child of reruns) attempts.push(attemptFromNode(child, statusKindForTag(child)));
190
+ if (declared === 'passed') attempts.push(passedAttempt(node));
191
+ else if (declared === 'skipped') attempts.push(skippedAttempt(skips[0] || node));
192
+ // Without an explicit pass, rerunFailure means the final retry stayed red.
193
+ return attempts;
194
+ }
195
+
196
+ if (skips.length > 0) {
197
+ attempts.push(skippedAttempt(skips[0]));
198
+ return attempts;
199
+ }
200
+
201
+ if (declared) {
202
+ attempts.push(statusAttempt(node, declared));
203
+ return attempts;
204
+ }
205
+
206
+ attempts.push(statusAttempt(node, 'passed'));
207
+ return attempts;
208
+ }
209
+
210
+ function suiteMetadata(node, sourcePath) {
211
+ return {
212
+ name: getAttribute(node, 'name') || null,
213
+ timestamp: getAttribute(node, 'timestamp') || null,
214
+ tests: numericAttribute(node, 'tests'),
215
+ failures: numericAttribute(node, 'failures'),
216
+ errors: numericAttribute(node, 'errors'),
217
+ skipped: numericAttribute(node, 'skipped'),
218
+ time: numericAttribute(node, 'time'),
219
+ file: getAttribute(node, 'file') || null,
220
+ source: sourcePath,
221
+ };
222
+ }
223
+
224
+ function testcaseIdentity(node, suiteName) {
225
+ const classname =
226
+ getAttribute(node, 'classname') ||
227
+ getAttribute(node, 'class') ||
228
+ getAttribute(node, 'class_name') ||
229
+ suiteName ||
230
+ '';
231
+ let name = getAttribute(node, 'name');
232
+ if (name === undefined || name === null || String(name) === '') {
233
+ name = getAttribute(node, 'test') || '(unnamed)';
234
+ }
235
+ const className = String(classname).trim();
236
+ const testName = String(name).trim();
237
+ return {
238
+ classname: className,
239
+ name: testName,
240
+ id: `${className}::${testName}`,
241
+ };
242
+ }
243
+
244
+ function addTestcase(node, suiteName, sourcePath, output, suiteKey) {
245
+ const identity = testcaseIdentity(node, suiteName);
246
+ const attempts = attemptsForTestcase(node);
247
+ output.testcaseCount += 1;
248
+ for (const attempt of attempts) {
249
+ output.attempts.push({
250
+ ...identity,
251
+ ...attempt,
252
+ source: sourcePath,
253
+ suite: suiteName,
254
+ suiteKey,
255
+ });
256
+ }
257
+ }
258
+
259
+ function visitContainer(node, context, output) {
260
+ for (const child of elementChildren(node)) {
261
+ if (!isElement(child)) continue;
262
+ const tag = lower(localName(child.name));
263
+ if (tag === 'testsuite') {
264
+ visitSuite(child, context, output);
265
+ } else if (tag === 'testsuites') {
266
+ visitContainer(child, context, output);
267
+ } else if (tag === 'testcase') {
268
+ addTestcase(
269
+ child,
270
+ context.suiteName,
271
+ context.sourcePath,
272
+ output,
273
+ context.suiteKey || `${context.sourcePath}#root`,
274
+ );
275
+ }
276
+ }
277
+ }
278
+
279
+ function visitSuite(node, context, output) {
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;
287
+ output.suites.push(suiteMetadata(node, context.sourcePath));
288
+ const timestamp = getAttribute(node, 'timestamp');
289
+ if (timestamp) {
290
+ const time = Date.parse(timestamp);
291
+ if (Number.isFinite(time) && (output.timestampMs === null || time < output.timestampMs)) {
292
+ output.timestampMs = time;
293
+ output.timestamp = timestamp;
294
+ }
295
+ }
296
+
297
+ for (const child of elementChildren(node)) {
298
+ if (!isElement(child)) continue;
299
+ const tag = lower(localName(child.name));
300
+ if (tag === 'testsuite') {
301
+ visitSuite(child, { ...context, suiteName: ownName, suiteKey }, output);
302
+ } else if (tag === 'testsuites') {
303
+ visitContainer(child, { ...context, suiteName: ownName, suiteKey }, output);
304
+ } else if (tag === 'testcase') {
305
+ addTestcase(child, ownName, context.sourcePath, output, suiteKey);
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Parse one JUnit XML document.
312
+ *
313
+ * Returns the test attempts in document order along with lightweight suite
314
+ * metadata. Structural XML errors throw XmlParseError; the scan layer catches
315
+ * them so one broken shard cannot hide the rest of the ledger.
316
+ */
317
+ export function parseJUnit(source, sourcePath = '<junit.xml>') {
318
+ const document = parseXml(source, { sourceName: sourcePath });
319
+ const output = {
320
+ sourcePath,
321
+ suites: [],
322
+ attempts: [],
323
+ testcaseCount: 0,
324
+ timestamp: null,
325
+ timestampMs: null,
326
+ };
327
+ const suiteCounter = { value: 0 };
328
+
329
+ for (const root of elementChildren(document)) {
330
+ if (!isElement(root)) continue;
331
+ const tag = lower(localName(root.name));
332
+ if (tag === 'testsuite') {
333
+ visitSuite(root, { suiteName: null, sourcePath, suiteCounter }, output);
334
+ } else if (tag === 'testsuites') {
335
+ visitContainer(root, { suiteName: null, sourcePath, suiteCounter }, output);
336
+ } else if (tag === 'testcase') {
337
+ addTestcase(root, null, sourcePath, output, `${sourcePath}#root`);
338
+ }
339
+ }
340
+
341
+ return output;
342
+ }
343
+
344
+ export function parseJUnitFileContents(contents, sourcePath) {
345
+ return parseJUnit(contents, sourcePath);
346
+ }
package/lib/report.mjs ADDED
@@ -0,0 +1,133 @@
1
+ const FLAKY_CLASSIFICATIONS = new Set(['cross-run flake', 'passed-on-retry', 'failed-on-retry']);
2
+
3
+ export function isFlaky(test) {
4
+ return FLAKY_CLASSIFICATIONS.has(test.classification);
5
+ }
6
+
7
+ export function flakyTests(tests) {
8
+ return tests.filter(isFlaky);
9
+ }
10
+
11
+ export function formatPercent(value, digits = 1) {
12
+ if (!Number.isFinite(value)) return '0.0%';
13
+ return `${(value * 100).toFixed(digits)}%`;
14
+ }
15
+
16
+ function tableRows(tests) {
17
+ return tests.map((test) => [
18
+ test.id,
19
+ String(test.runs),
20
+ String(test.attempts),
21
+ String(test.unstableRuns),
22
+ String(test.passedOnRetryRuns),
23
+ String(test.failedRuns + test.errorRuns),
24
+ formatPercent(test.unstableRate),
25
+ test.score.toFixed(3),
26
+ test.classification,
27
+ ]);
28
+ }
29
+
30
+ export function formatTable(tests, options = {}) {
31
+ const headers = [
32
+ 'TEST ID',
33
+ 'RUNS',
34
+ 'ATTEMPTS',
35
+ 'UNSTABLE',
36
+ 'PASS-ON-RETRY',
37
+ 'FAILED',
38
+ 'RATE',
39
+ 'SCORE',
40
+ 'CLASS',
41
+ ];
42
+ const rows = tableRows(tests);
43
+ const widths = headers.map((header, index) =>
44
+ rows.reduce(
45
+ (width, row) => Math.max(width, String(row[index]).length),
46
+ header.length,
47
+ ),
48
+ );
49
+ const render = (cells) =>
50
+ cells
51
+ .map((cell, index) => (index === cells.length - 1 ? String(cell) : String(cell).padEnd(widths[index])))
52
+ .join(' ')
53
+ .replace(/\s+$/, '');
54
+
55
+ const lines = [render(headers)];
56
+ for (const row of rows) lines.push(render(row));
57
+
58
+ const totals = options.totals;
59
+ if (totals) {
60
+ lines.push('');
61
+ const shown =
62
+ options.totalTests !== undefined && options.totalTests > tests.length
63
+ ? `showing ${tests.length} of ${options.totalTests} tests; `
64
+ : '';
65
+ lines.push(
66
+ `${shown}${totals.runs} run${totals.runs === 1 ? '' : 's'}, ` +
67
+ `${totals.tests} test${totals.tests === 1 ? '' : 's'}, ` +
68
+ `${totals.attempts} attempt${totals.attempts === 1 ? '' : 's'}, ` +
69
+ `${totals.files} XML file${totals.files === 1 ? '' : 's'}.`,
70
+ );
71
+ }
72
+
73
+ return lines.join('\n');
74
+ }
75
+
76
+ function indentedMessage(message) {
77
+ return String(message)
78
+ .split(/\r?\n/)
79
+ .map((line) => ` ${line}`)
80
+ .join('\n');
81
+ }
82
+
83
+ export function formatExplain(test) {
84
+ const lines = [
85
+ test.id,
86
+ ` classification: ${test.classification}`,
87
+ ` runs: ${test.runs} attempts: ${test.attempts} unstable: ${test.unstableRuns}/${test.eligibleRuns} ` +
88
+ `rate: ${formatPercent(test.unstableRate)} score: ${test.score.toFixed(3)}`,
89
+ ];
90
+
91
+ for (const entry of test.timeline) {
92
+ lines.push('');
93
+ lines.push(
94
+ ` run ${entry.runId} ${entry.outcome} ${entry.attemptCount} attempt${
95
+ entry.attemptCount === 1 ? '' : 's'
96
+ }`,
97
+ );
98
+ lines.push(` ${entry.runPath}`);
99
+ entry.attempts.forEach((attempt, index) => {
100
+ const type = attempt.type ? ` [${attempt.type}]` : '';
101
+ const retried = attempt.retried ? ' (retried)' : '';
102
+ lines.push(
103
+ ` #${index + 1} ${String(attempt.status).padEnd(7)} ${attempt.source}${type}${retried}`,
104
+ );
105
+ if (attempt.message) lines.push(indentedMessage(attempt.message));
106
+ });
107
+ }
108
+
109
+ return lines.join('\n');
110
+ }
111
+
112
+ export function toJson(result, options = {}) {
113
+ const tests = options.tests || result.tests;
114
+ return {
115
+ tool: 'flake-ledger',
116
+ version: options.version || null,
117
+ runsDir: result.runsDir,
118
+ totals: result.totals,
119
+ runs: result.runs.map((run) => ({
120
+ id: run.id,
121
+ path: run.path,
122
+ startedAt: run.startedAt,
123
+ files: run.files,
124
+ fileCount: run.files.length,
125
+ testCount: run.testCount,
126
+ attemptCount: run.attemptCount,
127
+ })),
128
+ tests,
129
+ warnings: result.warnings,
130
+ };
131
+ }
132
+
133
+ export { FLAKY_CLASSIFICATIONS };