@crvouga/atlas 0.8.0 → 0.10.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/core/src/lint.js +6 -2
  3. package/dist/core/src/spec/types.d.ts +4 -1
  4. package/dist/playwright-reporter/src/evidence.d.ts +122 -0
  5. package/dist/playwright-reporter/src/evidence.js +327 -0
  6. package/dist/playwright-reporter/src/index.d.ts +114 -0
  7. package/dist/playwright-reporter/src/index.js +312 -0
  8. package/dist/reports/src/compose.js +7 -14
  9. package/dist/reports/src/recorded-specs.d.ts +33 -0
  10. package/dist/reports/src/recorded-specs.js +42 -0
  11. package/dist/reports/src/report-index.d.ts +6 -0
  12. package/dist/reports/src/report-index.js +14 -2
  13. package/dist/reports/src/test-catalog.d.ts +386 -0
  14. package/dist/reports/src/test-catalog.js +655 -0
  15. package/dist/schema/src/api.d.ts +585 -0
  16. package/dist/schema/src/api.js +105 -0
  17. package/dist/schema/src/spec.d.ts +2 -1
  18. package/dist/schema/src/spec.js +5 -2
  19. package/dist/server/src/app.js +82 -4
  20. package/dist/server/src/llms.js +14 -0
  21. package/dist/server/src/openapi.d.ts +1 -1
  22. package/dist/server/src/openapi.js +60 -3
  23. package/docs/agents.md +1 -0
  24. package/docs/integrating.md +1 -0
  25. package/docs/reference/core.md +7 -2
  26. package/docs/reference/http-api.md +134 -2
  27. package/docs/reference/playwright-reporter.md +332 -0
  28. package/docs/reference/reports.md +632 -0
  29. package/docs/reference/schema.md +58 -1
  30. package/docs/rich-evidence.md +198 -0
  31. package/docs/server.md +41 -0
  32. package/docs/statecharts.md +2 -1
  33. package/llms-full.txt +380 -3
  34. package/llms.txt +2 -0
  35. package/package.json +6 -1
  36. package/packages/schema/schemas/report-run.schema.json +251 -0
  37. package/packages/schema/schemas/report-runs.schema.json +177 -0
  38. package/packages/schema/schemas/state-node.schema.json +7 -0
  39. package/packages/schema/schemas/test-catalog.schema.json +1177 -0
  40. package/packages/schema/schemas/test-history.schema.json +696 -0
  41. package/packages/server/openapi.json +8676 -5351
  42. package/packages/visualizer/src/app/AppShell.tsx +7 -3
  43. package/packages/visualizer/src/app/router.tsx +26 -3
  44. package/packages/visualizer/src/components/Header.tsx +2 -2
  45. package/packages/visualizer/src/evidence/evidence-context.tsx +10 -1
  46. package/packages/visualizer/src/explorer/Explorer.tsx +433 -0
  47. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +120 -0
  48. package/packages/visualizer/src/explorer/RunsView.tsx +164 -0
  49. package/packages/visualizer/src/explorer/TestDetail.tsx +214 -0
  50. package/packages/visualizer/src/explorer/catalog.tsx +274 -0
  51. package/packages/visualizer/src/explorer/explorer.module.css +1238 -0
  52. package/packages/visualizer/src/explorer/groups.test.ts +87 -0
  53. package/packages/visualizer/src/explorer/groups.ts +126 -0
  54. package/packages/visualizer/src/explorer/navigate.ts +36 -0
  55. package/packages/visualizer/src/explorer/parts.tsx +59 -0
  56. package/packages/visualizer/src/explorer/search.ts +97 -0
  57. package/packages/visualizer/src/find/Finder.tsx +2 -0
  58. package/packages/visualizer/src/find/corpus.ts +13 -6
  59. package/packages/visualizer/src/find/rank.ts +2 -0
  60. package/packages/visualizer/src/evidence/project-home.tsx +0 -45
