@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.
- package/CHANGELOG.md +8 -1
- package/dist/reports/src/evidence-detail.d.ts +19 -0
- package/dist/reports/src/evidence-detail.js +31 -4
- package/dist/reports/src/evidence-types.d.ts +2 -2
- package/dist/reports/src/layer-merge.js +4 -3
- package/dist/reports/src/layer-store.d.ts +1 -1
- package/dist/reports/src/layer-store.js +4 -2
- package/dist/reports/src/layer-types.d.ts +2 -0
- package/dist/reports/src/run-compare.d.ts +182 -0
- package/dist/reports/src/run-compare.js +329 -0
- package/dist/reports/src/test-catalog.d.ts +8 -0
- package/dist/reports/src/test-catalog.js +14 -1
- package/dist/schema/src/api.d.ts +4 -0
- package/dist/schema/src/api.js +7 -3
- package/docs/reference/reports.md +354 -3
- package/docs/ui.md +30 -1
- package/llms-full.txt +30 -1
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +2 -2
- package/packages/schema/schemas/effective-evidence.schema.json +2 -2
- package/packages/schema/schemas/evidence-page.schema.json +1 -1
- package/packages/schema/schemas/layer-rows.schema.json +1 -1
- package/packages/schema/schemas/report-run.schema.json +25 -3
- package/packages/schema/schemas/report-runs.schema.json +1 -1
- package/packages/schema/schemas/review-collection.schema.json +1 -1
- package/packages/schema/schemas/review-manifest.schema.json +1 -1
- package/packages/schema/schemas/test-catalog.schema.json +1 -1
- package/packages/schema/schemas/test-history.schema.json +2 -2
- package/packages/server/openapi.json +39 -17
- package/packages/visualizer/src/explorer/ComparePair.tsx +597 -0
- package/packages/visualizer/src/explorer/CompareView.tsx +340 -0
- package/packages/visualizer/src/explorer/Explorer.tsx +6 -2
- package/packages/visualizer/src/explorer/RunsView.tsx +10 -0
- package/packages/visualizer/src/explorer/playlist.tsx +1 -1
- 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;
|