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
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseJUnit } from './junit.mjs';
|
|
4
|
+
|
|
5
|
+
const SKIPPED_DIRECTORIES = new Set(['.git', '.hg', '.svn', 'node_modules']);
|
|
6
|
+
const JUNIT_EXTENSIONS = ['.xml', '.junit', '.xunit'];
|
|
7
|
+
|
|
8
|
+
export class ScanError extends Error {
|
|
9
|
+
constructor(message, options = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ScanError';
|
|
12
|
+
this.path = options.path || null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isJUnitFile(name) {
|
|
17
|
+
const lower = name.toLowerCase();
|
|
18
|
+
return JUNIT_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function naturalCompare(left, right) {
|
|
22
|
+
return String(left).localeCompare(String(right), 'en', { numeric: true, sensitivity: 'base' });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively find JUnit XML files under runsDir.
|
|
27
|
+
*
|
|
28
|
+
* VCS metadata and node_modules are skipped, symlinked directories are
|
|
29
|
+
* followed once (so a loop cannot hang the scan), and unreadable entries
|
|
30
|
+
* become warnings instead of aborting the whole ledger.
|
|
31
|
+
*/
|
|
32
|
+
export function discoverJUnitFiles(runsDir, options = {}) {
|
|
33
|
+
const root = path.resolve(runsDir);
|
|
34
|
+
const warnings = options.warnings || [];
|
|
35
|
+
const files = [];
|
|
36
|
+
const visitedDirectories = new Set();
|
|
37
|
+
|
|
38
|
+
let rootStat;
|
|
39
|
+
try {
|
|
40
|
+
rootStat = statSync(root);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new ScanError(`cannot read runs directory ${root}: ${error.message}`, { path: root });
|
|
43
|
+
}
|
|
44
|
+
if (!rootStat.isDirectory()) {
|
|
45
|
+
throw new ScanError(`runs path is not a directory: ${root}`, { path: root });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const walk = (directory) => {
|
|
49
|
+
let realDirectory;
|
|
50
|
+
try {
|
|
51
|
+
realDirectory = realpathSync(directory);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
warnings.push({ file: directory, message: `cannot resolve directory: ${error.message}` });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (visitedDirectories.has(realDirectory)) return;
|
|
57
|
+
visitedDirectories.add(realDirectory);
|
|
58
|
+
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
warnings.push({ file: directory, message: `cannot read directory: ${error.message}` });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
entries.sort((left, right) => naturalCompare(left.name, right.name));
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const fullPath = path.join(directory, entry.name);
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
|
|
72
|
+
walk(fullPath);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (entry.isSymbolicLink()) {
|
|
76
|
+
let target;
|
|
77
|
+
try {
|
|
78
|
+
target = statSync(fullPath);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
warnings.push({ file: fullPath, message: `cannot follow symlink: ${error.message}` });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (target.isDirectory()) {
|
|
84
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(fullPath);
|
|
85
|
+
} else if (target.isFile() && isJUnitFile(entry.name)) {
|
|
86
|
+
files.push(fullPath);
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (entry.isFile() && isJUnitFile(entry.name)) files.push(fullPath);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
walk(root);
|
|
95
|
+
files.sort((left, right) => naturalCompare(path.relative(root, left), path.relative(root, right)));
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Recognize the common shard markers in a file or directory stem:
|
|
101
|
+
* shard-1, part-2, run-3-worker-4, or report-1-of-2. Returns the run prefix
|
|
102
|
+
* before the marker (possibly the empty string) or null when there is none.
|
|
103
|
+
*/
|
|
104
|
+
function shardPrefixForStem(stem) {
|
|
105
|
+
const shardMatch = stem.match(
|
|
106
|
+
/^(?:(.*?)[-_.])?(shard|part|chunk|split|worker|matrix|node)(?:[-_.]?\d+)?(?:[-_.]of[-_.]?\d+)?$/i,
|
|
107
|
+
);
|
|
108
|
+
if (shardMatch) return (shardMatch[1] || '').replace(/[-_.]+$/, '');
|
|
109
|
+
const ofMatch = stem.match(/^(?:(.*?)[-_.])?\d+[-_.]of[-_.]?\d+$/i);
|
|
110
|
+
if (ofMatch) return (ofMatch[1] || '').replace(/[-_.]+$/, '');
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Recognize the flat layout runs/<run-id>-shard-1.xml or runs/shard-1.xml.
|
|
116
|
+
* Files whose names carry a shard marker belong to one run; ordinary files
|
|
117
|
+
* such as run-1.xml remain separate runs.
|
|
118
|
+
*/
|
|
119
|
+
function rootShardIdentity(relativePath) {
|
|
120
|
+
if (relativePath.includes(path.sep)) return null;
|
|
121
|
+
const stem = path.basename(relativePath, path.extname(relativePath));
|
|
122
|
+
const prefix = shardPrefixForStem(stem);
|
|
123
|
+
if (prefix === null) return null;
|
|
124
|
+
return { id: prefix || 'root', key: prefix.toLowerCase() || 'root' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Recognize a top-level run directory named run-1-shard-2 or shard-2. These
|
|
129
|
+
* are artifacts from one CI run split across directories rather than files.
|
|
130
|
+
*/
|
|
131
|
+
function directoryShardIdentity(directoryName) {
|
|
132
|
+
const prefix = shardPrefixForStem(directoryName);
|
|
133
|
+
if (prefix === null) return null;
|
|
134
|
+
return { id: prefix || 'root', key: prefix.toLowerCase() || 'root' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Decide which XML files belong to the same CI run.
|
|
139
|
+
*
|
|
140
|
+
* The conventional layout is runs/<run-id>/<shard>.xml, so by default the
|
|
141
|
+
* first directory below <runs-dir> is the run and every XML inside it is a
|
|
142
|
+
* shard of that run. XML files placed directly in <runs-dir> are separate
|
|
143
|
+
* runs, except when their names carry an explicit shard marker such as
|
|
144
|
+
* shard-1.xml or run-3-part-2.xml. Top-level directories with the same shard
|
|
145
|
+
* marker (run-3-shard-1/, run-3-shard-2/) are merged the same way.
|
|
146
|
+
*
|
|
147
|
+
* groupBy "parent" groups by immediate parent directory; "file" makes every
|
|
148
|
+
* XML document its own run.
|
|
149
|
+
*/
|
|
150
|
+
export function groupFilesByRun(runsDir, files, options = {}) {
|
|
151
|
+
const root = path.resolve(runsDir);
|
|
152
|
+
const groupBy = options.groupBy || 'auto';
|
|
153
|
+
const groups = new Map();
|
|
154
|
+
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const relative = path.relative(root, file);
|
|
157
|
+
const parts = relative.split(path.sep);
|
|
158
|
+
let key;
|
|
159
|
+
let id;
|
|
160
|
+
let runPath;
|
|
161
|
+
|
|
162
|
+
const shardIdentity =
|
|
163
|
+
groupBy === 'auto' && parts.length === 1 ? rootShardIdentity(relative) : null;
|
|
164
|
+
const directoryShard =
|
|
165
|
+
groupBy === 'auto' && parts.length > 1 ? directoryShardIdentity(parts[0]) : null;
|
|
166
|
+
|
|
167
|
+
if (groupBy === 'file') {
|
|
168
|
+
key = `file:${relative}`;
|
|
169
|
+
id = path.basename(relative, path.extname(relative));
|
|
170
|
+
runPath = file;
|
|
171
|
+
} else if (shardIdentity) {
|
|
172
|
+
key = `shards:${shardIdentity.key}`;
|
|
173
|
+
id = shardIdentity.id;
|
|
174
|
+
runPath = root;
|
|
175
|
+
} else if (directoryShard) {
|
|
176
|
+
key = `shards:${directoryShard.key}`;
|
|
177
|
+
id = directoryShard.id;
|
|
178
|
+
runPath = root;
|
|
179
|
+
} else if (parts.length === 1) {
|
|
180
|
+
key = `file:${relative}`;
|
|
181
|
+
id = path.basename(relative, path.extname(relative));
|
|
182
|
+
runPath = file;
|
|
183
|
+
} else if (groupBy === 'parent') {
|
|
184
|
+
const parent = parts.slice(0, -1).join(path.sep);
|
|
185
|
+
key = `parent:${parent}`;
|
|
186
|
+
id = parent;
|
|
187
|
+
runPath = path.join(root, parent);
|
|
188
|
+
} else {
|
|
189
|
+
key = `dir:${parts[0]}`;
|
|
190
|
+
id = parts[0];
|
|
191
|
+
runPath = path.join(root, parts[0]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!groups.has(key)) {
|
|
195
|
+
groups.set(key, { id, path: runPath, files: [] });
|
|
196
|
+
}
|
|
197
|
+
groups.get(key).files.push(file);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return [...groups.values()].sort((left, right) => {
|
|
201
|
+
const idOrder = naturalCompare(left.id, right.id);
|
|
202
|
+
return idOrder !== 0 ? idOrder : naturalCompare(left.path, right.path);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function statusesOf(attempts) {
|
|
207
|
+
return attempts.map((attempt) => attempt.status);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function outcomeForAttempts(attempts) {
|
|
211
|
+
const statuses = statusesOf(attempts);
|
|
212
|
+
const failures = statuses.filter((status) => status === 'failed' || status === 'error');
|
|
213
|
+
const passes = statuses.filter((status) => status === 'passed');
|
|
214
|
+
|
|
215
|
+
if (failures.length > 0 && passes.length > 0) {
|
|
216
|
+
const final = statuses[statuses.length - 1];
|
|
217
|
+
if (final === 'passed') return 'passed-on-retry';
|
|
218
|
+
return 'failed-on-retry';
|
|
219
|
+
}
|
|
220
|
+
if (failures.length > 0) return failures[failures.length - 1];
|
|
221
|
+
if (attempts.some((attempt) => attempt.retried)) return 'passed-on-retry';
|
|
222
|
+
if (passes.length > 0) return 'passed';
|
|
223
|
+
return statuses.length > 0 ? 'skipped' : 'unknown';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function finalStatusForOutcome(outcome) {
|
|
227
|
+
if (outcome === 'passed' || outcome === 'passed-on-retry') return 'passed';
|
|
228
|
+
if (outcome === 'error') return 'error';
|
|
229
|
+
if (outcome === 'skipped' || outcome === 'unknown') return outcome;
|
|
230
|
+
return 'failed';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function wilsonLowerBound(successes, trials, z = 1.96) {
|
|
234
|
+
if (!Number.isFinite(trials) || trials <= 0) return 0;
|
|
235
|
+
const proportion = Math.max(0, Math.min(1, successes / trials));
|
|
236
|
+
const zSquared = z * z;
|
|
237
|
+
const denominator = 1 + zSquared / trials;
|
|
238
|
+
const centre = proportion + zSquared / (2 * trials);
|
|
239
|
+
const margin = z * Math.sqrt((proportion * (1 - proportion) + zSquared / (4 * trials)) / trials);
|
|
240
|
+
return Math.max(0, (centre - margin) / denominator);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function classificationFor(counts) {
|
|
244
|
+
const failLikeRuns = counts.failedRuns + counts.errorRuns;
|
|
245
|
+
if (failLikeRuns > 0 && counts.passedRuns > 0) return 'cross-run flake';
|
|
246
|
+
if (counts.passedOnRetryRuns > 0) return 'passed-on-retry';
|
|
247
|
+
if (counts.failedOnRetryRuns > 0) return 'failed-on-retry';
|
|
248
|
+
if (failLikeRuns > 0) return 'always-failing';
|
|
249
|
+
if (counts.passedRuns > 0) return 'stable';
|
|
250
|
+
if (counts.skippedRuns > 0) return 'skipped';
|
|
251
|
+
return 'unknown';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function compareRuns(left, right) {
|
|
255
|
+
const leftTime = left.startedAtMs;
|
|
256
|
+
const rightTime = right.startedAtMs;
|
|
257
|
+
if (leftTime !== null && rightTime !== null && leftTime !== rightTime) return leftTime - rightTime;
|
|
258
|
+
const idOrder = naturalCompare(left.id, right.id);
|
|
259
|
+
return idOrder !== 0 ? idOrder : naturalCompare(left.path, right.path);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function compareTests(left, right) {
|
|
263
|
+
if (right.score !== left.score) return right.score - left.score;
|
|
264
|
+
if (right.unstableRate !== left.unstableRate) return right.unstableRate - left.unstableRate;
|
|
265
|
+
const failOrder = (right.failedRuns + right.errorRuns) - (left.failedRuns + left.errorRuns);
|
|
266
|
+
if (failOrder !== 0) return failOrder;
|
|
267
|
+
if (right.runs !== left.runs) return right.runs - left.runs;
|
|
268
|
+
return naturalCompare(left.id, right.id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Scan a directory of JUnit XML and collapse it into one ledger entry per
|
|
273
|
+
* classname::name. Every attempt stays in the entry's timeline, so a failure
|
|
274
|
+
* followed by a retry pass remains visible instead of disappearing into a
|
|
275
|
+
* green run.
|
|
276
|
+
*/
|
|
277
|
+
export function scanRuns(runsDir, options = {}) {
|
|
278
|
+
const root = path.resolve(runsDir);
|
|
279
|
+
const warnings = [];
|
|
280
|
+
const files = discoverJUnitFiles(root, { warnings });
|
|
281
|
+
if (files.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
runsDir: root,
|
|
284
|
+
files: [],
|
|
285
|
+
runs: [],
|
|
286
|
+
tests: [],
|
|
287
|
+
warnings,
|
|
288
|
+
totals: { files: 0, runs: 0, tests: 0, attempts: 0 },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const groups = groupFilesByRun(root, files, options);
|
|
293
|
+
const runs = [];
|
|
294
|
+
|
|
295
|
+
for (const group of groups) {
|
|
296
|
+
const run = {
|
|
297
|
+
id: group.id,
|
|
298
|
+
path: group.path,
|
|
299
|
+
files: [],
|
|
300
|
+
startedAt: null,
|
|
301
|
+
startedAtMs: null,
|
|
302
|
+
testCount: 0,
|
|
303
|
+
attemptCount: 0,
|
|
304
|
+
warnings: [],
|
|
305
|
+
};
|
|
306
|
+
const parsedFiles = [];
|
|
307
|
+
|
|
308
|
+
for (const file of group.files) {
|
|
309
|
+
let contents;
|
|
310
|
+
try {
|
|
311
|
+
contents = readFileSync(file, 'utf8');
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const warning = { file, runId: group.id, message: `cannot read file: ${error.message}` };
|
|
314
|
+
warnings.push(warning);
|
|
315
|
+
run.warnings.push(warning);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const parsed = parseJUnit(contents, file);
|
|
321
|
+
parsedFiles.push(parsed);
|
|
322
|
+
run.files.push(file);
|
|
323
|
+
if (
|
|
324
|
+
parsed.timestampMs !== null &&
|
|
325
|
+
(run.startedAtMs === null || parsed.timestampMs < run.startedAtMs)
|
|
326
|
+
) {
|
|
327
|
+
run.startedAtMs = parsed.timestampMs;
|
|
328
|
+
run.startedAt = parsed.timestamp;
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
const warning = {
|
|
332
|
+
file,
|
|
333
|
+
runId: group.id,
|
|
334
|
+
message: `cannot parse JUnit XML: ${error.message}`,
|
|
335
|
+
};
|
|
336
|
+
warnings.push(warning);
|
|
337
|
+
run.warnings.push(warning);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
parsedFiles.sort((left, right) => {
|
|
342
|
+
const leftTime = left.timestampMs;
|
|
343
|
+
const rightTime = right.timestampMs;
|
|
344
|
+
if (leftTime !== null && rightTime !== null && leftTime !== rightTime) return leftTime - rightTime;
|
|
345
|
+
// Shards are commonly named shard-1.xml, shard-2.xml. Compare basenames
|
|
346
|
+
// naturally before the full relative path so a nested shard-2 does not
|
|
347
|
+
// sort before the top-level shard-1.
|
|
348
|
+
const baseOrder = naturalCompare(
|
|
349
|
+
path.basename(left.sourcePath),
|
|
350
|
+
path.basename(right.sourcePath),
|
|
351
|
+
);
|
|
352
|
+
if (baseOrder !== 0) return baseOrder;
|
|
353
|
+
return naturalCompare(
|
|
354
|
+
path.relative(root, left.sourcePath),
|
|
355
|
+
path.relative(root, right.sourcePath),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const testsById = new Map();
|
|
360
|
+
for (const parsed of parsedFiles) {
|
|
361
|
+
for (const attempt of parsed.attempts) {
|
|
362
|
+
let test = testsById.get(attempt.id);
|
|
363
|
+
if (!test) {
|
|
364
|
+
test = {
|
|
365
|
+
id: attempt.id,
|
|
366
|
+
classname: attempt.classname,
|
|
367
|
+
name: attempt.name,
|
|
368
|
+
attempts: [],
|
|
369
|
+
};
|
|
370
|
+
testsById.set(attempt.id, test);
|
|
371
|
+
}
|
|
372
|
+
test.attempts.push(attempt);
|
|
373
|
+
run.attemptCount += 1;
|
|
374
|
+
}
|
|
375
|
+
run.testCount += parsed.testcaseCount;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const runTests = [];
|
|
379
|
+
for (const test of testsById.values()) {
|
|
380
|
+
const outcome = outcomeForAttempts(test.attempts);
|
|
381
|
+
runTests.push({
|
|
382
|
+
id: test.id,
|
|
383
|
+
classname: test.classname,
|
|
384
|
+
name: test.name,
|
|
385
|
+
outcome,
|
|
386
|
+
status: finalStatusForOutcome(outcome),
|
|
387
|
+
attemptCount: test.attempts.length,
|
|
388
|
+
attempts: test.attempts,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
run.tests = runTests;
|
|
392
|
+
run.testCount = runTests.length;
|
|
393
|
+
runs.push(run);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
runs.sort(compareRuns);
|
|
397
|
+
|
|
398
|
+
const ledger = new Map();
|
|
399
|
+
for (const run of runs) {
|
|
400
|
+
for (const test of run.tests) {
|
|
401
|
+
let record = ledger.get(test.id);
|
|
402
|
+
if (!record) {
|
|
403
|
+
record = {
|
|
404
|
+
id: test.id,
|
|
405
|
+
classname: test.classname,
|
|
406
|
+
name: test.name,
|
|
407
|
+
runs: 0,
|
|
408
|
+
attempts: 0,
|
|
409
|
+
passedAttempts: 0,
|
|
410
|
+
failedAttempts: 0,
|
|
411
|
+
errorAttempts: 0,
|
|
412
|
+
skippedAttempts: 0,
|
|
413
|
+
passedRuns: 0,
|
|
414
|
+
failedRuns: 0,
|
|
415
|
+
errorRuns: 0,
|
|
416
|
+
skippedRuns: 0,
|
|
417
|
+
passedOnRetryRuns: 0,
|
|
418
|
+
failedOnRetryRuns: 0,
|
|
419
|
+
unstableRuns: 0,
|
|
420
|
+
eligibleRuns: 0,
|
|
421
|
+
unstableRate: 0,
|
|
422
|
+
score: 0,
|
|
423
|
+
classification: 'unknown',
|
|
424
|
+
timeline: [],
|
|
425
|
+
};
|
|
426
|
+
ledger.set(test.id, record);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
record.runs += 1;
|
|
430
|
+
record.attempts += test.attempts.length;
|
|
431
|
+
for (const attempt of test.attempts) {
|
|
432
|
+
if (attempt.status === 'passed') record.passedAttempts += 1;
|
|
433
|
+
else if (attempt.status === 'failed') record.failedAttempts += 1;
|
|
434
|
+
else if (attempt.status === 'error') record.errorAttempts += 1;
|
|
435
|
+
else if (attempt.status === 'skipped') record.skippedAttempts += 1;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (test.outcome === 'passed') {
|
|
439
|
+
record.passedRuns += 1;
|
|
440
|
+
record.eligibleRuns += 1;
|
|
441
|
+
} else if (test.outcome === 'passed-on-retry') {
|
|
442
|
+
record.passedRuns += 1;
|
|
443
|
+
record.passedOnRetryRuns += 1;
|
|
444
|
+
record.unstableRuns += 1;
|
|
445
|
+
record.eligibleRuns += 1;
|
|
446
|
+
} else if (test.outcome === 'failed-on-retry') {
|
|
447
|
+
record.failedRuns += 1;
|
|
448
|
+
record.failedOnRetryRuns += 1;
|
|
449
|
+
record.unstableRuns += 1;
|
|
450
|
+
record.eligibleRuns += 1;
|
|
451
|
+
} else if (test.outcome === 'failed') {
|
|
452
|
+
record.failedRuns += 1;
|
|
453
|
+
record.unstableRuns += 1;
|
|
454
|
+
record.eligibleRuns += 1;
|
|
455
|
+
} else if (test.outcome === 'error') {
|
|
456
|
+
record.errorRuns += 1;
|
|
457
|
+
record.unstableRuns += 1;
|
|
458
|
+
record.eligibleRuns += 1;
|
|
459
|
+
} else if (test.outcome === 'skipped') {
|
|
460
|
+
record.skippedRuns += 1;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
record.timeline.push({
|
|
464
|
+
runId: run.id,
|
|
465
|
+
runPath: run.path,
|
|
466
|
+
id: test.id,
|
|
467
|
+
classname: test.classname,
|
|
468
|
+
name: test.name,
|
|
469
|
+
outcome: test.outcome,
|
|
470
|
+
status: test.status,
|
|
471
|
+
attemptCount: test.attemptCount,
|
|
472
|
+
attempts: test.attempts,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const tests = [...ledger.values()];
|
|
478
|
+
for (const test of tests) {
|
|
479
|
+
test.unstableRate = test.eligibleRuns > 0 ? test.unstableRuns / test.eligibleRuns : 0;
|
|
480
|
+
test.score = wilsonLowerBound(test.unstableRuns, test.eligibleRuns);
|
|
481
|
+
test.classification = classificationFor(test);
|
|
482
|
+
test.runIds = test.timeline.map((entry) => entry.runId);
|
|
483
|
+
}
|
|
484
|
+
tests.sort(compareTests);
|
|
485
|
+
|
|
486
|
+
for (const run of runs) {
|
|
487
|
+
delete run.tests;
|
|
488
|
+
delete run.warnings;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
runsDir: root,
|
|
493
|
+
files,
|
|
494
|
+
runs,
|
|
495
|
+
tests,
|
|
496
|
+
warnings,
|
|
497
|
+
totals: {
|
|
498
|
+
files: files.length,
|
|
499
|
+
runs: runs.length,
|
|
500
|
+
tests: tests.length,
|
|
501
|
+
attempts: runs.reduce((sum, run) => sum + run.attemptCount, 0),
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export const internals = {
|
|
507
|
+
outcomeForAttempts,
|
|
508
|
+
classificationFor,
|
|
509
|
+
wilsonLowerBound,
|
|
510
|
+
compareTests,
|
|
511
|
+
};
|