@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,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,7 @@ 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';
61
62
  import { CollectionsView } from './collections';
62
63
  import { RunsView } from './RunsView';
63
64
  import { SearchNotes, TypedFilters } from './search-notes';
@@ -169,7 +170,7 @@ export function Explorer() {
169
170
  const model = catalog.model;
170
171
  const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
171
172
  const empty = model !== null && model.tests.length === 0;
172
- const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections';
173
+ const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts' && search.tab !== 'collections' && search.tab !== 'compare';
173
174
  return (
174
175
  <Container size="xl" py="xl">
175
176
  <Box aria-label="Test explorer" component="article" miw={0}>
@@ -180,7 +181,7 @@ export function Explorer() {
180
181
  {model && (model.tests.length > 0 || model.summary.runs > 0) && (
181
182
  <Tabs
182
183
  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 })}
184
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
184
185
  >
185
186
  <Tabs.List aria-label="Explorer view">
186
187
  <Tabs.Tab
@@ -204,6 +205,7 @@ export function Explorer() {
204
205
  Runs
205
206
  </Tabs.Tab>
206
207
  <Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
208
+ <Tabs.Tab value="compare">Compare</Tabs.Tab>
207
209
  {!staticEvidence && <Tabs.Tab value="collections">Collections</Tabs.Tab>}
208
210
  </Tabs.List>
209
211
  </Tabs>
@@ -269,6 +271,8 @@ export function Explorer() {
269
271
  <RunsView />
270
272
  ) : search.tab === 'artifacts' ? (
271
273
  <ArtifactsView />
274
+ ) : search.tab === 'compare' ? (
275
+ <CompareView />
272
276
  ) : search.tab === 'collections' ? (
273
277
  <CollectionsView />
274
278
  ) : empty ? null : (
@@ -593,6 +593,16 @@ function RunDetailView({ runKey }: { runKey: string }) {
593
593
  Open the CI run
594
594
  </Anchor>
595
595
  )}
596
+ {(['base', 'cand'] as const).map((side) => (
597
+ <Anchor
598
+ key={side}
599
+ renderRoot={(props) => (
600
+ <Link {...props} to="/" search={patched({ tab: 'compare', [side]: run.key, openRun: undefined, layer: undefined, artifact: undefined, pair: undefined, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
601
+ )}
602
+ >
603
+ {side === 'base' ? 'Compare with this run as the baseline' : 'Compare with this run as the candidate'}
604
+ </Anchor>
605
+ ))}
596
606
  </Flex>
597
607
  <Flex gap="xs" align="center" wrap="wrap">
598
608
  <SearchBox
@@ -59,7 +59,7 @@ export function entryDetailQuery(entry: PlaylistEntry) {
59
59
  }
60
60
 
61
61
  /** The evidence of one entry's check, read when the entry is on screen. */
62
- function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
62
+ export function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
63
63
  const query = entryDetailQuery(entry);
64
64
  return useQuery({
65
65
  queryKey: query?.queryKey ?? ['evidence-detail', '', entry.layer, entry.kind ?? '', entry.check],
@@ -2,7 +2,7 @@
2
2
  * @module Explorer address
3
3
  *
4
4
  * Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
5
- * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
5
+ * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
6
6
  * take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
7
7
  * or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
8
8
  *
@@ -63,10 +63,18 @@ export const ExplorerSearchFields = {
63
63
  inRun: text,
64
64
  group: z.enum(TEST_GROUPS).optional().catch(undefined),
65
65
  sort: z.enum(EXPLORER_SORTS).optional().catch(undefined),
66
- tab: z.enum(['runs', 'artifacts', 'collections']).optional().catch(undefined),
66
+ tab: z.enum(['runs', 'artifacts', 'collections', 'compare']).optional().catch(undefined),
67
67
  /** The review collection opened in the Collections tab (its stable id), and the revision of it (default: the newest). */
68
68
  collection: text,
69
69
  rev: z.coerce.number().int().positive().optional().catch(undefined),
70
+ /** The baseline run of the Compare tab (a `RunSummary.key`). Pinned: a newer run never replaces it. */
71
+ base: text,
72
+ /** The candidate run of the Compare tab (a `RunSummary.key`). Pinned like `base`. */
73
+ cand: text,
74
+ /** The behavior opened in the Compare tab (a `ComparedResult.id`). */
75
+ pair: text,
76
+ /** The group of changes the Compare tab lists (a `CompareGroup`). Absent: every change. */
77
+ change: text,
70
78
  /** The run opened in the Runs tab. */
71
79
  openRun: text,
72
80
  /** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. With `openRun` and `artifact` it names the layer the file is in. */
@@ -119,9 +127,13 @@ export type ExplorerSearch = {
119
127
  readonly inRun?: string;
120
128
  readonly group?: TestGroup;
121
129
  readonly sort?: ExplorerSort;
122
- readonly tab?: 'runs' | 'artifacts' | 'collections';
130
+ readonly tab?: 'runs' | 'artifacts' | 'collections' | 'compare';
123
131
  readonly collection?: string;
124
132
  readonly rev?: number;
133
+ readonly base?: string;
134
+ readonly cand?: string;
135
+ readonly pair?: string;
136
+ readonly change?: string;
125
137
  readonly openRun?: string;
126
138
  readonly layer?: string;
127
139
  readonly clip?: string;