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.
- package/LICENSE +201 -0
- package/README.md +419 -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 +346 -0
- package/lib/report.mjs +133 -0
- package/lib/scan.mjs +537 -0
- package/lib/xml.mjs +296 -0
- package/package.json +39 -4
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { scanRuns, ScanError } from '../lib/scan.mjs';
|
|
4
|
+
import {
|
|
5
|
+
FLAKY_CLASSIFICATIONS,
|
|
6
|
+
flakyTests,
|
|
7
|
+
formatExplain,
|
|
8
|
+
formatTable,
|
|
9
|
+
toJson,
|
|
10
|
+
} from '../lib/report.mjs';
|
|
11
|
+
|
|
12
|
+
const VERSION = JSON.parse(
|
|
13
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
14
|
+
).version;
|
|
15
|
+
|
|
16
|
+
const USAGE = `flake-ledger: rank flaky tests from JUnit XML across CI runs
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
flake-ledger scan <runs-dir> [options]
|
|
20
|
+
flake-ledger explain <runs-dir> <test-id> [--json]
|
|
21
|
+
flake-ledger --help
|
|
22
|
+
flake-ledger --version
|
|
23
|
+
|
|
24
|
+
Scan options:
|
|
25
|
+
--json, -j print machine-readable JSON instead of a table
|
|
26
|
+
--top N show only the N highest-ranked tests
|
|
27
|
+
--quarantine <file> write the ids of flaky tests, one per line
|
|
28
|
+
--fail-on <rate> exit 1 when a flaky test's unstable rate is >= rate
|
|
29
|
+
--min-runs <n> only fail on tests seen in at least n runs (default: 1)
|
|
30
|
+
--group-by <mode> auto (default), file, or parent
|
|
31
|
+
|
|
32
|
+
Layout:
|
|
33
|
+
runs/<run-id>/<shard>.xml merges every XML in that run directory (shards)
|
|
34
|
+
runs/<run-id>.xml each XML directly under runs-dir is its own run
|
|
35
|
+
runs/shard-1.xml explicit shard names directly under runs-dir merge
|
|
36
|
+
runs/run-3-shard-1/*.xml shard-named run directories merge into run-3
|
|
37
|
+
|
|
38
|
+
MVP features:
|
|
39
|
+
- scan recursively parses every JUnit XML, merges sharded files, and
|
|
40
|
+
collapses repeated attempts into one record per classname::name.
|
|
41
|
+
- Classifies cross-run flake, passed-on-retry (Surefire flakyFailure /
|
|
42
|
+
rerunFailure, retry attributes, repeated testcases), always-failing, and
|
|
43
|
+
stable.
|
|
44
|
+
- Prints a table ranked by a 95% Wilson lower bound on unstable rate, with
|
|
45
|
+
--json and --top N for machines.
|
|
46
|
+
- --quarantine <file> writes flaky-only test ids; explain prints the per-run
|
|
47
|
+
timeline with file paths and failure messages.
|
|
48
|
+
- --fail-on <rate> --min-runs <n> makes CI exit 1 when a flaky test crosses
|
|
49
|
+
the threshold; the default stays report-only.
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
class CliError extends Error {
|
|
53
|
+
constructor(message, code = 2) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'CliError';
|
|
56
|
+
this.code = code;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class HelpRequest extends Error {}
|
|
61
|
+
class VersionRequest extends Error {}
|
|
62
|
+
|
|
63
|
+
function requireValue(argv, index, option, inline) {
|
|
64
|
+
if (inline !== undefined) {
|
|
65
|
+
if (inline === '') throw new CliError(`option ${option} needs a value`);
|
|
66
|
+
return { value: inline, index };
|
|
67
|
+
}
|
|
68
|
+
const value = argv[index + 1];
|
|
69
|
+
if (value === undefined || value.startsWith('-')) {
|
|
70
|
+
throw new CliError(`option ${option} needs a value`);
|
|
71
|
+
}
|
|
72
|
+
return { value, index: index + 1 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function integerValue(value, option, minimum) {
|
|
76
|
+
if (!/^\d+$/.test(String(value))) throw new CliError(`option ${option} needs an integer`);
|
|
77
|
+
const parsed = Number(value);
|
|
78
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum) {
|
|
79
|
+
throw new CliError(`option ${option} must be >= ${minimum}`);
|
|
80
|
+
}
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rateValue(value, option) {
|
|
85
|
+
const raw = String(value).trim();
|
|
86
|
+
let parsed;
|
|
87
|
+
if (raw.endsWith('%')) parsed = Number(raw.slice(0, -1)) / 100;
|
|
88
|
+
else parsed = Number(raw);
|
|
89
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
90
|
+
throw new CliError(`option ${option} needs a rate between 0 and 1 (or a percentage)`);
|
|
91
|
+
}
|
|
92
|
+
if (parsed > 1 && parsed <= 100) parsed /= 100;
|
|
93
|
+
if (parsed > 1) throw new CliError(`option ${option} needs a rate between 0 and 1`);
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function splitOption(arg) {
|
|
98
|
+
const equals = arg.indexOf('=');
|
|
99
|
+
if (equals === -1) return { name: arg, inline: undefined };
|
|
100
|
+
return { name: arg.slice(0, equals), inline: arg.slice(equals + 1) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseGroupBy(value) {
|
|
104
|
+
const mode = String(value).toLowerCase();
|
|
105
|
+
if (mode === 'auto' || mode === 'dir' || mode === 'directory') return 'auto';
|
|
106
|
+
if (mode === 'file' || mode === 'each-file') return 'file';
|
|
107
|
+
if (mode === 'parent' || mode === 'folder') return 'parent';
|
|
108
|
+
throw new CliError('option --group-by must be auto, file, or parent');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseScanArgs(argv) {
|
|
112
|
+
const options = {
|
|
113
|
+
json: false,
|
|
114
|
+
top: null,
|
|
115
|
+
quarantine: null,
|
|
116
|
+
failOn: null,
|
|
117
|
+
minRuns: 1,
|
|
118
|
+
groupBy: 'auto',
|
|
119
|
+
};
|
|
120
|
+
const positionals = [];
|
|
121
|
+
|
|
122
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
123
|
+
const arg = argv[index];
|
|
124
|
+
if (arg === '--') {
|
|
125
|
+
positionals.push(...argv.slice(index + 1));
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (arg === '-h' || arg === '--help') throw new HelpRequest();
|
|
129
|
+
if (arg === '-v' || arg === '--version') throw new VersionRequest();
|
|
130
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
131
|
+
positionals.push(arg);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { name, inline } = splitOption(arg);
|
|
136
|
+
if (name === '--json' || name === '-j') {
|
|
137
|
+
if (inline !== undefined) throw new CliError('option --json does not take a value');
|
|
138
|
+
options.json = true;
|
|
139
|
+
} else if (name === '--top') {
|
|
140
|
+
const consumed = requireValue(argv, index, name, inline);
|
|
141
|
+
index = consumed.index;
|
|
142
|
+
options.top = integerValue(consumed.value, name, 0);
|
|
143
|
+
} else if (name === '--quarantine') {
|
|
144
|
+
const consumed = requireValue(argv, index, name, inline);
|
|
145
|
+
index = consumed.index;
|
|
146
|
+
options.quarantine = consumed.value;
|
|
147
|
+
} else if (name === '--fail-on') {
|
|
148
|
+
const consumed = requireValue(argv, index, name, inline);
|
|
149
|
+
index = consumed.index;
|
|
150
|
+
options.failOn = rateValue(consumed.value, name);
|
|
151
|
+
} else if (name === '--min-runs') {
|
|
152
|
+
const consumed = requireValue(argv, index, name, inline);
|
|
153
|
+
index = consumed.index;
|
|
154
|
+
options.minRuns = integerValue(consumed.value, name, 0);
|
|
155
|
+
} else if (name === '--group-by') {
|
|
156
|
+
const consumed = requireValue(argv, index, name, inline);
|
|
157
|
+
index = consumed.index;
|
|
158
|
+
options.groupBy = parseGroupBy(consumed.value);
|
|
159
|
+
} else {
|
|
160
|
+
throw new CliError(`unknown option ${arg}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (positionals.length !== 1) {
|
|
165
|
+
throw new CliError('scan needs exactly one <runs-dir>');
|
|
166
|
+
}
|
|
167
|
+
options.runsDir = positionals[0];
|
|
168
|
+
return options;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseExplainArgs(argv) {
|
|
172
|
+
const options = { json: false, groupBy: 'auto' };
|
|
173
|
+
const positionals = [];
|
|
174
|
+
|
|
175
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
176
|
+
const arg = argv[index];
|
|
177
|
+
if (arg === '--') {
|
|
178
|
+
positionals.push(...argv.slice(index + 1));
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
if (arg === '-h' || arg === '--help') throw new HelpRequest();
|
|
182
|
+
if (arg === '-v' || arg === '--version') throw new VersionRequest();
|
|
183
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
184
|
+
positionals.push(arg);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { name, inline } = splitOption(arg);
|
|
189
|
+
if (name === '--json' || name === '-j') {
|
|
190
|
+
if (inline !== undefined) throw new CliError('option --json does not take a value');
|
|
191
|
+
options.json = true;
|
|
192
|
+
} else if (name === '--group-by') {
|
|
193
|
+
const consumed = requireValue(argv, index, name, inline);
|
|
194
|
+
index = consumed.index;
|
|
195
|
+
options.groupBy = parseGroupBy(consumed.value);
|
|
196
|
+
} else if (name === '--top') {
|
|
197
|
+
// Accepted for symmetry with scan; explain always prints one test.
|
|
198
|
+
const consumed = requireValue(argv, index, name, inline);
|
|
199
|
+
index = consumed.index;
|
|
200
|
+
integerValue(consumed.value, name, 0);
|
|
201
|
+
} else {
|
|
202
|
+
throw new CliError(`unknown option ${arg}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (positionals.length !== 2) {
|
|
207
|
+
throw new CliError('explain needs a <runs-dir> and a test id');
|
|
208
|
+
}
|
|
209
|
+
options.runsDir = positionals[0];
|
|
210
|
+
options.query = positionals[1];
|
|
211
|
+
return options;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function printWarnings(warnings) {
|
|
215
|
+
for (const warning of warnings) {
|
|
216
|
+
process.stderr.write(`warning: ${warning.message}\n`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function writeQuarantine(file, ids) {
|
|
221
|
+
const body = ids.length > 0 ? `${ids.join('\n')}\n` : '';
|
|
222
|
+
writeFileSync(file, body, 'utf8');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function runScan(options) {
|
|
226
|
+
const result = scanRuns(options.runsDir, { groupBy: options.groupBy });
|
|
227
|
+
printWarnings(result.warnings);
|
|
228
|
+
|
|
229
|
+
const shown = options.top === null ? result.tests : result.tests.slice(0, options.top);
|
|
230
|
+
if (options.json) {
|
|
231
|
+
process.stdout.write(`${JSON.stringify(toJson(result, { tests: shown, version: VERSION }), null, 2)}\n`);
|
|
232
|
+
} else if (result.files.length === 0) {
|
|
233
|
+
process.stdout.write(`No JUnit XML files found under ${result.runsDir}.\n`);
|
|
234
|
+
} else if (result.tests.length === 0) {
|
|
235
|
+
process.stdout.write(
|
|
236
|
+
`No testcases found in ${result.files.length} XML file${
|
|
237
|
+
result.files.length === 1 ? '' : 's'
|
|
238
|
+
} under ${result.runsDir}.\n`,
|
|
239
|
+
);
|
|
240
|
+
} else {
|
|
241
|
+
process.stdout.write(
|
|
242
|
+
`${formatTable(shown, {
|
|
243
|
+
totals: result.totals,
|
|
244
|
+
totalTests: result.tests.length,
|
|
245
|
+
})}\n`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let exitCode = 0;
|
|
250
|
+
if (options.quarantine) {
|
|
251
|
+
const ids = flakyTests(result.tests).map((test) => test.id);
|
|
252
|
+
try {
|
|
253
|
+
writeQuarantine(options.quarantine, ids);
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
`wrote ${ids.length} flaky test id${ids.length === 1 ? '' : 's'} to ${options.quarantine}\n`,
|
|
256
|
+
);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
process.stderr.write(`flake-ledger: cannot write quarantine file: ${error.message}\n`);
|
|
259
|
+
exitCode = 2;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (options.failOn !== null) {
|
|
264
|
+
const offending = result.tests.filter(
|
|
265
|
+
(test) =>
|
|
266
|
+
FLAKY_CLASSIFICATIONS.has(test.classification) &&
|
|
267
|
+
test.eligibleRuns >= options.minRuns &&
|
|
268
|
+
test.unstableRate >= options.failOn,
|
|
269
|
+
);
|
|
270
|
+
if (offending.length > 0) {
|
|
271
|
+
for (const test of offending) {
|
|
272
|
+
process.stderr.write(
|
|
273
|
+
`flaky: ${test.id} unstable in ${test.unstableRuns}/${test.eligibleRuns} runs ` +
|
|
274
|
+
`(${(test.unstableRate * 100).toFixed(1)}%) >= ${(options.failOn * 100).toFixed(1)}%\n`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
process.stderr.write(
|
|
278
|
+
`flake-ledger: ${offending.length} flaky test${
|
|
279
|
+
offending.length === 1 ? '' : 's'
|
|
280
|
+
} crossed --fail-on ${options.failOn}\n`,
|
|
281
|
+
);
|
|
282
|
+
if (exitCode === 0) exitCode = 1;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return exitCode;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function findTest(result, query) {
|
|
290
|
+
const exact = result.tests.find((test) => test.id === query);
|
|
291
|
+
if (exact) return { test: exact, matches: [exact] };
|
|
292
|
+
|
|
293
|
+
const matches = result.tests.filter(
|
|
294
|
+
(test) =>
|
|
295
|
+
test.name === query ||
|
|
296
|
+
test.classname === query ||
|
|
297
|
+
test.id.endsWith(`::${query}`) ||
|
|
298
|
+
test.id.includes(query),
|
|
299
|
+
);
|
|
300
|
+
return { test: matches.length === 1 ? matches[0] : null, matches };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function runExplain(options) {
|
|
304
|
+
const result = scanRuns(options.runsDir, { groupBy: options.groupBy });
|
|
305
|
+
printWarnings(result.warnings);
|
|
306
|
+
const { test, matches } = findTest(result, options.query);
|
|
307
|
+
if (!test) {
|
|
308
|
+
if (matches.length === 0) {
|
|
309
|
+
process.stderr.write(`flake-ledger: no test matched ${JSON.stringify(options.query)}\n`);
|
|
310
|
+
} else {
|
|
311
|
+
process.stderr.write(
|
|
312
|
+
`flake-ledger: ${JSON.stringify(options.query)} matched ${matches.length} tests:\n` +
|
|
313
|
+
matches
|
|
314
|
+
.slice(0, 20)
|
|
315
|
+
.map((match) => ` ${match.id}`)
|
|
316
|
+
.join('\n') +
|
|
317
|
+
`${matches.length > 20 ? '\n ...' : ''}\n`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (options.json) {
|
|
324
|
+
process.stdout.write(
|
|
325
|
+
`${JSON.stringify(
|
|
326
|
+
{
|
|
327
|
+
tool: 'flake-ledger',
|
|
328
|
+
version: VERSION,
|
|
329
|
+
runsDir: result.runsDir,
|
|
330
|
+
test,
|
|
331
|
+
warnings: result.warnings,
|
|
332
|
+
},
|
|
333
|
+
null,
|
|
334
|
+
2,
|
|
335
|
+
)}\n`,
|
|
336
|
+
);
|
|
337
|
+
} else {
|
|
338
|
+
process.stdout.write(`${formatExplain(test)}\n`);
|
|
339
|
+
}
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function main() {
|
|
344
|
+
const argv = process.argv.slice(2);
|
|
345
|
+
if (argv.length === 0) {
|
|
346
|
+
process.stdout.write(USAGE);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const command = argv[0];
|
|
351
|
+
if (command === '--help' || command === '-h' || command === 'help') {
|
|
352
|
+
process.stdout.write(USAGE);
|
|
353
|
+
return 0;
|
|
354
|
+
}
|
|
355
|
+
if (command === '--version' || command === '-v') {
|
|
356
|
+
process.stdout.write(`${VERSION}\n`);
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
if (command === 'scan') return runScan(parseScanArgs(argv.slice(1)));
|
|
362
|
+
if (command === 'explain') return runExplain(parseExplainArgs(argv.slice(1)));
|
|
363
|
+
throw new CliError(`unknown command ${JSON.stringify(command)}; try --help`);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (error instanceof HelpRequest) {
|
|
366
|
+
process.stdout.write(USAGE);
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
if (error instanceof VersionRequest) {
|
|
370
|
+
process.stdout.write(`${VERSION}\n`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
if (error instanceof ScanError) {
|
|
374
|
+
process.stderr.write(`flake-ledger: ${error.message}\n`);
|
|
375
|
+
return 2;
|
|
376
|
+
}
|
|
377
|
+
if (error instanceof CliError) {
|
|
378
|
+
process.stderr.write(`flake-ledger: ${error.message}\n`);
|
|
379
|
+
return error.code;
|
|
380
|
+
}
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
process.exitCode = main();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# flake-ledger example
|
|
2
|
+
|
|
3
|
+
Five CI runs, one JUnit XML report per run, checked in so you can see the ledger
|
|
4
|
+
without installing anything first. The reports are small but exercise the
|
|
5
|
+
interesting cases: a test that failed and passed on retry, two tests that failed
|
|
6
|
+
in some runs and passed in others, a stable test, and a skipped test.
|
|
7
|
+
|
|
8
|
+
## Run it
|
|
9
|
+
|
|
10
|
+
From the repository root:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm run example
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
or directly:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
node examples/demo.mjs
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The script runs the real CLI against `examples/runs`, prints four steps, and
|
|
23
|
+
exits nonzero if any of the numbers below stop matching. It has no
|
|
24
|
+
dependencies and does not write outside a temporary directory.
|
|
25
|
+
|
|
26
|
+
You can also run the commands yourself:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
node bin/flake-ledger.mjs scan examples/runs
|
|
30
|
+
node bin/flake-ledger.mjs explain examples/runs 'com.example.CheckoutTest::appliesCoupon'
|
|
31
|
+
node bin/flake-ledger.mjs scan examples/runs --json --top 3
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What is in the fixture
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
examples/runs/
|
|
38
|
+
run-01/results.xml
|
|
39
|
+
run-02/results.xml
|
|
40
|
+
run-03/results.xml
|
|
41
|
+
run-04/results.xml
|
|
42
|
+
run-05/results.xml
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Each file is a separate run because it sits in its own directory. Every run has
|
|
46
|
+
the same five tests:
|
|
47
|
+
|
|
48
|
+
| Test | Runs | Attempts | Unstable | Pass-on-retry | Classification |
|
|
49
|
+
| --- | ---: | ---: | ---: | ---: | --- |
|
|
50
|
+
| `com.example.LoginTest::rejectsExpiredToken` | 5 | 5 | 2 | 0 | cross-run flake |
|
|
51
|
+
| `com.example.SearchTest::filtersByPrice` | 5 | 5 | 2 | 0 | cross-run flake |
|
|
52
|
+
| `com.example.CheckoutTest::appliesCoupon` | 5 | 7 | 2 | 2 | passed-on-retry |
|
|
53
|
+
| `com.example.CartTest::addsItem` | 5 | 5 | 0 | 0 | stable |
|
|
54
|
+
| `com.example.ReportTest::exportsCsv` | 5 | 5 | 0 | 0 | skipped |
|
|
55
|
+
|
|
56
|
+
`run-01` and `run-03` contain Surefire-style `<flakyFailure>` evidence for
|
|
57
|
+
`CheckoutTest::appliesCoupon`: the report records the failed attempt and the
|
|
58
|
+
pass that followed it. That is why the test has seven attempts across five runs
|
|
59
|
+
and stays visible after the run went green.
|
|
60
|
+
|
|
61
|
+
## What to look for
|
|
62
|
+
|
|
63
|
+
`scan` ranks by a 95% Wilson lower bound on the unstable rate, so the two
|
|
64
|
+
cross-run flakes and the passed-on-retry test all score `0.118` from `2/5`
|
|
65
|
+
unstable runs, and the stable/skipped tests score `0.000`:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
TEST ID RUNS ATTEMPTS UNSTABLE PASS-ON-RETRY FAILED RATE SCORE CLASS
|
|
69
|
+
com.example.LoginTest::rejectsExpiredToken 5 5 2 0 2 40.0% 0.118 cross-run flake
|
|
70
|
+
com.example.SearchTest::filtersByPrice 5 5 2 0 2 40.0% 0.118 cross-run flake
|
|
71
|
+
com.example.CheckoutTest::appliesCoupon 5 7 2 2 0 40.0% 0.118 passed-on-retry
|
|
72
|
+
com.example.CartTest::addsItem 5 5 0 0 0 0.0% 0.000 stable
|
|
73
|
+
com.example.ReportTest::exportsCsv 5 5 0 0 0 0.0% 0.000 skipped
|
|
74
|
+
|
|
75
|
+
5 runs, 5 tests, 27 attempts, 5 XML files.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`explain` shows why `appliesCoupon` is not just a passing test. Run it yourself
|
|
79
|
+
to see the per-run timeline, including the failed attempt, its assertion
|
|
80
|
+
message, and the retried pass.
|
|
81
|
+
|
|
82
|
+
The demo's third step writes the quarantine list and applies the CI gate:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
node bin/flake-ledger.mjs scan examples/runs \
|
|
86
|
+
--quarantine flaky.txt --fail-on 0.2 --min-runs 5
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Three tests cross 20%, so the command exits 1; `flaky.txt` contains their ids.
|
|
90
|
+
Without `--fail-on` the same scan is report-only and exits 0.
|
|
91
|
+
|
|
92
|
+
## Use your own reports
|
|
93
|
+
|
|
94
|
+
Point the CLI at the directory where your CI downloads JUnit XML. The first
|
|
95
|
+
directory below the path is treated as one run:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
node bin/flake-ledger.mjs scan path/to/runs
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If your reports are flat files or sharded differently, see the layouts in the
|
|
102
|
+
main [README](../README.md#input-layout) and use `--group-by`.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run the real flake-ledger CLI against the five-run JUnit fixture in
|
|
3
|
+
// examples/runs and check the numbers the README claims.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
|
+
const bin = path.join(repoRoot, 'bin', 'flake-ledger.mjs');
|
|
12
|
+
const runsDir = path.join(repoRoot, 'examples', 'runs');
|
|
13
|
+
const runsArg = 'examples/runs';
|
|
14
|
+
|
|
15
|
+
function quote(part) {
|
|
16
|
+
return /^[A-Za-z0-9_./:=@%+-]+$/.test(part) ? part : JSON.stringify(part);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function display(args) {
|
|
20
|
+
return ['node bin/flake-ledger.mjs', ...args.map(quote)].join(' ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function run(args) {
|
|
24
|
+
const result = spawnSync(process.execPath, [bin, ...args], {
|
|
25
|
+
cwd: repoRoot,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
});
|
|
28
|
+
if (result.error) throw result.error;
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function check(condition, message) {
|
|
33
|
+
if (!condition) throw new Error(message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function show(title, args, result, options = {}) {
|
|
37
|
+
console.log(`\n${title}`);
|
|
38
|
+
console.log(`$ ${display(args)}`);
|
|
39
|
+
if (options.stdout !== false) process.stdout.write(result.stdout);
|
|
40
|
+
if (options.stderr !== false) process.stderr.write(result.stderr);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const temporary = mkdtempSync(path.join(tmpdir(), 'flake-ledger-example-'));
|
|
44
|
+
const quarantineFile = path.join(temporary, 'flaky.txt');
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
console.log('flake-ledger runnable example');
|
|
48
|
+
console.log(`fixtures: ${runsArg} (5 CI runs, one JUnit XML report per run)`);
|
|
49
|
+
console.log('This runs the checked-in CLI, not a mock.');
|
|
50
|
+
|
|
51
|
+
// 1. The ranked ledger.
|
|
52
|
+
const scanArgs = ['scan', runsArg];
|
|
53
|
+
const scan = run(scanArgs);
|
|
54
|
+
check(scan.status === 0, `scan exited ${scan.status}: ${scan.stderr}`);
|
|
55
|
+
show('1. Rank every test across the five runs', scanArgs, scan);
|
|
56
|
+
check(
|
|
57
|
+
scan.stdout.includes(
|
|
58
|
+
'com.example.CheckoutTest::appliesCoupon 5 7 2 2 0 40.0% 0.118 passed-on-retry',
|
|
59
|
+
),
|
|
60
|
+
'scan table is missing the passed-on-retry row',
|
|
61
|
+
);
|
|
62
|
+
check(
|
|
63
|
+
scan.stdout.includes('5 runs, 5 tests, 27 attempts, 5 XML files.'),
|
|
64
|
+
'scan totals changed',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// 2. The timeline that produced the score.
|
|
68
|
+
const explainArgs = [
|
|
69
|
+
'explain',
|
|
70
|
+
runsArg,
|
|
71
|
+
'com.example.CheckoutTest::appliesCoupon',
|
|
72
|
+
];
|
|
73
|
+
const explain = run(explainArgs);
|
|
74
|
+
check(explain.status === 0, `explain exited ${explain.status}: ${explain.stderr}`);
|
|
75
|
+
show("2. Show one test's per-run timeline", explainArgs, explain);
|
|
76
|
+
check(
|
|
77
|
+
explain.stdout.includes('run run-01 passed-on-retry 2 attempts'),
|
|
78
|
+
'explain is missing the run-01 retry',
|
|
79
|
+
);
|
|
80
|
+
check(
|
|
81
|
+
explain.stdout.includes('#1 failed') &&
|
|
82
|
+
explain.stdout.includes('expected 10% discount, got 0') &&
|
|
83
|
+
explain.stdout.includes('#2 passed') &&
|
|
84
|
+
explain.stdout.includes('(retried)'),
|
|
85
|
+
'explain is missing the failed-then-passed attempt pair',
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// 3. The CI gate: write the quarantine list and fail on a crossed threshold.
|
|
89
|
+
const gateArgs = [
|
|
90
|
+
'scan',
|
|
91
|
+
runsArg,
|
|
92
|
+
'--quarantine',
|
|
93
|
+
quarantineFile,
|
|
94
|
+
'--fail-on',
|
|
95
|
+
'0.2',
|
|
96
|
+
'--min-runs',
|
|
97
|
+
'5',
|
|
98
|
+
];
|
|
99
|
+
const gate = run(gateArgs);
|
|
100
|
+
check(gate.status === 1, `gate should exit 1, got ${gate.status}: ${gate.stderr}`);
|
|
101
|
+
show('3. Gate CI on the same ledger', gateArgs, gate, { stdout: false });
|
|
102
|
+
console.log(`exit code 1 (expected: three flaky tests crossed --fail-on 0.2)`);
|
|
103
|
+
|
|
104
|
+
const quarantined = readFileSync(quarantineFile, 'utf8').trimEnd().split('\n');
|
|
105
|
+
console.log('\nquarantine file (temporary):');
|
|
106
|
+
console.log(quarantined.join('\n'));
|
|
107
|
+
check(
|
|
108
|
+
quarantined.join('\n') ===
|
|
109
|
+
[
|
|
110
|
+
'com.example.LoginTest::rejectsExpiredToken',
|
|
111
|
+
'com.example.SearchTest::filtersByPrice',
|
|
112
|
+
'com.example.CheckoutTest::appliesCoupon',
|
|
113
|
+
].join('\n'),
|
|
114
|
+
'quarantine list changed',
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// 4. Machine-readable totals, and the classifications behind the table.
|
|
118
|
+
const jsonArgs = ['scan', runsArg, '--json'];
|
|
119
|
+
const json = run(jsonArgs);
|
|
120
|
+
check(json.status === 0, `scan --json exited ${json.status}: ${json.stderr}`);
|
|
121
|
+
console.log('\n4. Check the machine-readable ledger');
|
|
122
|
+
console.log(`$ ${display(jsonArgs)}`);
|
|
123
|
+
const ledger = JSON.parse(json.stdout);
|
|
124
|
+
console.log(`totals: ${JSON.stringify(ledger.totals)}`);
|
|
125
|
+
check(
|
|
126
|
+
JSON.stringify(ledger.totals) ===
|
|
127
|
+
JSON.stringify({ files: 5, runs: 5, tests: 5, attempts: 27 }),
|
|
128
|
+
`unexpected totals: ${JSON.stringify(ledger.totals)}`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const byId = new Map(ledger.tests.map((test) => [test.id, test]));
|
|
132
|
+
const checkout = byId.get('com.example.CheckoutTest::appliesCoupon');
|
|
133
|
+
check(checkout, 'checkout test is missing from JSON');
|
|
134
|
+
check(checkout.classification === 'passed-on-retry', 'checkout is not passed-on-retry');
|
|
135
|
+
check(checkout.runs === 5 && checkout.attempts === 7, 'checkout run/attempt counts changed');
|
|
136
|
+
check(checkout.passedOnRetryRuns === 2, 'checkout retry count changed');
|
|
137
|
+
check(
|
|
138
|
+
byId.get('com.example.LoginTest::rejectsExpiredToken').classification === 'cross-run flake',
|
|
139
|
+
'login test is not a cross-run flake',
|
|
140
|
+
);
|
|
141
|
+
check(
|
|
142
|
+
byId.get('com.example.SearchTest::filtersByPrice').classification === 'cross-run flake',
|
|
143
|
+
'search test is not a cross-run flake',
|
|
144
|
+
);
|
|
145
|
+
check(byId.get('com.example.CartTest::addsItem').classification === 'stable', 'cart test is not stable');
|
|
146
|
+
check(byId.get('com.example.ReportTest::exportsCsv').classification === 'skipped', 'report test is not skipped');
|
|
147
|
+
|
|
148
|
+
console.log(`\nAll checks passed. The ledger above comes from ${runsArg}.`);
|
|
149
|
+
console.log('Try editing a fixture (or point the CLI at your own runs) and run this again.');
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error(`\nexample failed: ${error.message}`);
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
155
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<testsuites>
|
|
3
|
+
<testsuite name="com.example.CheckoutTest" timestamp="2026-01-05T10:00: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:00:00">
|
|
12
|
+
<testcase classname="com.example.LoginTest" name="rejectsExpiredToken">
|
|
13
|
+
<failure message="expected 401, got 200">
|
|
14
|
+
<stackTrace>org.opentest4j.AssertionFailedError: expected 401, got 200</stackTrace>
|
|
15
|
+
</failure>
|
|
16
|
+
</testcase>
|
|
17
|
+
</testsuite>
|
|
18
|
+
<testsuite name="com.example.SearchTest" timestamp="2026-01-05T10:00:00">
|
|
19
|
+
<testcase classname="com.example.SearchTest" name="filtersByPrice"/>
|
|
20
|
+
</testsuite>
|
|
21
|
+
<testsuite name="com.example.CartTest" timestamp="2026-01-05T10:00:00">
|
|
22
|
+
<testcase classname="com.example.CartTest" name="addsItem"/>
|
|
23
|
+
</testsuite>
|
|
24
|
+
<testsuite name="com.example.ReportTest" timestamp="2026-01-05T10:00:00">
|
|
25
|
+
<testcase classname="com.example.ReportTest" name="exportsCsv">
|
|
26
|
+
<skipped message="CSV export not configured"/>
|
|
27
|
+
</testcase>
|
|
28
|
+
</testsuite>
|
|
29
|
+
</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:10:00">
|
|
4
|
+
<testcase classname="com.example.CheckoutTest" name="appliesCoupon"/>
|
|
5
|
+
</testsuite>
|
|
6
|
+
<testsuite name="com.example.LoginTest" timestamp="2026-01-05T10:10:00">
|
|
7
|
+
<testcase classname="com.example.LoginTest" name="rejectsExpiredToken"/>
|
|
8
|
+
</testsuite>
|
|
9
|
+
<testsuite name="com.example.SearchTest" timestamp="2026-01-05T10:10: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:10:00">
|
|
17
|
+
<testcase classname="com.example.CartTest" name="addsItem"/>
|
|
18
|
+
</testsuite>
|
|
19
|
+
<testsuite name="com.example.ReportTest" timestamp="2026-01-05T10:10:00">
|
|
20
|
+
<testcase classname="com.example.ReportTest" name="exportsCsv">
|
|
21
|
+
<skipped message="CSV export not configured"/>
|
|
22
|
+
</testcase>
|
|
23
|
+
</testsuite>
|
|
24
|
+
</testsuites>
|