@crvouga/atlas 1.13.0 → 1.15.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 (65) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/playwright/src/index.d.ts +4 -0
  3. package/dist/playwright/src/index.js +4 -0
  4. package/dist/playwright/src/ui-catalog.d.ts +51 -0
  5. package/dist/playwright/src/ui-catalog.js +44 -0
  6. package/dist/reports/src/evidence-detail.d.ts +19 -0
  7. package/dist/reports/src/evidence-detail.js +31 -4
  8. package/dist/reports/src/evidence-types.d.ts +5 -2
  9. package/dist/reports/src/layer-merge.d.ts +5 -1
  10. package/dist/reports/src/layer-merge.js +13 -5
  11. package/dist/reports/src/layer-store.d.ts +1 -1
  12. package/dist/reports/src/layer-store.js +4 -2
  13. package/dist/reports/src/layer-types.d.ts +2 -0
  14. package/dist/reports/src/report-index.js +9 -5
  15. package/dist/reports/src/run-compare.d.ts +182 -0
  16. package/dist/reports/src/run-compare.js +329 -0
  17. package/dist/reports/src/test-catalog.d.ts +8 -0
  18. package/dist/reports/src/test-catalog.js +14 -1
  19. package/dist/reports/src/ui-catalog-publish.d.ts +92 -0
  20. package/dist/reports/src/ui-catalog-publish.js +68 -0
  21. package/dist/reports/src/ui-catalog.d.ts +229 -0
  22. package/dist/reports/src/ui-catalog.js +273 -0
  23. package/dist/reports/src/ui-state.d.ts +70 -0
  24. package/dist/reports/src/ui-state.js +66 -0
  25. package/dist/schema/src/api.d.ts +354 -0
  26. package/dist/schema/src/api.js +78 -3
  27. package/dist/server/src/app.js +15 -0
  28. package/dist/server/src/openapi.js +22 -2
  29. package/docs/reference/http-api.md +46 -0
  30. package/docs/reference/playwright.md +63 -0
  31. package/docs/reference/reports.md +942 -4
  32. package/docs/reference/schema.md +33 -0
  33. package/docs/report-contract.md +2 -2
  34. package/docs/rich-evidence.md +5 -0
  35. package/docs/server.md +2 -0
  36. package/docs/ui-catalog.md +173 -0
  37. package/docs/ui.md +45 -1
  38. package/llms-full.txt +276 -3
  39. package/llms.txt +1 -0
  40. package/package.json +2 -1
  41. package/packages/schema/schemas/effective-delta.schema.json +75 -2
  42. package/packages/schema/schemas/effective-evidence.schema.json +75 -2
  43. package/packages/schema/schemas/evidence-page.schema.json +1 -1
  44. package/packages/schema/schemas/layer-rows.schema.json +74 -1
  45. package/packages/schema/schemas/report-run.schema.json +25 -3
  46. package/packages/schema/schemas/report-runs.schema.json +1 -1
  47. package/packages/schema/schemas/review-collection.schema.json +1 -1
  48. package/packages/schema/schemas/review-manifest.schema.json +1 -1
  49. package/packages/schema/schemas/test-catalog.schema.json +1 -1
  50. package/packages/schema/schemas/test-history.schema.json +2 -2
  51. package/packages/server/openapi.json +1559 -175
  52. package/packages/visualizer/src/app/AppShell.tsx +36 -30
  53. package/packages/visualizer/src/app/router.tsx +10 -1
  54. package/packages/visualizer/src/catalog/UiCatalogFilters.tsx +65 -0
  55. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +342 -0
  56. package/packages/visualizer/src/catalog/provider.tsx +25 -0
  57. package/packages/visualizer/src/catalog/search.ts +68 -0
  58. package/packages/visualizer/src/catalog/words.ts +26 -0
  59. package/packages/visualizer/src/components/Header.tsx +17 -4
  60. package/packages/visualizer/src/explorer/ComparePair.tsx +597 -0
  61. package/packages/visualizer/src/explorer/CompareView.tsx +340 -0
  62. package/packages/visualizer/src/explorer/Explorer.tsx +6 -2
  63. package/packages/visualizer/src/explorer/RunsView.tsx +10 -0
  64. package/packages/visualizer/src/explorer/playlist.tsx +1 -1
  65. package/packages/visualizer/src/explorer/search.ts +15 -3
