@crvouga/atlas 1.14.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 (35) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/dist/reports/src/evidence-detail.d.ts +19 -0
  3. package/dist/reports/src/evidence-detail.js +31 -4
  4. package/dist/reports/src/evidence-types.d.ts +2 -2
  5. package/dist/reports/src/layer-merge.js +4 -3
  6. package/dist/reports/src/layer-store.d.ts +1 -1
  7. package/dist/reports/src/layer-store.js +4 -2
  8. package/dist/reports/src/layer-types.d.ts +2 -0
  9. package/dist/reports/src/run-compare.d.ts +182 -0
  10. package/dist/reports/src/run-compare.js +329 -0
  11. package/dist/reports/src/test-catalog.d.ts +8 -0
  12. package/dist/reports/src/test-catalog.js +14 -1
  13. package/dist/schema/src/api.d.ts +4 -0
  14. package/dist/schema/src/api.js +7 -3
  15. package/docs/reference/reports.md +354 -3
  16. package/docs/ui.md +30 -1
  17. package/llms-full.txt +30 -1
  18. package/package.json +1 -1
  19. package/packages/schema/schemas/effective-delta.schema.json +2 -2
  20. package/packages/schema/schemas/effective-evidence.schema.json +2 -2
  21. package/packages/schema/schemas/evidence-page.schema.json +1 -1
  22. package/packages/schema/schemas/layer-rows.schema.json +1 -1
  23. package/packages/schema/schemas/report-run.schema.json +25 -3
  24. package/packages/schema/schemas/report-runs.schema.json +1 -1
  25. package/packages/schema/schemas/review-collection.schema.json +1 -1
  26. package/packages/schema/schemas/review-manifest.schema.json +1 -1
  27. package/packages/schema/schemas/test-catalog.schema.json +1 -1
  28. package/packages/schema/schemas/test-history.schema.json +2 -2
  29. package/packages/server/openapi.json +39 -17
  30. package/packages/visualizer/src/explorer/ComparePair.tsx +597 -0
  31. package/packages/visualizer/src/explorer/CompareView.tsx +340 -0
  32. package/packages/visualizer/src/explorer/Explorer.tsx +6 -2
  33. package/packages/visualizer/src/explorer/RunsView.tsx +10 -0
  34. package/packages/visualizer/src/explorer/playlist.tsx +1 -1
  35. 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));
@@ -3624,6 +3624,10 @@ export declare const RunDetailSchema: z.ZodObject<{
3624
3624
  extensions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
3625
3625
  }, z.core.$loose>>;
3626
3626
  }, z.core.$loose>>>;
3627
+ kind: z.ZodOptional<z.ZodString>;
3628
+ parameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
3629
+ specRevision: z.ZodOptional<z.ZodString>;
3630
+ recordedTitle: z.ZodOptional<z.ZodString>;
3627
3631
  }, z.core.$loose>>;
3628
3632
  layers: z.ZodOptional<z.ZodArray<z.ZodObject<{
3629
3633
  id: z.ZodString;
@@ -70,14 +70,14 @@ export const VideoRefSchema = z.looseObject({
70
70
  step: z.string().optional().describe('The name of the step that holds it'),
71
71
  tags: z.array(z.string()).optional().describe('The normalized tags of this attachment itself. Absent when it has none (and in documents of older servers)'),
72
72
  recording: z.string().optional().describe('What identifies the bytes; references with the same value are one recording. Absent when it cannot be played'),
73
- available: z.boolean().describe('False when the reference names no bytes (no bundle key, address or inline text)'),
73
+ available: z.boolean().describe('False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer\'s own bundle does not hold'),
74
74
  scope: EvidenceScopeSchema.optional().describe('What this recording shows as its producer declared it: its own `scope`, else its attempt\'s, check\'s, part\'s or layer\'s. Absent: the scope is unknown, never live, physical or audible')
75
75
  });
76
76
  /** Video references as counts: how many are referenced, how many distinct recordings can be played, how many cannot. */
77
77
  export const VideoCountsSchema = z.looseObject({
78
78
  references: z.number().int().nonnegative().describe('Every reference to a video; identical bytes referenced twice count twice'),
79
79
  recordings: z.number().int().nonnegative().describe('Distinct playable recordings'),
80
- unavailable: z.number().int().nonnegative().describe('References that name no bytes')
80
+ unavailable: z.number().int().nonnegative().describe('References that name no bytes, or bytes the layer does not hold')
81
81
  });
82
82
  /** What a result carries beyond its outcome, as counts and short facts. Present only when there is something. */
83
83
  export const EvidenceSummarySchema = z.looseObject({
@@ -671,7 +671,11 @@ export const RunDetailSchema = z.looseObject({
671
671
  results: z.array(z.looseObject({
672
672
  key: z.string(), id: z.string(), title: z.string(), specPath: z.string().nullable(), environment: z.string().nullable(), producer: z.string(),
673
673
  status: EvidenceStatusSchema, durationMs: z.number().nullable(), at: z.number(), layer: z.string(), detail: z.string(),
674
- videos: z.array(VideoRefSchema).optional().describe('Every video its evidence references, with who holds it')
674
+ videos: z.array(VideoRefSchema).optional().describe('Every video its evidence references, with who holds it'),
675
+ kind: z.string().optional().describe('The kind of the part that held it. With the producer, environment and parameters it is the condition the result was recorded under. Absent from an older server'),
676
+ parameters: z.record(z.string(), z.string()).optional().describe('The other choices that made it its own result (a device size, a locale…). Absent when there are none, and from an older server'),
677
+ specRevision: z.string().optional().describe('The fingerprint of its spec file the layer recorded. Absent means not recorded, never "the same"'),
678
+ recordedTitle: z.string().optional().describe('The title this run\'s layer gave the check. `title` is the check\'s newest title in the whole history; this is the wording as recorded. Absent from an older server')
675
679
  })),
676
680
  layers: z.array(z.looseObject({
677
681
  id: z.string().describe('The layer id; read its files with `/api/reports/evidence/detail/{backend}/{id}`'),