@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,340 @@
1
+ /**
2
+ * @module Compare view
3
+ *
4
+ * The Compare tab: two pinned runs, a baseline and a candidate, set side by side. Both are named in the address (`base`
5
+ * and `cand`), so a link reproduces the comparison and a newer run never replaces either; a pinned run that is no longer
6
+ * in the report is said to be missing. Behaviors are paired by the check they record, never by title, and each pair is
7
+ * in one group (newly failing, recovered, still failing, promise or conditions differ, not run, not in one run, same
8
+ * outcome), so a result that did not run is unknown rather than regressed or passed. Opening a behavior shows its promise,
9
+ * outcomes, recordings and figures (`ComparePair`).
10
+ */
11
+ import { COMPARE_GROUP_TEXT, COMPARE_GROUPS, compareRuns, type CompareGroup, type ComparedResult } from '@crvouga/atlas/reports/run-compare';
12
+ import { formatMs, type RunDetail, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
13
+ import { Alert, Anchor, Badge, Box, Button, Code, Drawer, Flex, Group, List, NativeSelect, Paper, SimpleGrid, Stack, Table, Text, Title, VisuallyHidden } from '@mantine/core';
14
+ import { IconArrowsExchange } from '@tabler/icons-react';
15
+ import { Link } from '@tanstack/react-router';
16
+ import { useMemo, useState } from 'react';
17
+
18
+ import { ago, duration, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
19
+ import { StatusMark } from '../evidence/marks';
20
+ import { useRunDetail, useRunList } from './catalog';
21
+ import { ComparePair } from './ComparePair';
22
+ import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
23
+ import { runName } from './parts';
24
+
25
+ const ROWS = 100;
26
+
27
+ const GROUP_COLOR: Record<CompareGroup, string> = {
28
+ regressed: 'red',
29
+ recovered: 'green',
30
+ 'still-failing': 'red',
31
+ 'not-comparable': 'orange',
32
+ unknown: 'gray',
33
+ removed: 'gray',
34
+ added: 'blue',
35
+ unchanged: 'gray'
36
+ };
37
+
38
+ /** What a run is called: the title its producer gave it, else its CI run number or `branch@sha`. */
39
+ function nameOf(run: RunSummary): string {
40
+ return run.title ?? runName(run.key);
41
+ }
42
+
43
+ function RunPicker({ label, value, runs, onChange }: { label: string; value: string | undefined; runs: readonly RunSummary[]; onChange: (key: string | undefined) => void }) {
44
+ const now = useNow();
45
+ const known = runs.some((run) => run.key === value);
46
+ return (
47
+ <NativeSelect
48
+ label={label}
49
+ value={value ?? ''}
50
+ onChange={(event) => onChange(event.currentTarget.value || undefined)}
51
+ data-compare-picker={label}
52
+ >
53
+ <option value="">Choose a run</option>
54
+ {value && !known && <option value={value}>{runName(value)} (not among the newest runs listed)</option>}
55
+ {runs.map((run) => (
56
+ <option key={run.key} value={run.key}>
57
+ {[nameOf(run), run.branch, shortSha(run.sha), ago(run.endedAtMs, now)].filter(Boolean).join(' · ')}
58
+ </option>
59
+ ))}
60
+ </NativeSelect>
61
+ );
62
+ }
63
+
64
+ function RunCard({ role, run }: { role: string; run: RunSummary }) {
65
+ return (
66
+ <Paper withBorder p="md" component="section" aria-label={`${role} run`} data-compare-run={role.toLowerCase()}>
67
+ <Stack gap="xs">
68
+ <Title order={3}>
69
+ {role}: {nameOf(run)}
70
+ </Title>
71
+ <Text component="div" size="sm">
72
+ {[run.branch, run.sha ? shortSha(run.sha) : null].filter(Boolean).join(' @ ') || 'No branch or commit recorded'}
73
+ {run.freshness && (
74
+ <Text span inherit c="dimmed">
75
+ {' '}
76
+ {run.freshness === 'modified' ? 'with local changes' : 'origin not captured'}
77
+ </Text>
78
+ )}
79
+ </Text>
80
+ <Text component="div" size="sm" c="dimmed" title={exactTime(run.endedAtMs)}>
81
+ Ended {exactTime(run.endedAtMs)} · took {formatMs(run.spanMs)} · {plural(run.layers, 'layer', 'layers')}
82
+ </Text>
83
+ <Text component="div" size="sm">
84
+ {plural(run.tests, 'test', 'tests')}: {run.counts.passed} passed, {run.counts.failed} failed, {run.counts['not-run']} not run
85
+ {run.counts.running > 0 ? `, ${run.counts.running} running` : ''}
86
+ </Text>
87
+ <Anchor renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: 'runs', openRun: run.key, pair: undefined })} resetScroll={false} activeOptions={{ exact: true }} />}>
88
+ Open this run
89
+ </Anchor>
90
+ </Stack>
91
+ </Paper>
92
+ );
93
+ }
94
+
95
+ function Outcome({ result }: { result: ComparedResult['baseline'] }) {
96
+ if (!result)
97
+ return (
98
+ <Text span inherit c="dimmed" data-outcome="none">
99
+ No result
100
+ </Text>
101
+ );
102
+ return (
103
+ <Group gap="xs" wrap="nowrap" data-outcome={result.status}>
104
+ <StatusMark status={result.status} size={14} />
105
+ <Text span inherit>
106
+ {statusWord(result.status)}
107
+ </Text>
108
+ <Text span inherit c="dimmed" size="xs">
109
+ {duration(result.durationMs)}
110
+ </Text>
111
+ </Group>
112
+ );
113
+ }
114
+
115
+ function Row({ item }: { item: ComparedResult }) {
116
+ return (
117
+ <Table.Tr data-group={item.group}>
118
+ <Table.Td>
119
+ <Text component="div">{item.title}</Text>
120
+ {item.specPath && (
121
+ <Text component="div" size="xs" c="dimmed">
122
+ <Code>{item.specPath}</Code>
123
+ </Text>
124
+ )}
125
+ {item.conditions.length > 0 && (
126
+ <Badge variant="light" color="orange" tt="none" mt="xs" title={item.conditions.join('; ')}>
127
+ Conditions differ
128
+ </Badge>
129
+ )}
130
+ {item.promise.state === 'changed' && (
131
+ <Badge variant="light" color="orange" tt="none" mt="xs">
132
+ Promise changed
133
+ </Badge>
134
+ )}
135
+ </Table.Td>
136
+ <Table.Td>
137
+ <Outcome result={item.baseline} />
138
+ </Table.Td>
139
+ <Table.Td>
140
+ <Outcome result={item.candidate} />
141
+ </Table.Td>
142
+ <Table.Td>
143
+ <Badge color={GROUP_COLOR[item.group]} variant="light" tt="none">
144
+ {COMPARE_GROUP_TEXT[item.group].label}
145
+ </Badge>
146
+ <Text component="div" size="xs" c="dimmed" mt="xs">
147
+ {item.summary}
148
+ </Text>
149
+ </Table.Td>
150
+ <Table.Td>
151
+ <Button
152
+ variant="light"
153
+ aria-label={`Compare ${item.title}`}
154
+ renderRoot={(props) => <Link {...props} to="/" search={patched({ pair: item.id })} resetScroll={false} activeOptions={{ exact: true }} />}
155
+ >
156
+ Compare
157
+ </Button>
158
+ </Table.Td>
159
+ </Table.Tr>
160
+ );
161
+ }
162
+
163
+ function Missing({ side, keyed, onRetry }: { side: string; keyed: string; onRetry?: () => void }) {
164
+ return (
165
+ <Alert color="blue" role="status" title={`The ${side} run is not in the report`} data-compare-missing={side}>
166
+ <Stack gap="xs" align="flex-start">
167
+ <Text component="div">
168
+ <Code>{keyed}</Code> is pinned in the address, but the report holds no run with that key any more. Its source may be switched off or have expired. It is not replaced by another run.
169
+ </Text>
170
+ {onRetry && (
171
+ <Button type="button" variant="light" onClick={onRetry}>
172
+ Try again
173
+ </Button>
174
+ )}
175
+ </Stack>
176
+ </Alert>
177
+ );
178
+ }
179
+
180
+ function Comparison({ baseline, candidate }: { baseline: RunDetail; candidate: RunDetail }) {
181
+ const search = useExplorerSearch();
182
+ const go = useExplorerNavigate();
183
+ const comparison = useMemo(() => compareRuns(baseline, candidate), [baseline, candidate]);
184
+ const [shown, setShown] = useState(ROWS);
185
+ const group = COMPARE_GROUPS.find((name) => name === search.change);
186
+ const listed = group ? comparison.results.filter((item) => item.group === group) : comparison.results;
187
+ const opened = search.pair ? comparison.results.find((item) => item.id === search.pair) : undefined;
188
+ const total = comparison.results.length;
189
+ return (
190
+ <Stack gap="md">
191
+ {comparison.notes.length > 0 && (
192
+ <Alert color="blue" role="status" title="Before reading this comparison">
193
+ <List size="sm">
194
+ {comparison.notes.map((note) => (
195
+ <List.Item key={note}>{note}</List.Item>
196
+ ))}
197
+ </List>
198
+ </Alert>
199
+ )}
200
+ <Text role="status" component="div" data-compare-summary>
201
+ {plural(total, 'behavior', 'behaviors')} across the two runs:{' '}
202
+ {COMPARE_GROUPS.filter((name) => comparison.counts[name] > 0)
203
+ .map((name) => `${comparison.counts[name]} ${COMPARE_GROUP_TEXT[name].label.toLowerCase()}`)
204
+ .join(', ') || 'none'}
205
+ .
206
+ </Text>
207
+ <Flex role="group" aria-label="Kind of change" gap="xs" align="center" wrap="wrap">
208
+ <Button type="button" variant="light" aria-pressed={!group} onClick={() => go({ change: undefined })}>
209
+ All {total}
210
+ </Button>
211
+ {COMPARE_GROUPS.filter((name) => comparison.counts[name] > 0).map((name) => (
212
+ <Button key={name} type="button" variant="light" aria-pressed={group === name} title={COMPARE_GROUP_TEXT[name].meaning} onClick={() => go({ change: group === name ? undefined : name })}>
213
+ {COMPARE_GROUP_TEXT[name].label} {comparison.counts[name]}
214
+ </Button>
215
+ ))}
216
+ </Flex>
217
+ {group && (
218
+ <Text component="div" size="sm" c="dimmed">
219
+ {COMPARE_GROUP_TEXT[group].meaning}
220
+ </Text>
221
+ )}
222
+ {total === 0 ? (
223
+ <Text component="div" c="dimmed">
224
+ Neither run recorded a result, so there is nothing to compare.
225
+ </Text>
226
+ ) : (
227
+ <Table.ScrollContainer minWidth={720}>
228
+ <Table data-compare-results>
229
+ <Table.Thead>
230
+ <Table.Tr>
231
+ <Table.Th scope="col">Behavior</Table.Th>
232
+ <Table.Th scope="col">Baseline</Table.Th>
233
+ <Table.Th scope="col">Candidate</Table.Th>
234
+ <Table.Th scope="col">Change</Table.Th>
235
+ <Table.Th scope="col">
236
+ <VisuallyHidden>Open</VisuallyHidden>
237
+ </Table.Th>
238
+ </Table.Tr>
239
+ </Table.Thead>
240
+ <Table.Tbody>
241
+ {listed.slice(0, shown).map((item) => (
242
+ <Row key={item.id} item={item} />
243
+ ))}
244
+ </Table.Tbody>
245
+ </Table>
246
+ </Table.ScrollContainer>
247
+ )}
248
+ {listed.length > shown && (
249
+ <Button type="button" variant="subtle" onClick={() => setShown(shown + ROWS)}>
250
+ Show more ({listed.length - shown} more)
251
+ </Button>
252
+ )}
253
+ <Drawer opened={!!search.pair} onClose={() => go({ pair: undefined })} title="Compare a behavior" size="xl">
254
+ {opened ? (
255
+ <ComparePair item={opened} comparison={comparison} layers={{ baseline: baseline.layers, candidate: candidate.layers }} onClose={() => go({ pair: undefined })} />
256
+ ) : (
257
+ <Alert color="blue" role="status">
258
+ That behavior is not in the two compared runs.
259
+ </Alert>
260
+ )}
261
+ </Drawer>
262
+ </Stack>
263
+ );
264
+ }
265
+
266
+ /** The Compare tab. */
267
+ export function CompareView() {
268
+ const search = useExplorerSearch();
269
+ const go = useExplorerNavigate();
270
+ const runs = useRunList(true, {});
271
+ const baseline = useRunDetail(search.base);
272
+ const candidate = useRunDetail(search.cand);
273
+ const picked = search.base && search.cand;
274
+ return (
275
+ <Box aria-label="Compare runs" component="section" mb="lg">
276
+ <Title order={2} mb="sm">
277
+ Compare two runs
278
+ </Title>
279
+ <Text component="div" c="dimmed" mb="md">
280
+ Choose a baseline and a candidate run. Behaviors are paired by the check they record, not by title. Both runs are pinned in the address, so a newer run never replaces either, and a link reproduces the comparison.
281
+ </Text>
282
+ <Group align="flex-end" gap="md" wrap="wrap" mb="md">
283
+ <RunPicker label="Baseline run" value={search.base} runs={runs.data?.runs ?? []} onChange={(key) => go({ base: key, pair: undefined, change: undefined })} />
284
+ <RunPicker label="Candidate run" value={search.cand} runs={runs.data?.runs ?? []} onChange={(key) => go({ cand: key, pair: undefined, change: undefined })} />
285
+ <Button
286
+ type="button"
287
+ variant="default"
288
+ leftSection={<IconArrowsExchange size={16} aria-hidden="true" />}
289
+ disabled={!search.base && !search.cand}
290
+ onClick={() => go({ base: search.cand, cand: search.base, pair: undefined, change: undefined })}
291
+ >
292
+ Swap baseline and candidate
293
+ </Button>
294
+ </Group>
295
+ {runs.isError && (
296
+ <Alert role="alert" color="red" my="sm">
297
+ The runs could not be listed. {runs.error.message}
298
+ </Alert>
299
+ )}
300
+ {!picked && (
301
+ <Text component="div" c="dimmed" role="status">
302
+ {runs.isPending ? 'Reading the runs…' : (runs.data?.runs.length ?? 0) < 1 ? 'No runs recorded yet, so there is nothing to compare.' : 'Choose both runs to compare them. You can also open a run in the Runs tab and use it as the baseline or the candidate.'}
303
+ </Text>
304
+ )}
305
+ {picked && (baseline.isPending || candidate.isPending) && (
306
+ <Text role="status" component="div" c="dimmed">
307
+ Reading the two runs…
308
+ </Text>
309
+ )}
310
+ {picked && (baseline.isError || candidate.isError) && (
311
+ <Alert role="alert" color="red" my="sm">
312
+ A run could not be read. {baseline.error?.message ?? candidate.error?.message}
313
+ <Group mt="xs">
314
+ <Button
315
+ type="button"
316
+ variant="light"
317
+ onClick={() => {
318
+ void baseline.refetch();
319
+ void candidate.refetch();
320
+ }}
321
+ >
322
+ Try again
323
+ </Button>
324
+ </Group>
325
+ </Alert>
326
+ )}
327
+ {picked && baseline.isSuccess && baseline.data === null && <Missing side="baseline" keyed={search.base!} />}
328
+ {picked && candidate.isSuccess && candidate.data === null && <Missing side="candidate" keyed={search.cand!} />}
329
+ {picked && baseline.data && candidate.data && (
330
+ <Stack gap="md">
331
+ <SimpleGrid cols={{ base: 1, sm: 2 }}>
332
+ <RunCard role="Baseline" run={baseline.data.run} />
333
+ <RunCard role="Candidate" run={candidate.data.run} />
334
+ </SimpleGrid>
335
+ <Comparison baseline={baseline.data} candidate={candidate.data} />
336
+ </Stack>
337
+ )}
338
+ </Box>
339
+ );
340
+ }
@@ -58,6 +58,8 @@ import { groupTests, tally, type TestGroupView } from './groups';
58
58
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
59
59
  import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