@@ -0,0 +1,329 @@
1
+ import { compareLatency, groupOf, latencyObservations, latencyReview } from './latency.js';
2
+ /** Where a pair of results (or one result with no partner) stands, in the order a review reads them. */
3
+ export const COMPARE_GROUPS = ['regressed', 'recovered', 'still-failing', 'not-comparable', 'unknown', 'removed', 'added', 'unchanged'];
4
+ /** What each group is called and what it does and does not say. */
5
+ export const COMPARE_GROUP_TEXT = {
6
+ regressed: { label: 'Newly failing', meaning: 'Passed in the baseline run and failed in the candidate run, under the same conditions and the same wording.' },
7
+ recovered: { label: 'Recovered', meaning: 'Failed in the baseline run and passed in the candidate run. One later pass is not a proven fix.' },
8
+ 'still-failing': { label: 'Still failing', meaning: 'Failed in both runs.' },
9
+ 'not-comparable': { label: 'Promise or conditions differ', meaning: 'The behavior is worded differently, or the two results were recorded under different conditions, so the two outcomes are not the same experiment and no regression or recovery is claimed.' },
10
+ unknown: { label: 'Not run or unfinished', meaning: 'One side did not run it or it is still running. That is unknown: not a failure and not a pass.' },
11
+ removed: { label: 'Not in the candidate run', meaning: 'Only the baseline run recorded it. The candidate may not have run it, or it was renamed or dropped: unknown, not a regression and not a pass.' },
12
+ added: { label: 'Not in the baseline run', meaning: 'Only the candidate run recorded it. It may be new, or the baseline may not have run it.' },
13
+ unchanged: { label: 'Same outcome', meaning: 'Passed in both runs, or not run in both.' }
14
+ };
15
+ /** The condition of a result. A server that does not send `kind` leaves it null: unknown, equal only to another unknown. */
16
+ export function conditionOf(result) {
17
+ return { kind: result.kind ?? null, producer: result.producer, environment: result.environment, parameters: result.parameters ?? {} };
18
+ }
19
+ /** A stable text for a condition, equal exactly when two conditions are. */
20
+ export function conditionId(condition) {
21
+ const parameters = Object.keys(condition.parameters).sort().map(name => `${name}=${condition.parameters[name]}`).join(',');
22
+ return [condition.kind ?? '', condition.producer, condition.environment ?? '', parameters].join('/');
23
+ }
24
+ /** How two conditions differ, in words; empty when they are the same. */
25
+ export function conditionDifferences(baseline, candidate) {
26
+ const out = [];
27
+ if (baseline.environment !== candidate.environment)
28
+ out.push(`environment ${baseline.environment ?? 'not declared'} against ${candidate.environment ?? 'not declared'}`);
29
+ if (baseline.producer !== candidate.producer)
30
+ out.push(`producer ${baseline.producer} against ${candidate.producer}`);
31
+ if ((baseline.kind ?? '') !== (candidate.kind ?? ''))
32
+ out.push(`kind ${baseline.kind ?? 'not declared'} against ${candidate.kind ?? 'not declared'}`);
33
+ for (const name of [...new Set([...Object.keys(baseline.parameters), ...Object.keys(candidate.parameters)])].sort()) {
34
+ if (baseline.parameters[name] !== candidate.parameters[name])
35
+ out.push(`${name} ${baseline.parameters[name] ?? 'not set'} against ${candidate.parameters[name] ?? 'not set'}`);
36
+ }
37
+ return out;
38
+ }
39
+ const completed = (status) => status === 'passed' || status === 'failed';
40
+ const compareText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
41
+ /** Short form of a spec fingerprint, to show. */
42
+ export function shortRevision(revision) {
43
+ return revision ? revision.slice(0, 8) : 'not recorded';
44
+ }
45
+ /** The wording a run recorded for a check: what that run's layer called it, else the check's newest title. */
46
+ export function recordedTitle(result) {
47
+ return result.recordedTitle ?? result.title;
48
+ }
49
+ function readPromise(baseline, candidate) {
50
+ const baselineTitle = baseline ? recordedTitle(baseline) : null;
51
+ const candidateTitle = candidate ? recordedTitle(candidate) : null;
52
+ const baselineRevision = baseline?.specRevision ?? null;
53
+ const candidateRevision = candidate?.specRevision ?? null;
54
+ const reasons = [];
55
+ const base = { baselineTitle, candidateTitle, baselineRevision, candidateRevision };
56
+ if (!baseline || !candidate)
57
+ return { ...base, state: 'unknown', reasons: ['Only one run recorded this behavior, so its promise cannot be compared.'] };
58
+ const titleDiffers = baselineTitle !== candidateTitle;
59
+ const revisionKnown = baselineRevision !== null && candidateRevision !== null;
60
+ const revisionDiffers = revisionKnown && baselineRevision !== candidateRevision;
61
+ if (titleDiffers)
62
+ reasons.push(`The behavior is worded differently: “${baselineTitle}” became “${candidateTitle}”.`);
63
+ if (revisionDiffers)
64
+ reasons.push(`The spec file was recorded at revision ${shortRevision(baselineRevision)} and then ${shortRevision(candidateRevision)}. A revision is of the whole file, so it does not say that this behavior's own promise changed.`);
65
+ if (titleDiffers)
66
+ return { ...base, state: 'changed', reasons };
67
+ if (revisionDiffers)
68
+ return { ...base, state: 'revision-differs', reasons };
69
+ if (!revisionKnown) {
70
+ reasons.push(`${baselineRevision === null && candidateRevision === null ? 'Neither run' : baselineRevision === null ? 'The baseline run' : 'The candidate run'} recorded no spec revision, so whether the promise is the same is unknown.`);
71
+ return { ...base, state: 'unknown', reasons };
72
+ }
73
+ return { ...base, state: 'same', reasons };
74
+ }
75
+ function movement(baseline, candidate) {
76
+ const b = baseline.status;
77
+ const c = candidate.status;
78
+ if (!completed(b) || !completed(c)) {
79
+ const where = !completed(c) ? 'candidate' : 'baseline';
80
+ const what = (status) => (status === 'running' ? 'still running' : 'not run');
81
+ const side = !completed(c) ? c : b;
82
+ return { group: 'unknown', summary: `${what(side)[0].toUpperCase()}${what(side).slice(1)} in the ${where} run, so there is no outcome to compare. That is unknown, not a failure or a pass.` };
83
+ }
84
+ if (b === 'passed' && c === 'failed')
85
+ return { group: 'regressed', summary: 'Passed in the baseline run, failed in the candidate run.' };
86
+ if (b === 'failed' && c === 'passed')
87
+ return { group: 'recovered', summary: 'Failed in the baseline run, passed in the candidate run.' };
88
+ if (b === 'failed')
89
+ return { group: 'still-failing', summary: 'Failed in both runs.' };
90
+ return { group: 'unchanged', summary: 'Passed in both runs.' };
91
+ }
92
+ function pairOf(key, baseline, candidate, differences) {
93
+ const promise = readPromise(baseline, candidate);
94
+ const id = [key, baseline ? conditionId(conditionOf(baseline)) : '', candidate ? conditionId(conditionOf(candidate)) : ''].join('\u0001');
95
+ const title = candidate ? recordedTitle(candidate) : recordedTitle(baseline);
96
+ const specPath = (candidate ?? baseline).specPath;
97
+ const caveats = [];
98
+ const common = { id, key, title, specPath, baseline, candidate, promise, conditions: differences, caveats };
99
+ if (!baseline)
100
+ return { ...common, group: 'added', summary: 'Only the candidate run recorded it. It may be new, or the baseline may not have run it.' };
101
+ if (!candidate)
102
+ return { ...common, group: 'removed', summary: 'The candidate run recorded no result for it. It may not have run it, or it was renamed or dropped: unknown, not a regression.' };
103
+ if (promise.state === 'changed' || differences.length > 0) {
104
+ const why = [...(promise.state === 'changed' ? ['the behavior is worded differently'] : []), ...(differences.length ? [`it was recorded under different conditions (${differences.join('; ')})`] : [])];
105
+ return { ...common, group: 'not-comparable', summary: `Not the same experiment: ${why.join(' and ')}. Both outcomes are shown; no regression or recovery is claimed.` };
106
+ }
107
+ const moved = movement(baseline, candidate);
108
+ if (promise.state === 'revision-differs')
109
+ caveats.push(...promise.reasons);
110
+ else if (promise.state === 'unknown' && moved.group !== 'unknown')
111
+ caveats.push(...promise.reasons);
112
+ return { ...common, ...moved };
113
+ }
114
+ /**
115
+ * Pairs the results of two runs and reads what changed. Results are paired by check identity and then by condition (see
116
+ * the module comment); titles never pair two results. Each side's results are those the run recorded last for each
117
+ * check and condition (`RunDetail.results`).
118
+ *
119
+ * @param baseline - The run to compare against.
120
+ * @param candidate - The run under review. Both are used as given: a newer run never replaces either.
121
+ * @returns Every result of either run, grouped by what changed, with the reasons.
122
+ * @example
123
+ * ```ts
124
+ * const comparison = compareRuns(await runDetail('run:ci-100'), await runDetail('run:ci-101'));
125
+ * comparison.results.filter(item => item.group === 'regressed');
126
+ * ```
127
+ */
128
+ export function compareRuns(baseline, candidate) {
129
+ const byKey = (results) => {
130
+ const map = new Map();
131
+ for (const result of results) {
132
+ const list = map.get(result.key);
133
+ if (list)
134
+ list.push(result);
135
+ else
136
+ map.set(result.key, [result]);
137
+ }
138
+ return map;
139
+ };
140
+ const left = byKey(baseline.results);
141
+ const right = byKey(candidate.results);
142
+ const pairs = [];
143
+ for (const key of [...new Set([...left.keys(), ...right.keys()])]) {
144
+ const sides = { base: [...(left.get(key) ?? [])], cand: [...(right.get(key) ?? [])] };
145
+ const order = (a, b) => compareText(conditionId(conditionOf(a)), conditionId(conditionOf(b))) || compareText(a.layer, b.layer);
146
+ sides.base.sort(order);
147
+ sides.cand.sort(order);
148
+ // First the same condition on both sides, then whatever is left of the same check under other conditions.
149
+ const restBase = [];
150
+ for (const result of sides.base) {
151
+ const at = sides.cand.findIndex(other => conditionId(conditionOf(other)) === conditionId(conditionOf(result)));
152
+ if (at < 0)
153
+ restBase.push(result);
154
+ else
155
+ pairs.push(pairOf(key, result, sides.cand.splice(at, 1)[0], []));
156
+ }
157
+ while (restBase.length && sides.cand.length) {
158
+ const result = restBase.shift();
159
+ const other = sides.cand.shift();
160
+ pairs.push(pairOf(key, result, other, conditionDifferences(conditionOf(result), conditionOf(other))));
161
+ }
162
+ for (const result of restBase)
163
+ pairs.push(pairOf(key, result, null, []));
164
+ for (const result of sides.cand)
165
+ pairs.push(pairOf(key, null, result, []));
166
+ }
167
+ pairs.sort((a, b) => COMPARE_GROUPS.indexOf(a.group) - COMPARE_GROUPS.indexOf(b.group) ||
168
+ compareText(a.specPath ?? '', b.specPath ?? '') ||
169
+ compareText(a.title, b.title) ||
170
+ compareText(a.id, b.id));
171
+ const counts = Object.fromEntries(COMPARE_GROUPS.map(group => [group, 0]));
172
+ for (const pair of pairs)
173
+ counts[pair.group] += 1;
174
+ const notes = [];
175
+ if (baseline.run.key === candidate.run.key)
176
+ notes.push('The same run is on both sides, so nothing can differ.');
177
+ if (baseline.run.branch !== candidate.run.branch)
178
+ notes.push(`The runs are on different branches (${baseline.run.branch ?? 'none recorded'} and ${candidate.run.branch ?? 'none recorded'}).`);
179
+ if (candidate.run.endedAtMs < baseline.run.endedAtMs)
180
+ notes.push('The candidate run ended before the baseline run.');
181
+ for (const [name, side] of [['baseline', baseline], ['candidate', candidate]]) {
182
+ if (side.run.freshness === 'modified')
183
+ notes.push(`The ${name} run was recorded from a checkout with uncommitted changes.`);
184
+ if (side.run.counts.running > 0)
185
+ notes.push(`The ${name} run still has results running, so its outcomes may change.`);
186
+ }
187
+ return { baseline: baseline.run, candidate: candidate.run, results: pairs, counts, notes };
188
+ }
189
+ const devicesOf = (entries) => [...new Set(latencyObservations(entries).map(o => groupOf(o, 'device')))].sort();
190
+ /**
191
+ * Compares the product latency recorded in two sets of results of one check, one per side. A metric is compared only
192
+ * when it is the same measurement (name, unit, boundaries) and both sides were measured on the same devices, in the
193
+ * same environments, with the same instrumentation and other conditions; otherwise it is `incompatible` and no change
194
+ * is computed, with the reasons. A metric only one side recorded is named as such. A side without any latency is unknown,
195
+ * not fast: it contributes no metric. `conditionsDiffer` names differences of the results' own conditions, which make
196
+ * every metric incompatible.
197
+ */
198
+ export function compareProductLatency(baseline, candidate, conditionsDiffer = []) {
199
+ const left = latencyReview(baseline).metrics;
200
+ const right = latencyReview(candidate).metrics;
201
+ const sameDevices = devicesOf(baseline).join('\u0001') === devicesOf(candidate).join('\u0001');
202
+ const out = [];
203
+ for (const { metric, groups } of left) {
204
+ const base = groups[0];
205
+ const other = right.find(item => item.metric.key === metric.key)?.groups[0];
206
+ if (!other) {
207
+ const renamed = right.find(item => item.metric.name === metric.name);
208
+ out.push({ state: 'baseline-only', name: metric.name, summary: base, reasons: [renamed ? `The candidate measures ${metric.name} over a different interval (${renamed.metric.boundary.label}), so the two are not the same measurement.` : 'The candidate recorded no such measurement.'] });
209
+ continue;
210
+ }
211
+ const change = compareLatency(base, other);
212
+ const reasons = [...conditionsDiffer.map(line => `Recorded under different conditions: ${line}.`)];
213
+ if (!sameDevices)
214
+ reasons.push(`Devices differ: ${devicesOf(baseline).join(', ')} against ${devicesOf(candidate).join(', ')}.`);
215
+ if (change.comparable) {
216
+ reasons.push(...change.disclosures.filter(line => /^(Environments differ|Test conditions differ|Instrumentation changed)/.test(line)));
217
+ }
218
+ else
219
+ reasons.push(...change.reasons);
220
+ if (reasons.length)
221
+ out.push({ state: 'incompatible', name: metric.name, baseline: base, candidate: other, change: null, reasons });
222
+ else
223
+ out.push({ state: 'compared', name: metric.name, baseline: base, candidate: other, change, reasons: change.disclosures });
224
+ }
225
+ for (const { metric, groups } of right) {
226
+ if (!left.some(item => item.metric.key === metric.key) && !left.some(item => item.metric.name === metric.name)) {
227
+ out.push({ state: 'candidate-only', name: metric.name, summary: groups[0], reasons: ['The baseline recorded no such measurement.'] });
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+ /**
233
+ * The scope that counts the independent executions of one result's check up to the end of its run: the same
234
+ * environment, producer and parameters, and the spec revision that result was recorded against (executions against
235
+ * another revision are other promises). A result that recorded no revision is counted across revisions, which
236
+ * {@link compareReliability} will not compare. Use it for each side, with that side's result and run.
237
+ */
238
+ export function reliabilityScopeAt(result, run) {
239
+ return {
240
+ ...(result.environment ? { environment: [result.environment] } : {}),
241
+ producer: [result.producer],
242
+ ...(result.parameters && Object.keys(result.parameters).length ? { parameter: Object.entries(result.parameters).map(([name, value]) => `${name}=${value}`).sort() } : {}),
243
+ ...(result.specRevision ? { specRevision: [result.specRevision] } : { revision: 'any' }),
244
+ until: run.endedAtMs
245
+ };
246
+ }
247
+ /**
248
+ * Sets the reliability of a check up to the baseline run against its reliability up to the candidate run. A change is
249
+ * stated only when both samples are established (at least `minimumExecutions` independent executions), both counted the
250
+ * same recorded spec revision (see {@link reliabilityScopeAt}) and the conditions match; whether the difference exceeds the uncertainty is
251
+ * said by `direction`. Otherwise each figure stands alone and the reasons say why.
252
+ */
253
+ export function compareReliability(baseline, candidate, conditionsDiffer = []) {
254
+ const reasons = conditionsDiffer.map(line => `Counted under different conditions: ${line}.`);
255
+ for (const [name, document] of [['baseline', baseline], ['candidate', candidate]]) {
256
+ if (document.sample === 'none')
257
+ reasons.push(`The ${name} figure has no completed execution, so it is unknown, not reliable.`);
258
+ else if (document.sample === 'insufficient')
259
+ reasons.push(`The ${name} figure rests on ${document.eventual.executions} independent ${document.eventual.executions === 1 ? 'execution' : 'executions'}; at least ${document.minimumExecutions} are needed.`);
260
+ }
261
+ const revisionOf = (document) => document.scope.specRevision?.join(',') ?? null;
262
+ if (revisionOf(baseline) === null || revisionOf(candidate) === null)
263
+ reasons.push('A side recorded no spec revision, so whether both count the same promise is unknown.');
264
+ else if (revisionOf(baseline) !== revisionOf(candidate))
265
+ reasons.push('The executions were counted against different revisions of the spec, so they describe different promises.');
266
+ const base = baseline.eventual;
267
+ const cand = candidate.eventual;
268
+ if (reasons.length || base.rate === null || cand.rate === null)
269
+ return { comparable: false, baseline: base, candidate: cand, reasons };
270
+ const separate = baseline.interval && candidate.interval ? baseline.interval.upper < candidate.interval.lower || candidate.interval.upper < baseline.interval.lower : false;
271
+ const points = Math.round((cand.rate - base.rate) * 1000) / 10;
272
+ return {
273
+ comparable: true,
274
+ baseline: base,
275
+ candidate: cand,
276
+ points,
277
+ direction: !separate ? 'indistinguishable' : cand.rate > base.rate ? 'higher' : 'lower',
278
+ reasons: separate ? [] : ['The two intervals overlap, so the difference may be chance. It is not shown as an improvement or a decline.']
279
+ };
280
+ }
281
+ /**
282
+ * Lines up the recorded steps of two results by name (the n-th step of a name on one side with the n-th of that name on
283
+ * the other), keeping each side's own time origin: an offset is milliseconds after that side's check began. Nothing is
284
+ * aligned by position or by fuzzy wording. When nothing lines up, `explanation` says why.
285
+ */
286
+ export function alignSteps(baseline, candidate) {
287
+ const keyed = (steps) => {
288
+ const seen = new Map();
289
+ return steps.map(step => {
290
+ const n = seen.get(step.name) ?? 0;
291
+ seen.set(step.name, n + 1);
292
+ return { step, id: `${n}\u0001${step.name}` };
293
+ });
294
+ };
295
+ const left = keyed(baseline);
296
+ const right = keyed(candidate);
297
+ const rightById = new Map(right.map(item => [item.id, item.step]));
298
+ const leftIds = new Set(left.map(item => item.id));
299
+ const steps = [];
300
+ for (const { step, id } of left) {
301
+ const other = rightById.get(id);
302
+ steps.push({
303
+ name: step.name,
304
+ ...(step.offsetMs === undefined ? {} : { baselineOffsetMs: step.offsetMs }),
305
+ ...(other?.offsetMs === undefined ? {} : { candidateOffsetMs: other.offsetMs }),
306
+ onBaseline: true,
307
+ onCandidate: other !== undefined
308
+ });
309
+ }
310
+ for (const { step, id } of right) {
311
+ if (!leftIds.has(id))
312
+ steps.push({ name: step.name, ...(step.offsetMs === undefined ? {} : { candidateOffsetMs: step.offsetMs }), onBaseline: false, onCandidate: true });
313
+ }
314
+ const seekable = steps.filter((step) => step.baselineOffsetMs !== undefined && step.candidateOffsetMs !== undefined);
315
+ const shared = steps.filter(step => step.onBaseline && step.onCandidate).length;
316
+ const differs = steps.some(step => !(step.onBaseline && step.onCandidate));
317
+ let explanation;
318
+ if (seekable.length > 0)
319
+ explanation = `${seekable.length} recorded ${seekable.length === 1 ? 'step is' : 'steps are'} on both sides with a time. Seeking uses each side's own step time from the start of its own check; the recordings are otherwise not synchronized.`;
320
+ else if (!baseline.length && !candidate.length)
321
+ explanation = 'Neither side recorded steps, so the recordings cannot be aligned. Each plays from its own start.';
322
+ else if (!baseline.length || !candidate.length)
323
+ explanation = `Only the ${baseline.length ? 'baseline' : 'candidate'} recorded steps, so the recordings cannot be aligned. Each plays from its own start.`;
324
+ else if (shared === 0)
325
+ explanation = 'The two sides share no step name, so the recordings cannot be aligned. Each plays from its own start.';
326
+ else
327
+ explanation = 'The shared steps carry no time on both sides, so the recordings cannot be aligned. Each plays from its own start.';
328
+ return { state: seekable.length > 0 ? 'aligned' : 'unaligned', steps, seekable, differs, explanation };
329
+ }
@@ -322,6 +322,14 @@ export type RunResult = {
322
322
  readonly detail: string;
323
323
  /** Every video its evidence references, with who holds it (check, retry attempt or step). Absent when there is none. */
324
324
  readonly videos?: readonly VideoRef[];
325
+ /** The kind of the part that held it (`behavior`, `checks`…). With the producer, environment and parameters it is the condition the result was recorded under. Absent from an older server. */
326
+ readonly kind?: string;
327
+ /** The other choices that made it its own result (a device size, a locale…). Absent when there are none, and from an older server. */
328
+ readonly parameters?: Readonly<Record<string, string>>;
329
+ /** The fingerprint of its spec file the layer recorded: results with different ones were recorded against different revisions of the spec. Absent means not recorded, never "the same". */
330
+ readonly specRevision?: string;
331
+ /** The title this run's layer gave the check. `title` is the newest title the check has in the whole history, so a check that was reworded shows its newest wording in both runs; this is the wording as recorded. Absent from an older server. */
332
+ readonly recordedTitle?: string;
325
333
  };
326
334
  /**
327
335
  * One layer of a run, as the run's artifact hub names it: who recorded it, how many checks it holds (zero is possible
@@ -105,6 +105,8 @@ export class TestHistory {
105
105
  #idents = new Map();
106
106
  #runs = new Map();
107
107
  #specs = new RecordedSpecs();
108
+ /** Titles seen, so the many results that share one hold one string. */
109
+ #titles = new Map();
108
110
  #seen = new Set();
109
111
  #rev = 0;
110
112
  #cells = null;
@@ -187,9 +189,16 @@ export class TestHistory {
187
189
  ident.title = row.title;
188
190
  ident.titledAt = layer.recordedAtMs;
189
191
  }
190
- this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence, m: productLatency(row.measurements), f: row.specPath ? fingerprints[row.specPath] : undefined });
192
+ this.#remember(slot, { o: origin, s: row.status, d: row.durationMs, k: row.kind, t: row.detail, e: row.evidence, m: productLatency(row.measurements), f: row.specPath ? fingerprints[row.specPath] : undefined, ti: this.#title(row.title) });
191
193
  }
