@crvouga/atlas 1.14.0 → 1.16.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 (52) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/reports/src/catalog-health.d.ts +15 -2
  3. package/dist/reports/src/catalog-health.js +23 -2
  4. package/dist/reports/src/evidence-detail.d.ts +19 -0
  5. package/dist/reports/src/evidence-detail.js +31 -4
  6. package/dist/reports/src/evidence-types.d.ts +2 -2
  7. package/dist/reports/src/layer-merge.js +4 -3
  8. package/dist/reports/src/layer-store.d.ts +1 -1
  9. package/dist/reports/src/layer-store.js +4 -2
  10. package/dist/reports/src/layer-types.d.ts +2 -0
  11. package/dist/reports/src/run-compare.d.ts +182 -0
  12. package/dist/reports/src/run-compare.js +329 -0
  13. package/dist/reports/src/test-catalog.d.ts +8 -0
  14. package/dist/reports/src/test-catalog.js +14 -1
  15. package/dist/schema/src/api.d.ts +10 -0
  16. package/dist/schema/src/api.js +15 -5
  17. package/dist/schema/src/server-config.d.ts +3 -0
  18. package/dist/schema/src/server-config.js +5 -0
  19. package/dist/server/src/app.d.ts +2 -0
  20. package/dist/server/src/app.js +6 -2
  21. package/dist/server/src/config.js +4 -0
  22. package/dist/server/src/main.js +1 -0
  23. package/dist/server/src/openapi.js +2 -2
  24. package/docs/extending.md +1 -1
  25. package/docs/reference/http-api.md +4 -4
  26. package/docs/reference/reports.md +378 -5
  27. package/docs/reference/server.md +2 -0
  28. package/docs/server.md +11 -2
  29. package/docs/ui.md +49 -1
  30. package/llms-full.txt +65 -8
  31. package/package.json +1 -1
  32. package/packages/schema/schemas/catalog-health.schema.json +20 -0
  33. package/packages/schema/schemas/effective-delta.schema.json +2 -2
  34. package/packages/schema/schemas/effective-evidence.schema.json +2 -2
  35. package/packages/schema/schemas/evidence-page.schema.json +1 -1
  36. package/packages/schema/schemas/layer-rows.schema.json +1 -1
  37. package/packages/schema/schemas/report-run.schema.json +25 -3
  38. package/packages/schema/schemas/report-runs.schema.json +1 -1
  39. package/packages/schema/schemas/review-collection.schema.json +1 -1
  40. package/packages/schema/schemas/review-manifest.schema.json +1 -1
  41. package/packages/schema/schemas/server-config.schema.json +17 -0
  42. package/packages/schema/schemas/test-catalog.schema.json +1 -1
  43. package/packages/schema/schemas/test-history.schema.json +2 -2
  44. package/packages/server/openapi.json +63 -21
  45. package/packages/visualizer/src/explorer/ComparePair.tsx +597 -0
  46. package/packages/visualizer/src/explorer/CompareView.tsx +340 -0
  47. package/packages/visualizer/src/explorer/Explorer.tsx +13 -2
  48. package/packages/visualizer/src/explorer/HealthView.tsx +402 -0
  49. package/packages/visualizer/src/explorer/RunsView.tsx +10 -0
  50. package/packages/visualizer/src/explorer/catalog.tsx +33 -0
  51. package/packages/visualizer/src/explorer/playlist.tsx +1 -1
  52. package/packages/visualizer/src/explorer/search.ts +18 -3