60
60
  import { ArtifactsView } from './ArtifactsView';
61
+ import { CompareView } from './CompareView';
62
+ import { HealthView } from './HealthView';
61
63
  import { CollectionsView } from './collections';
62
64
  import { RunsView } from './RunsView';
63
65
  import { SearchNotes, TypedFilters } from './search-notes';
@@ -169,7 +171,7 @@ export function Explorer() {
169
171
  const model = catalog.model;
170
172
  const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
171
173
  const empty = model !== null && model.tests.length === 0;
172
- const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections';
174
+ const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections' && search.tab !== 'compare' && search.tab !== 'health';
173
175
  return (
174
176
  <Container size="xl" py="xl">
175
177
  <Box aria-label="Test explorer" component="article" miw={0}>
@@ -180,7 +182,7 @@ export function Explorer() {
180
182
  {model && (model.tests.length > 0 || model.summary.runs > 0) && (
181
183
  <Tabs
182
184
  value={search.tab ?? 'tests'}
183
- onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined })}
185
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' || value === 'health' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
184
186
  >
185
187
  <Tabs.List aria-label="Explorer view">
186
188
  <Tabs.Tab
@@ -204,6 +206,8 @@ export function Explorer() {
204
206
  Runs
205
207
  </Tabs.Tab>
206
208
  <Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
209
+ <Tabs.Tab value="compare">Compare</Tabs.Tab>
210
+ <Tabs.Tab value="health">Catalog health</Tabs.Tab>
207
211
  {!staticEvidence && <Tabs.Tab value="collections">Collections</Tabs.Tab>}
208
212
  </Tabs.List>
209
213
  </Tabs>
@@ -241,6 +245,9 @@ export function Explorer() {
241
245
  </List.Item>
242
246
  ))}
243
247
  </List>
248
+ <Anchor mt="xs" display="inline-block" size="sm" renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: 'health', test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />}>
249
+ Why do these counts differ from the Tests?
250
+ </Anchor>
244
251
  </Box>
245
252
  )}
246
253
  {!model && !evidence.unavailable && (
@@ -269,6 +276,10 @@ export function Explorer() {
269
276
  <RunsView />
270
277
  ) : search.tab === 'artifacts' ? (
271
278
  <ArtifactsView />
279
+ ) : search.tab === 'compare' ? (
280
+ <CompareView />
281
+ ) : search.tab === 'health' ? (
282
+ <HealthView />
272
283
  ) : search.tab === 'collections' ? (
273
284
  <CollectionsView />
274
285
  ) : empty ? null : (