192
194
  }
195
+ #title(title) {
196
+ const held = this.#titles.get(title);
197
+ if (held !== undefined)
198
+ return held;
199
+ this.#titles.set(title, title);
200
+ return title;
201
+ }
193
202
  /** Sorted insert, newest first; past the bound, the oldest is dropped. */
194
203
  #remember(slot, entry) {
195
204
  const list = this.#bySlot.get(slot);
@@ -262,6 +271,10 @@ export class TestHistory {
262
271
  key: i.slot, id: i.id, title: i.title, specPath: i.specPath, environment: r.o.environment, producer: r.o.producer,
263
272
  status: r.s, durationMs: r.d, at: r.o.at, layer: r.o.layer, detail: shortDetail(r.t),
264
273
  ...(r.e?.videos ? { videos: r.e.videos } : {}),
274
+ kind: r.k,
275
+ ...(Object.keys(r.o.parameters).length ? { parameters: r.o.parameters } : {}),
276
+ ...(r.f ? { specRevision: r.f } : {}),
277
+ ...(r.ti !== undefined ? { recordedTitle: r.ti } : {}),
265
278
  }));
266
279
  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));
267
280
  const layers = [...run.members].sort((a, b) => a.recordedAtMs - b.recordedAtMs || compareText(a.id, b.id));
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @module Writing a UI catalog from any collector
3
+ *
4
+ * Two small builders that turn what a collector captured, or what a planner expects, into the input of `publishChecks`
5
+ * (and of `atlas report publish --checks`), following the convention in {@link ./ui-state}. They are plain data in and plain
6
+ * data out: no browser, no test framework, no file system. A Playwright test, a Cypress run, a mobile device lab, a Storybook
7
+ * crawler or a shell script that writes the same JSON all produce the same catalog.
8
+ *
9
+ * Shards need no coordination: each shard calls {@link uiStateLayer} for the states it captured (and optionally
10
+ * {@link uiPlanLayer} for the states it was given), with its own `shard` label, and publishes. A planner that knows the whole
11
+ * product publishes the plan once.
12
+ *
13
+ * @see docs/ui-catalog.md
14
+ */
15
+ import type { PublishChecksInput } from '../../schema/src/layer.js';
16
+ import { type UiStateFacts } from './ui-state.js';
17
+ /** A picture to store with a capture: its bytes (`data`, `base64`), or where it already is (`uri`). */
18
+ export type UiPicture = {
19
+ /** The file name; default `<id>.png`. */
20
+ readonly name?: string;
21
+ readonly data?: Uint8Array;
22
+ readonly base64?: string;
23
+ readonly uri?: string;
24
+ /** Default `image/png`. */
25
+ readonly contentType?: string;
26
+ readonly width?: number;
27
+ readonly height?: number;
28
+ };
29
+ /** One captured state. `id` must be stable across runs: it is how a new capture replaces an old one. */
30
+ export type UiStateCapture = UiStateFacts & {
31
+ readonly id: string;
32
+ /** For a person; default the id. */
33
+ readonly title?: string;
34
+ /** `passed` (default): captured. `failed`: the capture was attempted and did not work (say why in `detail`). `running`: being taken. `not-run`: skipped. */
35
+ readonly status?: 'passed' | 'failed' | 'running' | 'not-run';
36
+ /** Why it failed or was skipped: a readiness check that never became true, a console error. */
37
+ readonly detail?: string;
38
+ readonly screenshot?: UiPicture;
39
+ readonly durationMs?: number;
40
+ /** More facts about the capture that never split it (`browser`, `os`). */
41
+ readonly labels?: Readonly<Record<string, string>>;
42
+ readonly tags?: readonly string[];
43
+ };
44
+ /** One expected state, or (with `inventory`) one surface of the product's inventory. */
45
+ export type UiStatePlan = UiStateFacts & {
46
+ readonly id: string;
47
+ readonly title?: string;
48
+ };
49
+ /** What every layer of a catalog says about where and when it was recorded. */
50
+ export type UiLayerOrigin = {
51
+ /** The suite or tool that makes the layer. Part of the slot; a planner and a collector normally differ. */
52
+ readonly producer: string;
53
+ /** When the captures were taken (or the plan was made), epoch milliseconds. Take it from the run, not from the moment of publishing. */
54
+ readonly recordedAtMs: number;
55
+ /** The client: `desktop-web`, `mobile-web`, `ios`, `android`… Part of the slot. */
56
+ readonly environment?: string;
57
+ /** The rest of the profile, such as `{ viewport: '390x844', scheme: 'dark' }`. Each combination is its own result. Part of the slot. */
58
+ readonly profile?: Readonly<Record<string, string>>;
59
+ readonly sha?: string | null;
60
+ readonly branch?: string | null;
61
+ readonly runUrl?: string | null;
62
+ /** Which shard this is (`2/4`, `checkout`, a machine name). Shown with each capture; never part of the slot. */
63
+ readonly shard?: string;
64
+ /** The execution these layers belong to, so shards of one run stay one run. */
65
+ readonly run?: {
66
+ readonly id: string;
67
+ readonly title?: string;
68
+ };
69
+ };
70
+ /**
71
+ * The input of `publishChecks` for the states one collector (or one shard) captured under one profile. Throws when a state id
72
+ * repeats, because the second would silently replace the first. A state without a `status` is `passed`: it was captured.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * await publishChecks(store, uiStateLayer({
77
+ * producer: 'web-capture', recordedAtMs: Date.now(), environment: 'mobile-web', profile: { viewport: '390x844', scheme: 'dark' }, shard: '1/4',
78
+ * states: [{ id: 'checkout/payment', title: 'Payment', route: '/checkout', covers: ['payment-form'], screenshot: { data: png } }]
79
+ * }));
80
+ * ```
81
+ */
82
+ export declare function uiStateLayer(input: UiLayerOrigin & {
83
+ readonly states: readonly UiStateCapture[];
84
+ }): PublishChecksInput;
85
+ /**
86
+ * The input of `publishChecks` for what is expected: states that should be captured under this profile and, with `inventory`,
87
+ * surfaces the product is known to have. Every entry is `not-run`, so a plan never counts as a pass, and it fills a slot of
88
+ * its own (kind `ui-plan`), so a capture never erases it and it never hides a capture. Throws when an id repeats.
89
+ */
90
+ export declare function uiPlanLayer(input: UiLayerOrigin & {
91
+ readonly states: readonly UiStatePlan[];
92
+ }): PublishChecksInput;
@@ -0,0 +1,68 @@
1
+ import { UI_PLAN_KIND, UI_STATE_EXTENSION, UI_STATE_KIND } from './ui-state.js';
2
+ function common(origin, kind, title) {
3
+ return {
4
+ producer: origin.producer, kind, title, recordedAtMs: origin.recordedAtMs,
5
+ ...(origin.environment ? { environment: origin.environment } : {}),
6
+ parameters: { ...origin.profile },
7
+ ...(origin.sha !== undefined ? { sha: origin.sha } : {}), ...(origin.branch !== undefined ? { branch: origin.branch } : {}), ...(origin.runUrl !== undefined ? { runUrl: origin.runUrl } : {}),
8
+ ...(origin.run ? { run: origin.run } : {})
9
+ };
10
+ }
11
+ function facts(source) {
12
+ const { route, fixture, cohort, area, flags, covers, inventory } = source;
13
+ return { ...(route ? { route } : {}), ...(fixture ? { fixture } : {}), ...(cohort ? { cohort } : {}), ...(area ? { area } : {}), ...(flags?.length ? { flags } : {}), ...(covers?.length ? { covers } : {}), ...(inventory ? { inventory } : {}) };
14
+ }
15
+ /**
16
+ * The input of `publishChecks` for the states one collector (or one shard) captured under one profile. Throws when a state id
17
+ * repeats, because the second would silently replace the first. A state without a `status` is `passed`: it was captured.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * await publishChecks(store, uiStateLayer({
22
+ * producer: 'web-capture', recordedAtMs: Date.now(), environment: 'mobile-web', profile: { viewport: '390x844', scheme: 'dark' }, shard: '1/4',
23
+ * states: [{ id: 'checkout/payment', title: 'Payment', route: '/checkout', covers: ['payment-form'], screenshot: { data: png } }]
24
+ * }));
25
+ * ```
26
+ */
27
+ export function uiStateLayer(input) {
28
+ const seen = new Set();
29
+ const checks = input.states.map(state => {
30
+ if (seen.has(state.id))
31
+ throw new Error(`uiStateLayer: the state "${state.id}" appears twice; give each state of one profile its own id`);
32
+ seen.add(state.id);
33
+ const shot = state.screenshot;
34
+ const attachments = shot ? [{
35
+ name: shot.name ?? `${state.id.replace(/[^\w.-]+/g, '-')}.png`, role: 'screenshot', contentType: shot.contentType ?? 'image/png',
36
+ ...(shot.data ? { data: shot.data } : {}), ...(shot.base64 ? { base64: shot.base64 } : {}), ...(shot.uri ? { uri: shot.uri } : {}),
37
+ ...(shot.width !== undefined ? { width: shot.width } : {}), ...(shot.height !== undefined ? { height: shot.height } : {})
38
+ }] : undefined;
39
+ const labels = { ...state.labels, ...(input.shard ? { shard: input.shard } : {}) };
40
+ return {
41
+ id: state.id, title: state.title ?? state.id, status: state.status ?? 'passed', detail: state.detail ?? '',
42
+ ...(state.durationMs !== undefined ? { durationMs: state.durationMs } : {}),
43
+ ...(attachments ? { attachments } : {}),
44
+ ...(Object.keys(labels).length ? { labels } : {}), ...(state.tags?.length ? { tags: [...state.tags] } : {}),
45
+ extensions: { [UI_STATE_EXTENSION]: facts(state) }
46
+ };
47
+ });
48
+ return { ...common(input, UI_STATE_KIND, 'UI states'), checks };
49
+ }
50
+ /**
51
+ * The input of `publishChecks` for what is expected: states that should be captured under this profile and, with `inventory`,
52
+ * surfaces the product is known to have. Every entry is `not-run`, so a plan never counts as a pass, and it fills a slot of
53
+ * its own (kind `ui-plan`), so a capture never erases it and it never hides a capture. Throws when an id repeats.
54
+ */
55
+ export function uiPlanLayer(input) {
56
+ const seen = new Set();
57
+ const checks = input.states.map(state => {
58
+ if (seen.has(state.id))
59
+ throw new Error(`uiPlanLayer: "${state.id}" appears twice`);
60
+ seen.add(state.id);
61
+ return {
62
+ id: state.id, title: state.title ?? state.id, status: 'not-run', detail: state.inventory ? 'In the product inventory' : 'Expected, not captured yet',
63
+ ...(input.shard ? { labels: { shard: input.shard } } : {}),
64
+ extensions: { [UI_STATE_EXTENSION]: facts(state) }
65
+ };
66
+ });
67
+ return { ...common(input, UI_PLAN_KIND, 'UI states expected'), checks };
68
+ }