@@ -0,0 +1,655 @@
1
+ import { checkSlots } from './evidence-view.js';
2
+ import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
3
+ import { RecordedSpecs } from './recorded-specs.js';
4
+ /** The version of the documents this module writes (`TestCatalog`, `TestHistoryDocument`, `RunList`, `RunDetail`). Only optional fields are added within it. */
5
+ export const TEST_CATALOG_VERSION = 1;
6
+ /** Recorded results kept per test, newest first. Older ones are counted in no statistic. */
7
+ export const HISTORY_PER_TEST = 100;
8
+ const FAILURE_RANK = { passed: 0, 'not-run': 1, running: 2, failed: 3 };
9
+ /** How tests are ordered. */
10
+ export const TEST_SORTS = ['spec', 'title', 'recent', 'slowest', 'failures', 'flaky', 'runs'];
11
+ /** The run a layer belongs to: its run URL, else `branch@sha`, else `local`. Layers with the same key are one run. */
12
+ export function runKey(layer) {
13
+ if (layer.runUrl)
14
+ return layer.runUrl;
15
+ if (layer.branch || layer.sha)
16
+ return `${layer.branch ?? ''}@${layer.sha ?? ''}`;
17
+ return 'local';
18
+ }
19
+ function shortDetail(detail) {
20
+ return detail.length > 500 ? `${detail.slice(0, 497)}...` : detail;
21
+ }
22
+ function completed(status) {
23
+ return status === 'passed' || status === 'failed';
24
+ }
25
+ function entryOf(layer, row, backend) {
26
+ return materialize({ o: originOf(layer, backend), s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence });
27
+ }
28
+ function originOf(layer, backend) {
29
+ const parameters = Object.keys(layer.parameters).sort().map(key => `${key}=${layer.parameters[key]}`).join('\u0002');
30
+ return {
31
+ at: layer.recordedAtMs, environment: layer.environment, producer: layer.producer, parameters: layer.parameters,
32
+ branch: layer.branch, sha: layer.sha, runUrl: layer.runUrl, run: runKey(layer), layer: layer.id, backend,
33
+ place: `${layer.producer}\u0001${layer.environment ?? ''}\u0001${parameters}`,
34
+ };
35
+ }
36
+ function materialize(held) {
37
+ const { o } = held;
38
+ return {
39
+ at: o.at, status: held.s, durationMs: held.d, environment: o.environment, producer: o.producer, kind: held.k, parameters: o.parameters,
40
+ branch: o.branch, sha: o.sha, runUrl: o.runUrl, run: o.run, layer: o.layer, backend: o.backend, detail: shortDetail(held.t),
41
+ ...(held.e ? { evidence: held.e } : {}),
42
+ };
43
+ }
44
+ /** Newest first; at the same time the more severe, then the larger layer id, so the order is total. */
45
+ function newer(a, b) {
46
+ return b.o.at - a.o.at || FAILURE_RANK[b.s] - FAILURE_RANK[a.s] || (a.o.layer < b.o.layer ? 1 : a.o.layer > b.o.layer ? -1 : a.o.place < b.o.place ? 1 : a.o.place > b.o.place ? -1 : a.k < b.k ? 1 : a.k > b.k ? -1 : 0);
47
+ }
48
+ /** Whether `next` is a run's better last word on a result than `prior`: completed beats unfinished, then newer, then more severe. */
49
+ function laterInRun(next, prior) {
50
+ if (completed(next.s) !== completed(prior.s))
51
+ return completed(next.s);
52
+ return newer(next, prior) < 0;
53
+ }
54
+ /**
55
+ * Every recorded result of every test (the newest `HISTORY_PER_TEST` per test, leaving out `running` progress) and the
56
+ * runs they came from. A run's results are read from that history, so memory follows the number of tests and not the age
57
+ * of the project: a run older than a test's last hundred results no longer lists that test. Add each layer once with
58
+ * `add`; the order layers arrive in does not change what it holds. It never decides a status.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const history = new TestHistory();
63
+ * for (const { layer, rows } of layers) history.add(layer, rows, specs, layer.backend);
64
+ * const catalog = testCatalog(catalogModel(layers, { history }));
65
+ * ```
66
+ */
67
+ export class TestHistory {
68
+ #bySlot = new Map();
69
+ #idents = new Map();
70
+ #runs = new Map();
71
+ #specs = new RecordedSpecs();
72
+ #seen = new Set();
73
+ #rev = 0;
74
+ #cells = null;
75
+ /** Changes each time a layer is added. */
76
+ get rev() {
77
+ return this.#rev;
78
+ }
79
+ /** The specs the added layers recorded. */
80
+ get specs() {
81
+ return this.#specs;
82
+ }
83
+ /** How many layers were added. */
84
+ get layers() {
85
+ return this.#seen.size;
86
+ }
87
+ /** Takes in one layer: its header, its rows, the specs it recorded and the backend it was read from. A layer id already added is ignored. */
88
+ add(layer, rows, specs = [], backend = layer.backend) {
89
+ if (this.#seen.has(layer.id))
90
+ return;
91
+ this.#seen.add(layer.id);
92
+ this.#rev += 1;
93
+ this.#specs.add(specs, layer.recordedAtMs);
94
+ const key = runKey(layer);
95
+ let run = this.#runs.get(key);
96
+ if (!run) {
97
+ run = { key, runUrl: layer.runUrl, branch: layer.branch, sha: layer.sha, start: layer.recordedAtMs, end: layer.recordedAtMs, first: layer.id, producers: new Set(), environments: new Set(), layers: 0 };
98
+ this.#runs.set(key, run);
99
+ }
100
+ // A run is told by its first layer (by time, then id), whatever order its layers arrive in.
101
+ if (layer.recordedAtMs < run.start || (layer.recordedAtMs === run.start && layer.id < run.first)) {
102
+ run.start = layer.recordedAtMs;
103
+ run.first = layer.id;
104
+ run.branch = layer.branch;
105
+ run.sha = layer.sha;
106
+ }
107
+ run.end = Math.max(run.end, layer.recordedAtMs);
108
+ run.producers.add(layer.producer);
109
+ if (layer.environment)
110
+ run.environments.add(layer.environment);
111
+ run.layers += 1;
112
+ const origin = originOf(layer, backend);
113
+ for (const row of rows) {
114
+ // "Still running" is progress, not history: a test running now shows it as its current outcome.
115
+ if (row.status === 'running')
116
+ continue;
117
+ const slot = slotKey(row.specPath ?? '', row.id);
118
+ let ident = this.#idents.get(slot);
119
+ if (!ident)
120
+ this.#idents.set(slot, ident = { slot, id: row.id, title: row.title, titledAt: layer.recordedAtMs, specPath: row.specPath });
121
+ else if (layer.recordedAtMs > ident.titledAt || (layer.recordedAtMs === ident.titledAt && row.title > ident.title)) {
122
+ ident.title = row.title;
123
+ ident.titledAt = layer.recordedAtMs;
124
+ }
125
+ this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence });
126
+ }
127
+ }
128
+ /** Sorted insert, newest first; past the bound, the oldest is dropped. */
129
+ #remember(slot, entry) {
130
+ const list = this.#bySlot.get(slot);
131
+ if (!list) {
132
+ this.#bySlot.set(slot, [entry]);
133
+ return;
134
+ }
135
+ let lo = 0, hi = list.length;
136
+ while (lo < hi) {
137
+ const mid = (lo + hi) >> 1;
138
+ if (newer(list[mid], entry) <= 0)
139
+ lo = mid + 1;
140
+ else
141
+ hi = mid;
142
+ }
143
+ if (lo >= HISTORY_PER_TEST)
144
+ return;
145
+ list.splice(lo, 0, entry);
146
+ if (list.length > HISTORY_PER_TEST)
147
+ list.pop();
148
+ }
149
+ /** The recorded results of one test (by `TestSummary.key`), newest first. Empty when none. */
150
+ of(key) {
151
+ return (this.#bySlot.get(key) ?? []).map(materialize);
152
+ }
153
+ /** How many runs it knows of. */
154
+ get runCount() {
155
+ return this.#runs.size;
156
+ }
157
+ /**
158
+ * Each run's last word on each test, producer, environment, parameter set and kind it recorded, read from history in
159
+ * one pass and kept until a layer arrives.
160
+ */
161
+ #runCells() {
162
+ if (this.#cells?.rev === this.#rev)
163
+ return this.#cells.byRun;
164
+ const byRun = new Map();
165
+ for (const [slot, list] of this.#bySlot) {
166
+ const i = this.#idents.get(slot);
167
+ const best = new Map();
168
+ for (const held of list) {
169
+ const cell = `${held.o.run}\u0001${held.o.place}\u0001${held.k}`;
170
+ const prior = best.get(cell);
171
+ if (!prior || laterInRun(held, prior))
172
+ best.set(cell, held);
173
+ }
174
+ for (const r of best.values()) {
175
+ const cells = byRun.get(r.o.run);
176
+ if (cells)
177
+ cells.push({ i, r });
178
+ else
179
+ byRun.set(r.o.run, [{ i, r }]);
180
+ }
181
+ }
182
+ this.#cells = { rev: this.#rev, byRun };
183
+ return byRun;
184
+ }
185
+ /** Every run, newest first. */
186
+ runs() {
187
+ const cells = this.#runCells();
188
+ return [...this.#runs.values()].map(run => runSummary(run, cells.get(run.key) ?? [])).sort((a, b) => b.endedAtMs - a.endedAtMs || (a.key < b.key ? -1 : 1));
189
+ }
190
+ /** One run and its results (failed first, then by spec and title), or null. */
191
+ run(key) {
192
+ const run = this.#runs.get(key);
193
+ if (!run)
194
+ return null;
195
+ const cells = this.#runCells().get(key) ?? [];
196
+ const results = cells.map(({ i, r }) => ({
197
+ key: i.slot, id: i.id, title: i.title, specPath: i.specPath, environment: r.o.environment, producer: r.o.producer,
198
+ status: r.s, durationMs: r.d, at: r.o.at, layer: r.o.layer, detail: shortDetail(r.t),
199
+ }));
200
+ results.sort((a, b) => FAILURE_RANK[b.status] - FAILURE_RANK[a.status] || compareText(a.specPath ?? '', b.specPath ?? '') || compareText(a.title, b.title) || compareText(a.environment ?? '', b.environment ?? '') || compareText(a.producer, b.producer) || compareText(a.key, b.key));
201
+ return { run: runSummary(run, cells), results };
202
+ }
203
+ /** The keys of the tests one run recorded. */
204
+ testsIn(key) {
205
+ return new Set((this.#runCells().get(key) ?? []).map(cell => cell.i.slot));
206
+ }
207
+ }
208
+ function runSummary(run, cells) {
209
+ const counts = emptyCounts();
210
+ const tests = new Set();
211
+ let total = 0, measured = false;
212
+ const failed = [];
213
+ for (const cell of cells) {
214
+ counts[cell.r.s] += 1;
215
+ tests.add(cell.i.slot);
216
+ if (cell.r.d !== null) {
217
+ total += cell.r.d;
218
+ measured = true;
219
+ }
220
+ if (cell.r.s === 'failed')
221
+ failed.push(cell);
222
+ }
223
+ failed.sort((a, b) => compareText(a.i.slot, b.i.slot) || compareText(a.r.o.place, b.r.o.place) || compareText(a.r.k, b.r.k));
224
+ const failing = failed.slice(0, 20).map(cell => ({ key: cell.i.slot, title: cell.i.title, environment: cell.r.o.environment, detail: (cell.r.t.split('\n')[0] ?? '').slice(0, 500) }));
225
+ return {
226
+ key: run.key, runUrl: run.runUrl, branch: run.branch, sha: run.sha,
227
+ startedAtMs: run.start, endedAtMs: run.end, spanMs: run.end - run.start,
228
+ producers: [...run.producers].sort(), environments: [...run.environments].sort(),
229
+ layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
230
+ };
231
+ }
232
+ function compareText(a, b) {
233
+ return a < b ? -1 : a > b ? 1 : 0;
234
+ }
235
+ /** Durations of a list of numbers (ms), or null when empty. `lastMs` is the first value, so give them newest first. */
236
+ export function durationStats(values) {
237
+ if (!values.length)
238
+ return null;
239
+ const sorted = [...values].sort((a, b) => a - b);
240
+ const total = sorted.reduce((sum, value) => sum + value, 0);
241
+ const at = (q) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))];
242
+ return { count: sorted.length, totalMs: total, avgMs: Math.round(total / sorted.length), p50Ms: at(0.5), p95Ms: at(0.95), minMs: sorted[0], maxMs: sorted.at(-1), lastMs: values[0] };
243
+ }
244
+ /** The spec a path names: the recorded one, or one derived from the path (`specs/<domain>/<name>.<ext>`). */
245
+ export function catalogSpec(path, known) {
246
+ if (!path)
247
+ return null;
248
+ const entry = known(path);
249
+ const parts = normalSpecPath(path).split('/');
250
+ const folder = parts.length > 2 && parts[0] === 'specs' ? parts[1] : parts.length > 1 ? parts.at(-2) : '';
251
+ const base = (parts.at(-1) ?? path).replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ');
252
+ return {
253
+ path: entry?.path ?? normalSpecPath(path),
254
+ title: entry?.title || (base ? base[0].toUpperCase() + base.slice(1) : path),
255
+ domain: entry?.domain || folder,
256
+ kind: entry?.kind || (/\.([^./]+)$/.exec(path)?.[1] ?? 'unknown'),
257
+ };
258
+ }
259
+ function statsOf(history, outcomes, tags, labels) {
260
+ // Without history, the current results are all there is.
261
+ const entries = history.length ? history : outcomes;
262
+ let passed = 0, failed = 0, firstSeenMs = null, lastSeenMs = null, lastPassedAtMs = null, lastFailedAtMs = null;
263
+ const durations = [];
264
+ const runs = new Set();
265
+ const branches = [];
266
+ const bySha = new Map();
267
+ const sequences = new Map();
268
+ for (const entry of entries) {
269
+ if (entry.status === 'passed') {
270
+ passed += 1;
271
+ lastPassedAtMs ??= entry.at;
272
+ }
273
+ if (entry.status === 'failed') {
274
+ failed += 1;
275
+ lastFailedAtMs ??= entry.at;
276
+ }
277
+ lastSeenMs ??= entry.at;
278
+ firstSeenMs = entry.at;
279
+ if (entry.durationMs !== null && completed(entry.status))
280
+ durations.push(entry.durationMs);
281
+ runs.add(entry.run);
282
+ if (entry.branch && !branches.includes(entry.branch) && branches.length < 10)
283
+ branches.push(entry.branch);
284
+ if (completed(entry.status)) {
285
+ const place = `${entry.producer}|${entry.environment ?? ''}|${JSON.stringify(entry.parameters)}`;
286
+ if (entry.sha) {
287
+ const sha = `${place}|${entry.sha}`;
288
+ const seen = bySha.get(sha) ?? new Set();
289
+ seen.add(entry.status);
290
+ bySha.set(sha, seen);
291
+ }
292
+ const sequence = sequences.get(place) ?? [];
293
+ sequence.push(entry.status);
294
+ sequences.set(place, sequence);
295
+ }
296
+ }
297
+ let flips = 0;
298
+ for (const sequence of sequences.values())
299
+ for (let i = 1; i < sequence.length; i++)
300
+ if (sequence[i] !== sequence[i - 1])
301
+ flips += 1;
302
+ const mixedSha = [...bySha.values()].some(statuses => statuses.size > 1);
303
+ const retried = outcomes.some(outcome => outcome.status === 'passed' && (outcome.evidence?.attempts ?? 0) > 1);
304
+ const said = tags.some(tag => tag.toLowerCase() === 'flaky') || labels.flaky === 'true';
305
+ const flakyReason = mixedSha ? 'Passed and failed on the same commit' : retried ? 'Passed only after a retry' : said ? 'Marked flaky by its producer' : '';
306
+ return {
307
+ results: entries.length, passed, failed, passRate: passed + failed ? passed / (passed + failed) : null,
308
+ flaky: flakyReason !== '', flakyReason, flips, durations: durationStats(durations),
309
+ firstSeenMs, lastSeenMs, lastPassedAtMs, lastFailedAtMs, runs: runs.size, branches,
310
+ };
311
+ }
312
+ function outcomeOf(result) {
313
+ return entryOf(result.layer, result.row, result.layer.backend);
314
+ }
315
+ function summaryOf(slot, history, known) {
316
+ const outcomes = slot.outcomes.map(outcomeOf);
317
+ const tags = new Set();
318
+ const labels = {};
319
+ let attachments = 0, steps = 0, attempts = 0;
320
+ const roles = new Set();
321
+ for (const outcome of outcomes) {
322
+ const evidence = outcome.evidence;
323
+ if (!evidence)
324
+ continue;
325
+ for (const tag of evidence.tags)
326
+ tags.add(tag);
327
+ Object.assign(labels, evidence.labels);
328
+ attachments += evidence.attachments;
329
+ steps += evidence.steps;
330
+ attempts = Math.max(attempts, evidence.attempts);
331
+ for (const role of evidence.roles)
332
+ roles.add(role);
333
+ }
334
+ const tagList = [...tags].sort();
335
+ return {
336
+ key: slot.key,
337
+ id: slot.id,
338
+ title: slot.title,
339
+ specPath: slot.specPath,
340
+ spec: catalogSpec(slot.specPath, known),
341
+ status: slot.status,
342
+ environments: [...new Set(outcomes.map(outcome => outcome.environment).filter((env) => !!env))].sort(),
343
+ producers: [...new Set(outcomes.map(outcome => outcome.producer))].sort(),
344
+ outcomes,
345
+ stats: statsOf(history?.of(slot.key) ?? [], outcomes, tagList, labels),
346
+ tags: tagList,
347
+ labels,
348
+ evidence: { attachments, steps, attempts, roles: [...roles].sort() },
349
+ lastRecordedAtMs: slot.latestAtMs,
350
+ live: slot.live,
351
+ };
352
+ }
353
+ /** The lowercase text a search matches a test against: title, id, spec, domain, tags, labels, environments and producers. */
354
+ export function haystackOf(test) {
355
+ return [
356
+ test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags,
357
+ ...Object.entries(test.labels).flatMap(([key, value]) => [key, value]), ...test.environments, ...test.producers,
358
+ ].join('\n').toLowerCase();
359
+ }
360
+ /** A test's worst current outcome on one environment (`(none)` for results that named none), or its own status when it has no result there. */
361
+ function statusOn(test, environment) {
362
+ let status = null;
363
+ for (const outcome of test.outcomes) {
364
+ if ((outcome.environment ?? '(none)') !== environment)
365
+ continue;
366
+ if (status === null || FAILURE_RANK[outcome.status] > FAILURE_RANK[status])
367
+ status = outcome.status;
368
+ }
369
+ return status ?? test.status;
370
+ }
371
+ function group(name, tests, environment = null) {
372
+ const counts = emptyCounts();
373
+ const averages = [];
374
+ for (const test of tests) {
375
+ counts[environment === null ? test.status : statusOn(test, environment)] += 1;
376
+ if (test.stats.durations)
377
+ averages.push(test.stats.durations.avgMs);
378
+ }
379
+ return { name, tests: tests.length, counts, avgMs: averages.length ? Math.round(averages.reduce((a, b) => a + b, 0) / averages.length) : null };
380
+ }
381
+ function groupsBy(tests, keys, byEnvironment = false) {
382
+ const groups = new Map();
383
+ for (const test of tests)
384
+ for (const key of keys(test)) {
385
+ const list = groups.get(key);
386
+ if (list)
387
+ list.push(test);
388
+ else
389
+ groups.set(key, [test]);
390
+ }
391
+ return [...groups].map(([name, list]) => group(name, list, byEnvironment ? name : null)).sort((a, b) => b.tests - a.tests || compareText(a.name, b.name));
392
+ }
393
+ function facet(tests, keys) {
394
+ const counts = new Map();
395
+ for (const test of tests)
396
+ for (const key of new Set(keys(test)))
397
+ counts.set(key, (counts.get(key) ?? 0) + 1);
398
+ return [...counts].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count || compareText(a.value, b.value));
399
+ }
400
+ function ref(test, value) {
401
+ return { key: test.key, title: test.title, specPath: test.specPath, status: test.status, value };
402
+ }
403
+ /**
404
+ * Builds the tests of a report from its winners (`layers`, as the effective report holds them: each layer with the rows
405
+ * that win) and, when given, a history. The model is what `testCatalog` and `testHistory` query; build it once per
406
+ * version and reuse it.
407
+ */
408
+ export function catalogModel(layers, sources = {}) {
409
+ const history = sources.history ?? null;
410
+ const recorded = new RecordedSpecs();
411
+ if (history)
412
+ recorded.add(history.specs.entries(), 0);
413
+ if (sources.specs)
414
+ recorded.add(sources.specs, 1);
415
+ const known = (path) => recorded.get(path);
416
+ const combined = combineEvidence({ specs: [], sha: null, changedFiles: [] }, layers);
417
+ const slots = checkSlots(combined.unassigned);
418
+ const tests = slots.map(slot => summaryOf(slot, history, known));
419
+ const byKey = new Map(tests.map(test => [test.key, test]));
420
+ const haystacks = new Map(tests.map(test => [test.key, haystackOf(test)]));
421
+ const counts = emptyCounts();
422
+ for (const test of tests)
423
+ counts[test.status] += 1;
424
+ const averages = tests.flatMap(test => (test.stats.durations ? [test.stats.durations.avgMs] : []));
425
+ const lastRecordedAtMs = tests.length ? Math.max(...tests.map(test => test.lastRecordedAtMs)) : null;
426
+ const summary = {
427
+ tests: tests.length,
428
+ counts,
429
+ outcomes: tests.reduce((sum, test) => sum + test.outcomes.length, 0),
430
+ flaky: tests.filter(test => test.stats.flaky).length,
431
+ durations: durationStats(averages),
432
+ measured: averages.length,
433
+ withAttachments: tests.filter(test => test.evidence.attachments > 0).length,
434
+ // An environment counts how its own results went, so a failure on one does not show on another.
435
+ environments: groupsBy(tests, test => test.environments.length ? test.environments : ['(none)'], true),
436
+ producers: groupsBy(tests, test => test.producers),
437
+ domains: groupsBy(tests, test => [test.spec?.domain || '(no spec)']),
438
+ slowest: tests.filter(test => test.stats.durations).sort((a, b) => b.stats.durations.avgMs - a.stats.durations.avgMs || compareText(a.key, b.key)).slice(0, 10).map(test => ref(test, test.stats.durations.avgMs)),
439
+ flakiest: tests.filter(test => test.stats.flaky).sort((a, b) => b.stats.flips - a.stats.flips || compareText(a.key, b.key)).slice(0, 10).map(test => ref(test, test.stats.flips)),
440
+ failing: tests.filter(test => test.status === 'failed').sort((a, b) => b.lastRecordedAtMs - a.lastRecordedAtMs || compareText(a.key, b.key)).slice(0, 50).map(test => ref(test, test.stats.failed)),
441
+ runs: history?.runCount ?? new Set(tests.flatMap(test => test.outcomes.map(outcome => outcome.run))).size,
442
+ lastRecordedAtMs,
443
+ history: history !== null,
444
+ };
445
+ const specGroups = new Map();
446
+ for (const test of tests)
447
+ if (test.spec) {
448
+ const list = specGroups.get(test.spec.path);
449
+ if (list)
450
+ list.push(test);
451
+ else
452
+ specGroups.set(test.spec.path, [test]);
453
+ }
454
+ const labelKeys = new Set(tests.flatMap(test => Object.keys(test.labels)));
455
+ const facets = {
456
+ statuses: facet(tests, test => [test.status, ...(test.stats.flaky ? ['flaky'] : [])]),
457
+ environments: facet(tests, test => test.environments),
458
+ producers: facet(tests, test => test.producers),
459
+ domains: facet(tests, test => [test.spec?.domain || '(no spec)']),
460
+ specs: [...specGroups].map(([, list]) => { const g = group(list[0].spec.path, list); return { ...list[0].spec, tests: g.tests, counts: g.counts }; }).sort((a, b) => compareText(a.domain, b.domain) || compareText(a.title, b.title)),
461
+ tags: facet(tests, test => test.tags),
462
+ branches: facet(tests, test => test.stats.branches),
463
+ labels: Object.fromEntries([...labelKeys].sort().map(key => [key, facet(tests, test => (test.labels[key] === undefined ? [] : [test.labels[key]]))])),
464
+ };
465
+ return { version: sources.version ?? '', generatedAtMs: sources.now ?? Date.now(), tests, byKey, summary, facets, haystacks, history };
466
+ }
467
+ const SORTERS = {
468
+ spec: (a, b) => compareText(a.spec?.domain ?? '\uffff', b.spec?.domain ?? '\uffff') || compareText(a.spec?.title ?? '\uffff', b.spec?.title ?? '\uffff') || compareText(a.title, b.title),
469
+ title: (a, b) => compareText(a.title.toLowerCase(), b.title.toLowerCase()),
470
+ recent: (a, b) => b.lastRecordedAtMs - a.lastRecordedAtMs,
471
+ slowest: (a, b) => (b.stats.durations?.avgMs ?? -1) - (a.stats.durations?.avgMs ?? -1),
472
+ failures: (a, b) => FAILURE_RANK[b.status] - FAILURE_RANK[a.status] || b.stats.failed - a.stats.failed || (a.stats.passRate ?? 1) - (b.stats.passRate ?? 1),
473
+ flaky: (a, b) => Number(b.stats.flaky) - Number(a.stats.flaky) || b.stats.flips - a.stats.flips,
474
+ runs: (a, b) => b.stats.runs - a.stats.runs,
475
+ };
476
+ /** Whether one test matches a query (everything but sorting and paging). */
477
+ export function matchesQuery(model, test, query, inRun) {
478
+ const words = (query.q ?? '').toLowerCase().split(/\s+/).filter(Boolean);
479
+ if (words.length) {
480
+ const hay = model.haystacks.get(test.key) ?? '';
481
+ if (!words.every(word => hay.includes(word)))
482
+ return false;
483
+ }
484
+ if (query.status?.length && !query.status.some(status => (status === 'flaky' ? test.stats.flaky : test.status === status)))
485
+ return false;
486
+ if (query.environment?.length && !query.environment.some(env => test.environments.includes(env) || (env === '(none)' && !test.environments.length)))
487
+ return false;
488
+ if (query.producer?.length && !query.producer.some(producer => test.producers.includes(producer)))
489
+ return false;
490
+ if (query.domain?.length && !query.domain.includes(test.spec?.domain || '(no spec)'))
491
+ return false;
492
+ if (query.spec?.length && !query.spec.some(path => test.specPath !== null && normalSpecPath(path) === test.specPath))
493
+ return false;
494
+ if (query.tag?.length && !query.tag.some(tag => test.tags.includes(tag)))
495
+ return false;
496
+ if (inRun && !inRun.has(test.key))
497
+ return false;
498
+ return true;
499
+ }
500
+ /** The catalog document for a query: the model's summary and facets, and the matching tests, sorted and paged. */
501
+ export function testCatalog(model, query = {}) {
502
+ const inRun = query.run && model.history ? model.history.testsIn(query.run) : query.run ? new Set(model.tests.filter(test => test.outcomes.some(outcome => outcome.run === query.run)).map(test => test.key)) : null;
503
+ const matched = model.tests.filter(test => matchesQuery(model, test, query, inRun));
504
+ const sorter = SORTERS[query.sort ?? 'spec'] ?? SORTERS.spec;
505
+ matched.sort((a, b) => sorter(a, b) || compareText(a.key, b.key));
506
+ const offset = Math.max(0, Math.floor(query.offset ?? 0));
507
+ const limit = Math.min(20_000, Math.max(0, Math.floor(query.limit ?? 500)));
508
+ return { schemaVersion: TEST_CATALOG_VERSION, version: model.version, generatedAtMs: model.generatedAtMs, summary: model.summary, facets: model.facets, total: matched.length, offset, limit, tests: matched.slice(offset, offset + limit) };
509
+ }
510
+ /** One test and its recorded results, or null when the report has no test with that key. */
511
+ export function testHistory(model, key) {
512
+ const test = model.byKey.get(key);
513
+ if (!test)
514
+ return null;
515
+ return { schemaVersion: TEST_CATALOG_VERSION, version: model.version, test, history: model.history?.of(key) ?? test.outcomes };
516
+ }
517
+ /** Every run the history knows of, newest first, `limit` at most (default 100). */
518
+ export function runList(history, version, limit = 100) {
519
+ const runs = history.runs();
520
+ return { schemaVersion: TEST_CATALOG_VERSION, version, total: runs.length, runs: runs.slice(0, Math.max(0, limit)) };
521
+ }
522
+ /** One run with every result it recorded, or null when no run has that key. */
523
+ export function runDetail(history, version, key) {
524
+ const found = history.run(key);
525
+ return found ? { schemaVersion: TEST_CATALOG_VERSION, version, ...found } : null;
526
+ }
527
+ /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`. */
528
+ export function parseTestQuery(params) {
529
+ const many = (name) => params.getAll(name).flatMap(value => value.split(',')).map(value => value.trim()).filter(Boolean);
530
+ const number = (name) => { const value = params.get(name); return value !== null && /^\d+$/.test(value) ? Number(value) : undefined; };
531
+ const sort = params.get('sort');
532
+ const query = {};
533
+ const q = params.get('q');
534
+ if (q)
535
+ query.q = q;
536
+ for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag']) {
537
+ const values = many(name);
538
+ if (values.length)
539
+ query[name] = values;
540
+ }
541
+ const run = params.get('run');
542
+ if (run)
543
+ query.run = run;
544
+ if (sort && TEST_SORTS.includes(sort))
545
+ query.sort = sort;
546
+ const offset = number('offset');
547
+ const limit = number('limit');
548
+ if (offset !== undefined)
549
+ query.offset = offset;
550
+ if (limit !== undefined)
551
+ query.limit = limit;
552
+ return query;
553
+ }
554
+ /** A duration for a person: `850 ms`, `12.3 s`, `4m 05s`. */
555
+ export function formatMs(ms) {
556
+ if (ms === null || ms === undefined)
557
+ return '—';
558
+ if (ms < 1000)
559
+ return `${Math.round(ms)} ms`;
560
+ if (ms < 60_000)
561
+ return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`;
562
+ const seconds = Math.round(ms / 1000);
563
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;
564
+ }
565
+ const cell = (text) => text.replaceAll('|', '\\|').replaceAll('\n', ' ');
566
+ const iso = (ms) => (ms === null ? '—' : new Date(ms).toISOString().replace('.000Z', 'Z'));
567
+ const counted = (counts) => `${counts.passed} passed, ${counts.failed} failed, ${counts.running} running, ${counts['not-run']} not run`;
568
+ /** The catalog as Markdown: the summary, insights, groups and the listed tests as a table. */
569
+ export function catalogMarkdown(catalog) {
570
+ const { summary } = catalog;
571
+ const lines = [
572
+ '# Tests',
573
+ '',
574
+ `${summary.tests} tests (${counted(summary.counts)}); ${summary.flaky} flaky; ${summary.outcomes} current results across ${summary.environments.length} environments; ${summary.runs} runs. Last recorded ${iso(summary.lastRecordedAtMs)}.`,
575
+ summary.durations ? `Average test duration ${formatMs(summary.durations.avgMs)} (p50 ${formatMs(summary.durations.p50Ms)}, p95 ${formatMs(summary.durations.p95Ms)}) over ${summary.measured} measured tests.` : 'No test recorded a duration.',
576
+ '',
577
+ '## By environment',
578
+ '',
579
+ '| Environment | Tests | Passed | Failed | Running | Not run | Avg |',
580
+ '|---|---:|---:|---:|---:|---:|---:|',
581
+ ...summary.environments.map(g => `| ${cell(g.name)} | ${g.tests} | ${g.counts.passed} | ${g.counts.failed} | ${g.counts.running} | ${g.counts['not-run']} | ${formatMs(g.avgMs)} |`),
582
+ '',
583
+ '## By domain',
584
+ '',
585
+ '| Domain | Tests | Passed | Failed | Not run | Avg |',
586
+ '|---|---:|---:|---:|---:|---:|',
587
+ ...summary.domains.map(g => `| ${cell(g.name)} | ${g.tests} | ${g.counts.passed} | ${g.counts.failed} | ${g.counts['not-run']} | ${formatMs(g.avgMs)} |`),
588
+ ];
589
+ if (summary.failing.length)
590
+ lines.push('', '## Failing', '', ...summary.failing.map(t => `- ${cell(t.title)} (${t.specPath ?? 'no spec'})`));
591
+ if (summary.flakiest.length)
592
+ lines.push('', '## Flaky', '', ...summary.flakiest.map(t => `- ${cell(t.title)}: ${t.value} flips (${t.specPath ?? 'no spec'})`));
593
+ if (summary.slowest.length)
594
+ lines.push('', '## Slowest', '', ...summary.slowest.map(t => `- ${cell(t.title)}: ${formatMs(t.value)} average (${t.specPath ?? 'no spec'})`));
595
+ lines.push('', `## Tests ${catalog.offset + 1}–${catalog.offset + catalog.tests.length} of ${catalog.total}`, '', '| Status | Test | Spec | Environments | Avg | Pass rate | Runs | Last recorded |', '|---|---|---|---|---:|---:|---:|---|', ...catalog.tests.map(t => `| ${t.status}${t.stats.flaky ? ' (flaky)' : ''} | ${cell(t.title)} | ${cell(t.spec ? `${t.spec.title} (${t.spec.path})` : '—')} | ${cell(t.outcomes.map(o => `${o.environment ?? '—'}: ${o.status}`).join(', '))} | ${formatMs(t.stats.durations?.avgMs)} | ${t.stats.passRate === null ? '—' : `${Math.round(t.stats.passRate * 100)}%`} | ${t.stats.runs} | ${iso(t.lastRecordedAtMs)} |`));
596
+ if (catalog.total > catalog.offset + catalog.tests.length)
597
+ lines.push('', `More: ask again with offset=${catalog.offset + catalog.tests.length}.`);
598
+ return `${lines.join('\n')}\n`;
599
+ }
600
+ /** One test's history as Markdown. */
601
+ export function historyMarkdown(document) {
602
+ const { test } = document;
603
+ const d = test.stats.durations;
604
+ return `${[
605
+ `# ${test.title}`,
606
+ '',
607
+ `- Status: ${test.status}${test.stats.flaky ? ` (flaky: ${test.stats.flakyReason})` : ''}`,
608
+ `- Spec: ${test.spec ? `${test.spec.title} (${test.spec.path}), domain ${test.spec.domain || '—'}` : 'none'}`,
609
+ `- Id: ${test.id}`,
610
+ `- Environments: ${test.environments.join(', ') || '—'}; producers: ${test.producers.join(', ')}`,
611
+ `- Results: ${test.stats.results} recorded, ${test.stats.passed} passed, ${test.stats.failed} failed${test.stats.passRate === null ? '' : `, pass rate ${Math.round(test.stats.passRate * 100)}%`}, in ${test.stats.runs} runs`,
612
+ `- Duration: ${d ? `avg ${formatMs(d.avgMs)}, p50 ${formatMs(d.p50Ms)}, p95 ${formatMs(d.p95Ms)}, min ${formatMs(d.minMs)}, max ${formatMs(d.maxMs)}, last ${formatMs(d.lastMs)}` : 'not measured'}`,
613
+ ...(test.tags.length ? [`- Tags: ${test.tags.join(', ')}`] : []),
614
+ ...(Object.keys(test.labels).length ? [`- Labels: ${Object.entries(test.labels).map(([k, v]) => `${k}=${v}`).join(', ')}`] : []),
615
+ `- Evidence: ${test.evidence.attachments} attachments${test.evidence.roles.length ? ` (${test.evidence.roles.join(', ')})` : ''}, ${test.evidence.steps} steps`,
616
+ '',
617
+ '## Current results',
618
+ '',
619
+ '| Environment | Producer | Status | Duration | Branch | Commit | Recorded | Run |',
620
+ '|---|---|---|---:|---|---|---|---|',
621
+ ...test.outcomes.map(o => `| ${o.environment ?? '—'} | ${cell(o.producer)} | ${o.status} | ${formatMs(o.durationMs)} | ${cell(o.branch ?? '—')} | ${o.sha?.slice(0, 10) ?? '—'} | ${iso(o.at)} | ${o.runUrl ?? '—'} |`),
622
+ '',
623
+ '## History',
624
+ '',
625
+ '| Recorded | Environment | Status | Duration | Branch | Commit | Detail |',
626
+ '|---|---|---|---:|---|---|---|',
627
+ ...document.history.map(e => `| ${iso(e.at)} | ${e.environment ?? '—'} | ${e.status} | ${formatMs(e.durationMs)} | ${cell(e.branch ?? '—')} | ${e.sha?.slice(0, 10) ?? '—'} | ${cell(e.detail.split('\n')[0] ?? '')} |`),
628
+ ].join('\n')}\n`;
629
+ }
630
+ /** The run list as Markdown. */
631
+ export function runsMarkdown(list) {
632
+ return `${[
633
+ `# Runs (${list.runs.length} of ${list.total})`,
634
+ '',
635
+ '| Ended | Branch | Commit | Environments | Tests | Passed | Failed | Not run | Span | Run |',
636
+ '|---|---|---|---|---:|---:|---:|---:|---:|---|',
637
+ ...list.runs.map(r => `| ${iso(r.endedAtMs)} | ${cell(r.branch ?? '—')} | ${r.sha?.slice(0, 10) ?? '—'} | ${r.environments.join(', ') || '—'} | ${r.tests} | ${r.counts.passed} | ${r.counts.failed} | ${r.counts['not-run']} | ${formatMs(r.spanMs)} | ${cell(r.key)} |`),
638
+ ].join('\n')}\n`;
639
+ }
640
+ /** One run as Markdown. */
641
+ export function runMarkdown(detail) {
642
+ const { run } = detail;
643
+ return `${[
644
+ `# Run ${run.key}`,
645
+ '',
646
+ `- Branch ${run.branch ?? '—'}, commit ${run.sha ?? '—'}`,
647
+ `- ${iso(run.startedAtMs)} to ${iso(run.endedAtMs)} (${formatMs(run.spanMs)}); ${run.layers} layers from ${run.producers.join(', ')}`,
648
+ `- Environments: ${run.environments.join(', ') || '—'}`,
649
+ `- ${run.tests} tests: ${counted(run.counts)}${run.totalDurationMs === null ? '' : `; ${formatMs(run.totalDurationMs)} of test time`}`,
650
+ '',
651
+ '| Status | Test | Spec | Environment | Duration | Detail |',
652
+ '|---|---|---|---|---:|---|',
653
+ ...detail.results.map(r => `| ${r.status} | ${cell(r.title)} | ${cell(r.specPath ?? '—')} | ${r.environment ?? '—'} | ${formatMs(r.durationMs)} | ${cell(r.detail.split('\n')[0] ?? '')} |`),
654
+ ].join('\n')}\n`;
655
+ }