flake-ledger 0.0.0-stage → 0.1.0
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/LICENSE +201 -0
- package/README.md +337 -2
- package/bin/flake-ledger.mjs +385 -0
- package/examples/README.md +102 -0
- package/examples/demo.mjs +155 -0
- package/examples/runs/run-01/results.xml +29 -0
- package/examples/runs/run-02/results.xml +24 -0
- package/examples/runs/run-03/results.xml +25 -0
- package/examples/runs/run-04/results.xml +24 -0
- package/examples/runs/run-05/results.xml +24 -0
- package/lib/index.mjs +24 -0
- package/lib/junit.mjs +332 -0
- package/lib/report.mjs +133 -0
- package/lib/scan.mjs +511 -0
- package/lib/xml.mjs +296 -0
- package/package.json +39 -4
|
@@ -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,332 @@
|
|
|
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) {
|
|
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
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function visitContainer(node, context, output) {
|
|
259
|
+
for (const child of elementChildren(node)) {
|
|
260
|
+
if (!isElement(child)) continue;
|
|
261
|
+
const tag = lower(localName(child.name));
|
|
262
|
+
if (tag === 'testsuite') {
|
|
263
|
+
visitSuite(child, context, output);
|
|
264
|
+
} else if (tag === 'testsuites') {
|
|
265
|
+
visitContainer(child, context, output);
|
|
266
|
+
} else if (tag === 'testcase') {
|
|
267
|
+
addTestcase(child, context.suiteName, context.sourcePath, output);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function visitSuite(node, context, output) {
|
|
273
|
+
const ownName = getAttribute(node, 'name') || context.suiteName || null;
|
|
274
|
+
output.suites.push(suiteMetadata(node, context.sourcePath));
|
|
275
|
+
const timestamp = getAttribute(node, 'timestamp');
|
|
276
|
+
if (timestamp) {
|
|
277
|
+
const time = Date.parse(timestamp);
|
|
278
|
+
if (Number.isFinite(time) && (output.timestampMs === null || time < output.timestampMs)) {
|
|
279
|
+
output.timestampMs = time;
|
|
280
|
+
output.timestamp = timestamp;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
for (const child of elementChildren(node)) {
|
|
285
|
+
if (!isElement(child)) continue;
|
|
286
|
+
const tag = lower(localName(child.name));
|
|
287
|
+
if (tag === 'testsuite') {
|
|
288
|
+
visitSuite(child, { ...context, suiteName: ownName }, output);
|
|
289
|
+
} else if (tag === 'testsuites') {
|
|
290
|
+
visitContainer(child, { ...context, suiteName: ownName }, output);
|
|
291
|
+
} else if (tag === 'testcase') {
|
|
292
|
+
addTestcase(child, ownName, context.sourcePath, output);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Parse one JUnit XML document.
|
|
299
|
+
*
|
|
300
|
+
* Returns the test attempts in document order along with lightweight suite
|
|
301
|
+
* metadata. Structural XML errors throw XmlParseError; the scan layer catches
|
|
302
|
+
* them so one broken shard cannot hide the rest of the ledger.
|
|
303
|
+
*/
|
|
304
|
+
export function parseJUnit(source, sourcePath = '<junit.xml>') {
|
|
305
|
+
const document = parseXml(source, { sourceName: sourcePath });
|
|
306
|
+
const output = {
|
|
307
|
+
sourcePath,
|
|
308
|
+
suites: [],
|
|
309
|
+
attempts: [],
|
|
310
|
+
testcaseCount: 0,
|
|
311
|
+
timestamp: null,
|
|
312
|
+
timestampMs: null,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
for (const root of elementChildren(document)) {
|
|
316
|
+
if (!isElement(root)) continue;
|
|
317
|
+
const tag = lower(localName(root.name));
|
|
318
|
+
if (tag === 'testsuite') {
|
|
319
|
+
visitSuite(root, { suiteName: null, sourcePath }, output);
|
|
320
|
+
} else if (tag === 'testsuites') {
|
|
321
|
+
visitContainer(root, { suiteName: null, sourcePath }, output);
|
|
322
|
+
} else if (tag === 'testcase') {
|
|
323
|
+
addTestcase(root, null, sourcePath, output);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return output;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function parseJUnitFileContents(contents, sourcePath) {
|
|
331
|
+
return parseJUnit(contents, sourcePath);
|
|
332
|
+
}
|
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 };
|