@@ -0,0 +1,597 @@
1
+ /**
2
+ * @module Compare one behavior
3
+ *
4
+ * One behavior set side by side across the two pinned runs: its promise (wording, spec revision and recorded steps), each
5
+ * side's outcome with its own provenance, the two recordings, and the figures that can honestly be set against each other
6
+ * (product latency and reliability). The tabs read only what is on screen: steps and recordings are read when the
7
+ * behavior is opened, the test's history when Figures is. Each recording keeps its own attempt, revision and time
8
+ * origin; they are not synchronized, and seeking both to a recorded step uses each side's own step time. Nothing here
9
+ * decides a status: an unsupported comparison says why instead of computing a change.
10
+ */
11
+ import { attachmentIdentity, isVideoAttachment } from '@crvouga/atlas/reports/evidence-detail';
12
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
13
+ import { formatLatency } from '@crvouga/atlas/reports/latency';
14
+ import { reliability } from '@crvouga/atlas/reports/reliability';
15
+ import {
16
+ alignSteps,
17
+ COMPARE_GROUP_TEXT,
18
+ compareProductLatency,
19
+ compareReliability,
20
+ conditionId,
21
+ conditionOf,
22
+ reliabilityScopeAt,
23
+ shortRevision,
24
+ type ComparedResult,
25
+ type MetricComparison,
26
+ type RunComparison
27
+ } from '@crvouga/atlas/reports/run-compare';
28
+ import { formatMs, HISTORY_PER_TEST, type RunLayer, type RunResult, type RunSummary, type TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
29
+ import { Alert, Anchor, Badge, Box, Button, Code, Group, List, NativeSelect, SimpleGrid, Stack, Table, Tabs, Text, Title } from '@mantine/core';
30
+ import { IconPlayerPause, IconPlayerPlay } from '@tabler/icons-react';
31
+ import { Link } from '@tanstack/react-router';
32
+ import { useMemo, useRef, useState } from 'react';
33
+
34
+ import { attachmentSource } from '../evidence/artifacts';
35
+ import { everyAttachment } from '../evidence/artifact-tags';
36
+ import { ScopeNotice } from '../evidence/evidence-scope';
37
+ import { duration, exactTime, plural, statusWord } from '../evidence/format';
38
+ import { StatusMark } from '../evidence/marks';
39
+ import { useTestHistory } from './catalog';
40
+ import { patched } from './navigate';
41
+ import { entriesFromRun, playableVideo, RECORDING_STATE_TEXT, recordingState, type PlaylistEntry } from './playlist-entries';
42
+ import { useEntryDetail } from './playlist';
43
+
44
+ type Side = 'baseline' | 'candidate';
45
+ const SIDE_LABEL: Record<Side, string> = { baseline: 'Baseline', candidate: 'Candidate' };
46
+
47
+ /** The result of one side as a playlist entry, so its recording is judged and read the way the playlist does. */
48
+ function entryOf(result: RunResult | null, layers: readonly RunLayer[] | undefined): PlaylistEntry | null {
49
+ if (!result) return null;
50
+ return { ...entriesFromRun([result], layers)[0]!, kind: result.kind ?? null };
51
+ }
52
+
53
+ /** Both sides of a pair, written the same way, in a labelled region so each keeps its own identity. */
54
+ function Pair({ label, children }: { label: string; children: (side: Side) => React.ReactNode }) {
55
+ return (
56
+ <SimpleGrid cols={{ base: 1, md: 2 }} aria-label={label} role="group">
57
+ {(['baseline', 'candidate'] as const).map((side) => (
58
+ <Stack key={side} gap="xs" component="section" aria-label={`${SIDE_LABEL[side]}: ${label}`} data-side={side} miw={0}>
59
+ <Title order={4}>{SIDE_LABEL[side]}</Title>
60
+ {children(side)}
61
+ </Stack>
62
+ ))}
63
+ </SimpleGrid>
64
+ );
65
+ }
66
+
67
+ const PROMISE_BADGE = {
68
+ same: { label: 'Same promise', color: 'green' },
69
+ changed: { label: 'Promise changed', color: 'orange' },
70
+ 'revision-differs': { label: 'Spec revision differs', color: 'yellow' },
71
+ unknown: { label: 'Promise sameness unknown', color: 'gray' }
72
+ } as const;
73
+
74
+ function Provenance({ result, run }: { result: RunResult; run: RunSummary }) {
75
+ return (
76
+ <Text component="div" c="dimmed" size="sm">
77
+ {[environmentLabel(result.environment), result.producer, `recorded ${exactTime(result.at)}`, run.branch, run.sha?.slice(0, 7)].filter(Boolean).join(' · ')}
78
+ </Text>
79
+ );
80
+ }
81
+
82
+ function Missing({ side, item }: { side: Side; item: ComparedResult }) {
83
+ return (
84
+ <Text component="div" c="dimmed" role="status">
85
+ The {side} run recorded no result for this behavior. {item.group === 'removed' || item.group === 'added' ? 'That is unknown, not a pass and not a failure.' : ''}
86
+ </Text>
87
+ );
88
+ }
89
+
90
+ function PromiseTab({ item, comparison, steps }: { item: ComparedResult; comparison: RunComparison; steps: Record<Side, readonly { name: string; kind?: string | undefined; offsetMs?: number | undefined }[] | null> }) {
91
+ const promise = item.promise;
92
+ const aligned = steps.baseline && steps.candidate ? alignSteps(steps.baseline, steps.candidate) : null;
93
+ const only = (side: Side, name: string) => aligned?.steps.some((step) => step.name === name && (side === 'baseline' ? !step.onCandidate : !step.onBaseline));
94
+ return (
95
+ <Stack gap="md">
96
+ <Group gap="xs">
97
+ <Badge color={PROMISE_BADGE[promise.state].color} variant="light" tt="none" data-promise={promise.state}>
98
+ {PROMISE_BADGE[promise.state].label}
99
+ </Badge>
100
+ {aligned?.differs && (
101
+ <Badge color="orange" variant="light" tt="none" data-steps-differ>
102
+ Recorded steps differ
103
+ </Badge>
104
+ )}
105
+ </Group>
106
+ {promise.reasons.length > 0 && (
107
+ <List size="sm" c="dimmed" aria-label="About the promise">
108
+ {promise.reasons.map((reason) => (
109
+ <List.Item key={reason}>{reason}</List.Item>
110
+ ))}
111
+ </List>
112
+ )}
113
+ <Pair label="promise">
114
+ {(side) => {
115
+ const result = item[side];
116
+ const run = comparison[side];
117
+ const stepsOfSide = steps[side];
118
+ if (!result) return <Missing side={side} item={item} />;
119
+ return (
120
+ <>
121
+ <Text component="div" fw={600}>
122
+ {side === 'baseline' ? promise.baselineTitle : promise.candidateTitle}
123
+ </Text>
124
+ <Text component="div" size="sm" c="dimmed">
125
+ Spec revision <Code>{shortRevision(result.specRevision)}</Code>
126
+ {result.specPath && (
127
+ <>
128
+ {' '}
129
+ in <Code>{result.specPath}</Code>
130
+ </>
131
+ )}
132
+ </Text>
133
+ <Provenance result={result} run={run} />
134
+ {stepsOfSide === null ? (
135
+ <Text role="status" component="div" c="dimmed" size="sm">
136
+ Reading the recorded steps…
137
+ </Text>
138
+ ) : stepsOfSide.length === 0 ? (
139
+ <Text component="div" c="dimmed" size="sm">
140
+ This result recorded no steps.
141
+ </Text>
142
+ ) : (
143
+ <List type="ordered" spacing="xs" aria-label={`${SIDE_LABEL[side]} steps`}>
144
+ {stepsOfSide.map((step, index) => (
145
+ <List.Item key={`${step.name}:${index}`}>
146
+ {step.name}
147
+ {aligned?.differs && only(side, step.name) && (
148
+ <Badge ml="xs" size="sm" variant="light" color="orange" tt="none">
149
+ Only {side}
150
+ </Badge>
151
+ )}
152
+ </List.Item>
153
+ ))}
154
+ </List>
155
+ )}
156
+ </>
157
+ );
158
+ }}
159
+ </Pair>
160
+ </Stack>
161
+ );
162
+ }
163
+
164
+ function OutcomeTab({ item, comparison, onClose }: { item: ComparedResult; comparison: RunComparison; onClose: () => void }) {
165
+ return (
166
+ <Stack gap="md">
167
+ <Pair label="outcome">
168
+ {(side) => {
169
+ const result = item[side];
170
+ if (!result) return <Missing side={side} item={item} />;
171
+ return (
172
+ <>
173
+ <Group gap="xs" data-outcome={result.status}>
174
+ <StatusMark status={result.status} size={14} />
175
+ <Text span inherit fw={600}>
176
+ {statusWord(result.status)}
177
+ </Text>
178
+ </Group>
179
+ <Provenance result={result} run={comparison[side]} />
180
+ <Text component="div" size="sm">
181
+ Test execution time {duration(result.durationMs) || 'not measured'} <Text span inherit c="dimmed">(setup, waits and pacing included; not product latency)</Text>
182
+ </Text>
183
+ {result.status === 'failed' && result.detail && (
184
+ <Alert color="red" title="Why it failed" role="status">
185
+ <Text component="div" lineClamp={6}>
186
+ {result.detail}
187
+ </Text>
188
+ </Alert>
189
+ )}
190
+ <Anchor
191
+ renderRoot={(props) => (
192
+ <Link {...props} to="/" search={patched({ tab: undefined, pair: undefined, test: result.key, layer: result.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
193
+ )}
194
+ >
195
+ Open this {side} result
196
+ </Anchor>
197
+ </>
198
+ );
199
+ }}
200
+ </Pair>
201
+ </Stack>
202
+ );
203
+ }
204
+
205
+ function RecordingSide({
206
+ side,
207
+ item,
208
+ entry,
209
+ detail,
210
+ video,
211
+ positions
212
+ }: {
213
+ side: Side;
214
+ item: ComparedResult;
215
+ entry: PlaylistEntry | null;
216
+ detail: ReturnType<typeof useEntryDetail>;
217
+ video: React.RefObject<HTMLVideoElement | null>;
218
+ positions: Map<string, number>;
219
+ }) {
220
+ const result = item[side];
221
+ const chosen = entry ? playableVideo(entry) : undefined;
222
+ const state = entry ? recordingState(entry) : 'none';
223
+ const movie = useMemo(() => {
224
+ const videos = (detail.data ? everyAttachment(detail.data.check) : []).filter(isVideoAttachment);
225
+ return videos.find((attachment) => attachmentIdentity(attachment) === chosen?.recording) ?? videos[0];
226
+ }, [detail.data, chosen]);
227
+ if (!result || !entry) return <Missing side={side} item={item} />;
228
+ const url = movie ? attachmentSource(movie, entry.layer).url : null;
229
+ let body;
230
+ if (state !== 'playable') {
231
+ body = (
232
+ <Alert color="orange" title={RECORDING_STATE_TEXT[state].label} role="status" data-recording-state={state}>
233
+ {RECORDING_STATE_TEXT[state].meaning}
234
+ </Alert>
235
+ );
236
+ } else if (detail.isPending && detail.fetchStatus !== 'idle') {
237
+ body = (
238
+ <Text role="status" component="div" c="dimmed">
239
+ Loading the recording…
240
+ </Text>
241
+ );
242
+ } else if (detail.isError || !url) {
243
+ body = (
244
+ <Alert color="orange" role="alert" title="This recording cannot be played">
245
+ <Stack gap="xs" align="flex-start">
246
+ <Text component="div">{detail.isError ? 'Its evidence could not be read.' : 'The result names a recording, but no file can be played from here.'}</Text>
247
+ {detail.isError && (
248
+ <Button type="button" variant="light" onClick={() => void detail.refetch()}>
249
+ Try again
250
+ </Button>
251
+ )}
252
+ </Stack>
253
+ </Alert>
254
+ );
255
+ } else {
256
+ body = (
257
+ <Stack gap="xs">
258
+ <ScopeNotice scope={chosen?.scope} name={movie!.name} />
259
+ <Box
260
+ component="video"
261
+ ref={video}
262
+ src={url}
263
+ controls
264
+ preload="metadata"
265
+ aria-label={`${SIDE_LABEL[side]} recording: ${movie!.description ?? movie!.name}`}
266
+ w="100%"
267
+ mah="50vh"
268
+ data-compare-video={side}
269
+ onLoadedMetadata={() => {
270
+ const saved = positions.get(side);
271
+ if (saved !== undefined && video.current && saved < (video.current.duration || Number.POSITIVE_INFINITY)) video.current.currentTime = saved;
272
+ }}
273
+ onTimeUpdate={() => video.current && positions.set(side, video.current.currentTime)}
274
+ />
275
+ </Stack>
276
+ );
277
+ }
278
+ return (
279
+ <>
280
+ {body}
281
+ <Text component="div" size="sm" c="dimmed">
282
+ {chosen?.owner === 'attempt' && chosen.attempt !== undefined ? `Try ${chosen.attempt + 1}` : 'Whole test'} · spec revision {shortRevision(result.specRevision)} · recorded {exactTime(result.at)}. Time 0 is the start of this
283
+ recording.
284
+ </Text>
285
+ </>
286
+ );
287
+ }
288
+
289
+ function RecordingTab({
290
+ item,
291
+ entries,
292
+ details,
293
+ steps
294
+ }: {
295
+ item: ComparedResult;
296
+ entries: Record<Side, PlaylistEntry | null>;
297
+ details: Record<Side, ReturnType<typeof useEntryDetail>>;
298
+ steps: Record<Side, readonly { name: string; offsetMs?: number | undefined }[] | null>;
299
+ }) {
300
+ const videos = { baseline: useRef<HTMLVideoElement>(null), candidate: useRef<HTMLVideoElement>(null) };
301
+ const positions = useRef(new Map<string, number>());
302
+ const [step, setStep] = useState('');
303
+ const alignment = steps.baseline && steps.candidate ? alignSteps(steps.baseline, steps.candidate) : null;
304
+ const both = entries.baseline && entries.candidate && recordingState(entries.baseline) === 'playable' && recordingState(entries.candidate) === 'playable';
305
+ const each = (action: (element: HTMLVideoElement, side: Side) => void) => (['baseline', 'candidate'] as const).forEach((side) => videos[side].current && action(videos[side].current!, side));
306
+ return (
307
+ <Stack gap="md">
308
+ <Pair label="recording">
309
+ {(side) => <RecordingSide side={side} item={item} entry={entries[side]} detail={details[side]} video={videos[side]} positions={positions.current} />}
310
+ </Pair>
311
+ <Stack gap="xs" component="section" aria-label="Aligning the recordings">
312
+ <Title order={4}>Aligning the recordings</Title>
313
+ <Text component="div" size="sm" c="dimmed" data-alignment={alignment?.state ?? 'pending'}>
314
+ {alignment ? alignment.explanation : 'Reading the recorded steps…'}
315
+ </Text>
316
+ {both && alignment && (
317
+ <Group align="flex-end" gap="md" wrap="wrap">
318
+ <Button type="button" variant="default" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => each((element) => void (element.play() as Promise<void> | undefined)?.catch(() => undefined))}>
319
+ Play both
320
+ </Button>
321
+ <Button type="button" variant="default" leftSection={<IconPlayerPause size={16} aria-hidden="true" />} onClick={() => each((element) => element.pause())}>
322
+ Pause both
323
+ </Button>
324
+ {alignment.seekable.length > 0 && (
325
+ <NativeSelect
326
+ label="Go to a step in both recordings"
327
+ description="Each recording moves to its own recorded time of the step."
328
+ value={step}
329
+ onChange={(event) => {
330
+ const chosen = alignment.seekable.find((candidate) => candidate.name === event.currentTarget.value);
331
+ setStep(event.currentTarget.value);
332
+ if (chosen) each((element, side) => void (element.currentTime = (side === 'baseline' ? chosen.baselineOffsetMs : chosen.candidateOffsetMs) / 1000));
333
+ }}
334
+ >
335
+ <option value="">Choose a step</option>
336
+ {alignment.seekable.map((candidate, index) => (
337
+ <option key={`${candidate.name}:${index}`} value={candidate.name}>
338
+ {candidate.name}
339
+ </option>
340
+ ))}
341
+ </NativeSelect>
342
+ )}
343
+ </Group>
344
+ )}
345
+ </Stack>
346
+ <Text component="div" c="dimmed" size="sm">
347
+ Illustrative recordings of one run each. Their length and speed are those of the recordings, not production latency, and neither approves the behavior for production.
348
+ </Text>
349
+ </Stack>
350
+ );
351
+ }
352
+
353
+ const sign = (value: number) => (value > 0 ? '+' : value < 0 ? '−' : '');
354
+
355
+ function Metric({ metric }: { metric: MetricComparison }) {
356
+ if ('summary' in metric) {
357
+ return (
358
+ <Alert color="gray" title={`${metric.name}: only the ${metric.state === 'baseline-only' ? 'baseline' : 'candidate'} measured it`} role="status" data-metric={metric.state}>
359
+ {metric.reasons.join(' ')} Nothing is compared.
360
+ </Alert>
361
+ );
362
+ }
363
+ const unit = metric.baseline.metric.unit;
364
+ const figure = (summary: typeof metric.baseline) => (summary.p50 === undefined ? `${formatLatency(summary.last, unit)} newest of ${plural(summary.samples, 'observation', 'observations')}` : `p50 ${formatLatency(summary.p50, unit)} of ${plural(summary.samples, 'observation', 'observations')}`);
365
+ const delta = metric.state === 'compared' ? (metric.change.p50 ?? metric.change.p95 ?? metric.change.last) : undefined;
366
+ return (
367
+ <Stack gap="xs" role="group" aria-label={`Product latency: ${metric.name}`} data-metric={metric.state}>
368
+ <Text fw={600}>{metric.name}</Text>
369
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>
370
+ <Text component="div" size="sm">Baseline: {figure(metric.baseline)}</Text>
371
+ <Text component="div" size="sm">Candidate: {figure(metric.candidate)}</Text>
372
+ </SimpleGrid>
373
+ {metric.state === 'compared' && delta ? (
374
+ <Text component="div" size="sm" fw={600}>
375
+ Change {sign(delta.absolute)}
376
+ {formatLatency(Math.abs(delta.absolute), unit)} {delta.relative === null ? '' : `(${sign(delta.relative)}${Math.abs(Math.round(delta.relative * 1000) / 10)}%) `}
377
+ {metric.change.basis === 'last' ? 'in the newest observation, not a percentile' : `in ${metric.change.basis}`}
378
+ </Text>
379
+ ) : (
380
+ <Alert color="orange" title="Not compared" role="status">
381
+ No change is computed. {metric.reasons.join(' ')}
382
+ </Alert>
383
+ )}
384
+ {metric.state === 'compared' && metric.reasons.length > 0 && (
385
+ <List size="sm" c="dimmed">
386
+ {metric.reasons.map((reason) => (
387
+ <List.Item key={reason}>{reason}</List.Item>
388
+ ))}
389
+ </List>
390
+ )}
391
+ </Stack>
392
+ );
393
+ }
394
+
395
+ /** The entries of the test's history that one side's result was recorded as: its run, and the condition it was recorded under. */
396
+ function recordedBy(history: readonly TestResultEntry[], result: RunResult | null, run: RunSummary): TestResultEntry[] {
397
+ if (!result) return [];
398
+ const wanted = conditionId(conditionOf(result));
399
+ return history.filter((entry) => {
400
+ if (entry.run !== run.key) return false;
401
+ const found = conditionOf({ kind: result.kind === undefined ? undefined : entry.kind, producer: entry.producer, environment: entry.environment, parameters: entry.parameters });
402
+ return conditionId(found) === wanted;
403
+ });
404
+ }
405
+
406
+ function Rate({ rate, sample }: { rate: { executions: number; passed: number; rate: number | null }; sample: string }) {
407
+ if (rate.rate === null) return <Text span inherit>No completed execution: unknown</Text>;
408
+ return (
409
+ <>
410
+ {Math.round(rate.rate * 1000) / 10}% of {plural(rate.executions, 'independent execution', 'independent executions')}
411
+ {sample !== 'sufficient' && (
412
+ <Badge ml="xs" color="orange" variant="light" tt="none">
413
+ Insufficient evidence
414
+ </Badge>
415
+ )}
416
+ </>
417
+ );
418
+ }
419
+
420
+ function FiguresTab({ item, comparison }: { item: ComparedResult; comparison: RunComparison }) {
421
+ const history = useTestHistory(item.key);
422
+ const entries = useMemo(() => history.data?.history ?? [], [history.data]);
423
+ const { baseline, candidate } = item;
424
+ const figures = useMemo(() => {
425
+ if (!baseline || !candidate || !history.data) return null;
426
+ const left = recordedBy(entries, baseline, comparison.baseline);
427
+ const right = recordedBy(entries, candidate, comparison.candidate);
428
+ const reliable = (result: RunResult, run: RunSummary) => reliability(item.key, entries, reliabilityScopeAt(result, run));
429
+ const base = reliable(baseline, comparison.baseline);
430
+ const cand = reliable(candidate, comparison.candidate);
431
+ return {
432
+ left,
433
+ right,
434
+ latency: compareProductLatency(left, right, item.conditions),
435
+ base,
436
+ cand,
437
+ reliability: compareReliability(base, cand, item.conditions)
438
+ };
439
+ }, [entries, baseline, candidate, comparison, item, history.data]);
440
+ if (!baseline || !candidate)
441
+ return (
442
+ <Text component="div" c="dimmed" role="status">
443
+ Only one run recorded this behavior, so there is nothing to set its figures against. Nothing is computed.
444
+ </Text>
445
+ );
446
+ const sameConditions = item.conditions.length === 0;
447
+ const timeDelta = baseline.durationMs !== null && candidate.durationMs !== null ? candidate.durationMs - baseline.durationMs : null;
448
+ return (
449
+ <Stack gap="lg">
450
+ <Stack gap="xs" component="section" aria-label="Test execution time">
451
+ <Title order={4}>Test execution time</Title>
452
+ <Text component="div" size="sm">
453
+ Baseline {duration(baseline.durationMs) || 'not measured'} · Candidate {duration(candidate.durationMs) || 'not measured'}.{' '}
454
+ {sameConditions && timeDelta !== null
455
+ ? `Change ${sign(timeDelta)}${formatMs(Math.abs(timeDelta))}. `
456
+ : `No change is stated: ${!sameConditions ? 'the results were recorded under different conditions' : 'one side did not measure it'}. `}
457
+ <Text span inherit c="dimmed">
458
+ This is how long the test took, including setup, waits and pacing. It is not product latency.
459
+ </Text>
460
+ </Text>
461
+ </Stack>
462
+ {history.isPending && (
463
+ <Text role="status" component="div" c="dimmed">
464
+ Reading this behavior's history…
465
+ </Text>
466
+ )}
467
+ {history.isError && (
468
+ <Alert color="red" role="alert">
469
+ Its history could not be read. {history.error.message}
470
+ </Alert>
471
+ )}
472
+ {history.isSuccess && !history.data && (
473
+ <Alert color="blue" role="status">
474
+ The history holds no record of this behavior, so product latency and reliability are unknown.
475
+ </Alert>
476
+ )}
477
+ {figures && (
478
+ <>
479
+ {(figures.left.length === 0 || figures.right.length === 0) && (
480
+ <Alert color="blue" role="status">
481
+ The history no longer holds this run's record for {figures.left.length === 0 ? 'the baseline' : 'the candidate'} (only the newest {HISTORY_PER_TEST} results of a test are kept), so its product latency is unknown.
482
+ </Alert>
483
+ )}
484
+ <Stack gap="sm" component="section" aria-label="Product latency">
485
+ <Title order={4}>Product latency</Title>
486
+ {figures.latency.length === 0 ? (
487
+ <Text component="div" c="dimmed" role="status">
488
+ Neither run recorded product latency for this behavior, so it is unknown, not fast. Nothing is compared.
489
+ </Text>
490
+ ) : (
491
+ figures.latency.map((metric) => <Metric key={`${metric.name}:${metric.state}`} metric={metric} />)
492
+ )}
493
+ </Stack>
494
+ <Stack gap="xs" component="section" aria-label="Reliability">
495
+ <Title order={4}>Reliability up to each run</Title>
496
+ <Text component="div" size="sm" c="dimmed">
497
+ Independent executions of this check in the same environment, producer and parameters, against the spec revision each result was recorded at, up to the end of each run.
498
+ </Text>
499
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>
500
+ <Text component="div">
501
+ Baseline: <Rate rate={figures.base.eventual} sample={figures.base.sample} />
502
+ </Text>
503
+ <Text component="div">
504
+ Candidate: <Rate rate={figures.cand.eventual} sample={figures.cand.sample} />
505
+ </Text>
506
+ </SimpleGrid>
507
+ {figures.reliability.comparable ? (
508
+ <Text component="div" fw={600} data-reliability={figures.reliability.direction}>
509
+ {figures.reliability.direction === 'indistinguishable'
510
+ ? 'The two figures cannot be told apart.'
511
+ : `The candidate figure is ${figures.reliability.direction} by ${Math.abs(figures.reliability.points ?? 0)} points, beyond the uncertainty of both.`}
512
+ </Text>
513
+ ) : (
514
+ <Alert color="orange" title="Not compared" role="status" data-reliability="not-comparable">
515
+ No change is computed. {figures.reliability.reasons.join(' ')}
516
+ </Alert>
517
+ )}
518
+ {figures.reliability.comparable && figures.reliability.reasons.length > 0 && (
519
+ <Text component="div" size="sm" c="dimmed">
520
+ {figures.reliability.reasons.join(' ')}
521
+ </Text>
522
+ )}
523
+ </Stack>
524
+ </>
525
+ )}
526
+ </Stack>
527
+ );
528
+ }
529
+
530
+ /**
531
+ * The detail of one compared behavior: promise, outcomes, recordings and figures for the baseline and the candidate.
532
+ * `layers` say which backend each side's layers are read from; `onClose` is called when a link leaves the comparison.
533
+ */
534
+ export function ComparePair({
535
+ item,
536
+ comparison,
537
+ layers,
538
+ onClose
539
+ }: {
540
+ item: ComparedResult;
541
+ comparison: RunComparison;
542
+ layers: Record<Side, readonly RunLayer[] | undefined>;
543
+ onClose: () => void;
544
+ }) {
545
+ const [tab, setTab] = useState<string>('promise');
546
+ const entries = { baseline: entryOf(item.baseline, layers.baseline), candidate: entryOf(item.candidate, layers.candidate) };
547
+ const empty = { ...(entries.baseline ?? entries.candidate!), backend: null } as PlaylistEntry;
548
+ const details = {
549
+ baseline: useEntryDetail(entries.baseline ?? empty, entries.baseline !== null),
550
+ candidate: useEntryDetail(entries.candidate ?? empty, entries.candidate !== null)
551
+ };
552
+ // Steps are null while they are being read; a result whose evidence cannot be read has none to show.
553
+ const stepsOf = (detail: ReturnType<typeof useEntryDetail>) => (detail.data ? (detail.data.check.steps ?? []) : detail.isPending && detail.fetchStatus !== 'idle' ? null : []);
554
+ const steps = { baseline: entries.baseline ? stepsOf(details.baseline) : [], candidate: entries.candidate ? stepsOf(details.candidate) : [] };
555
+ const group = COMPARE_GROUP_TEXT[item.group];
556
+ return (
557
+ <Stack gap="md" aria-label={`Compare ${item.title}`} component="section">
558
+ <Title order={3}>{item.title}</Title>
559
+ <Group gap="xs">
560
+ <Badge variant="light" tt="none" data-group={item.group}>
561
+ {group.label}
562
+ </Badge>
563
+ {item.specPath && <Code>{item.specPath}</Code>}
564
+ </Group>
565
+ <Text component="div" data-pair-summary>
566
+ {item.summary}
567
+ </Text>
568
+ {item.caveats.length > 0 && (
569
+ <List size="sm" c="dimmed" aria-label="Before reading this change">
570
+ {item.caveats.map((caveat) => (
571
+ <List.Item key={caveat}>{caveat}</List.Item>
572
+ ))}
573
+ </List>
574
+ )}
575
+ <Tabs value={tab} onChange={(value) => setTab(value ?? 'promise')} keepMounted={false}>
576
+ <Tabs.List aria-label="What to compare">
577
+ <Tabs.Tab value="promise">Promise</Tabs.Tab>
578
+ <Tabs.Tab value="outcome">Outcomes</Tabs.Tab>
579
+ <Tabs.Tab value="recording">Recordings</Tabs.Tab>
580
+ <Tabs.Tab value="figures">Figures</Tabs.Tab>
581
+ </Tabs.List>
582
+ <Tabs.Panel value="promise" pt="md">
583
+ <PromiseTab item={item} comparison={comparison} steps={steps} />
584
+ </Tabs.Panel>
585
+ <Tabs.Panel value="outcome" pt="md">
586
+ <OutcomeTab item={item} comparison={comparison} onClose={onClose} />
587
+ </Tabs.Panel>
588
+ <Tabs.Panel value="recording" pt="md">
589
+ <RecordingTab item={item} entries={entries} details={details} steps={steps} />
590
+ </Tabs.Panel>
591
+ <Tabs.Panel value="figures" pt="md">
592
+ <FiguresTab item={item} comparison={comparison} />
593
+ </Tabs.Panel>
594
+ </Tabs>
595
+ </Stack>
596
+ );
597
+ }