@crvouga/atlas 0.15.0 → 0.17.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 +12 -0
- package/dist/reports/src/attachment-files.d.ts +3 -1
- package/dist/reports/src/attachment-files.js +14 -4
- package/dist/reports/src/evidence-detail.d.ts +19 -2
- package/dist/reports/src/evidence-detail.js +63 -14
- package/dist/reports/src/evidence-types.d.ts +53 -0
- package/dist/reports/src/layer-merge.js +7 -2
- package/dist/reports/src/layer-types.d.ts +1 -1
- package/dist/reports/src/publish-checks.js +11 -3
- package/dist/reports/src/recording-receipt.d.ts +48 -0
- package/dist/reports/src/recording-receipt.js +103 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +20 -2
- package/dist/reports/src/report-status.d.ts +2 -0
- package/dist/reports/src/report-status.js +17 -1
- package/dist/reports/src/reports-explorer-route.d.ts +8 -0
- package/dist/reports/src/reports-route.d.ts +8 -0
- package/dist/reports/src/test-catalog.d.ts +24 -3
- package/dist/reports/src/test-catalog.js +37 -11
- package/dist/schema/src/api.d.ts +232 -0
- package/dist/schema/src/api.js +35 -6
- package/dist/schema/src/cli-results.d.ts +107 -2
- package/dist/schema/src/cli-results.js +11 -3
- package/dist/schema/src/layer.d.ts +165 -0
- package/dist/schema/src/layer.js +72 -1
- package/dist/server/src/app.js +3 -2
- package/dist/server/src/llms.js +2 -1
- package/dist/server/src/openapi.js +2 -1
- package/docs/reference/cli.md +5 -0
- package/docs/reference/http-api.md +4 -2
- package/docs/reference/reports.md +204 -5
- package/docs/reference/schema.md +75 -0
- package/docs/rich-evidence.md +29 -0
- package/docs/server.md +1 -1
- package/llms-full.txt +39 -3
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +20 -0
- package/packages/schema/schemas/effective-delta.schema.json +143 -0
- package/packages/schema/schemas/effective-evidence.schema.json +143 -0
- package/packages/schema/schemas/evidence-page.schema.json +86 -0
- package/packages/schema/schemas/layer-rows.schema.json +57 -0
- package/packages/schema/schemas/layer.schema.json +153 -0
- package/packages/schema/schemas/publish-checks.schema.json +24 -0
- package/packages/schema/schemas/publish-result.schema.json +161 -1
- package/packages/schema/schemas/report-run.schema.json +71 -0
- package/packages/schema/schemas/report-runs.schema.json +38 -1
- package/packages/schema/schemas/report-status.schema.json +104 -0
- package/packages/schema/schemas/test-catalog.schema.json +41 -0
- package/packages/schema/schemas/test-history.schema.json +82 -0
- package/packages/server/openapi.json +830 -4
- package/packages/visualizer/src/evidence/marks.tsx +16 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +128 -10
- package/packages/visualizer/src/explorer/catalog.tsx +16 -6
- package/packages/visualizer/src/explorer/search.ts +4 -1
|
@@ -82,12 +82,28 @@ export function CountLine({ summary, noun, nouns }: { summary: SpecSummary; noun
|
|
|
82
82
|
export function backendLabel(backends: readonly BackendInfo[], id: string) {
|
|
83
83
|
return backends.find((backend) => backend.id === id)?.label ?? id;
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Recording completeness of a layer, apart from its test outcomes: how many expected videos were published. A layer
|
|
87
|
+
* that declared no expectations returns null (its completeness is unknown, so nothing is claimed).
|
|
88
|
+
*/
|
|
89
|
+
export function recordingLabel(layer: Pick<LayerHeader, 'recordings'>): { text: string; incomplete: boolean } | null {
|
|
90
|
+
const recordings = layer.recordings;
|
|
91
|
+
if (!recordings || recordings.expected === 0) return null;
|
|
92
|
+
const published = recordings.uploaded + recordings.referenced;
|
|
93
|
+
return { text: `Recordings ${published} of ${recordings.expected}`, incomplete: recordings.skipped + recordings.unresolved > 0 };
|
|
94
|
+
}
|
|
85
95
|
/** A result's provenance; incomplete origin data is stated explicitly. */
|
|
86
96
|
export function Origin({ layer, backends, now }: { layer: LayerHeader; backends: readonly BackendInfo[]; now: number }) {
|
|
87
97
|
const sha = shortSha(layer.sha);
|
|
98
|
+
const recording = recordingLabel(layer);
|
|
88
99
|
return (
|
|
89
100
|
<Flex component="span" display="inline-flex" wrap="wrap" gap="xs" align="center" c="dimmed" fz="xs">
|
|
90
101
|
{layer.live && <Badge size="sm">Running now</Badge>}
|
|
102
|
+
{recording && (
|
|
103
|
+
<Badge size="sm" variant="light" color={recording.incomplete ? 'orange' : 'gray'} title={recording.incomplete ? 'Some expected recordings were not published. The test outcomes are unaffected.' : 'Every expected recording was published.'}>
|
|
104
|
+
{recording.incomplete ? `${recording.text}: incomplete` : recording.text}
|
|
105
|
+
</Badge>
|
|
106
|
+
)}
|
|
91
107
|
<Text span inherit>
|
|
92
108
|
{backendLabel(backends, layer.backend)}
|
|
93
109
|
</Text>
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { IconArrowLeft } from '@tabler/icons-react';
|
|
1
|
+
import { IconAlertTriangle, IconArrowLeft, IconVideo } from '@tabler/icons-react';
|
|
2
2
|
import { Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text, TextInput, Title, VisuallyHidden } from '@mantine/core';
|
|
3
3
|
/**
|
|
4
4
|
* @module Runs view
|
|
5
5
|
*
|
|
6
6
|
* The CI runs that produced the report, newest first: when, on which branch and commit, which environments, how many
|
|
7
|
-
* tests and how they went, how long it took, and a link to the run. Opening one lists
|
|
8
|
-
* with a way to show exactly those tests in the explorer.
|
|
7
|
+
* tests and how they went, how long it took, whether it has recordings to watch, and a link to the run. Opening one lists
|
|
8
|
+
* every test it ran, filterable, with a way to show exactly those tests in the explorer. Videos are counted as
|
|
9
|
+
* references (every mention) and as distinct playable recordings (identical bytes are one), and a reference whose file
|
|
10
|
+
* was never stored is shown as unavailable, never as a recording.
|
|
9
11
|
*/
|
|
12
|
+
import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
|
|
13
|
+
import type { VideoCounts, VideoRef } from '@crvouga/atlas/reports/evidence-types';
|
|
10
14
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
11
15
|
import { formatMs, type RunResult, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
12
16
|
import { Link } from '@tanstack/react-router';
|
|
@@ -14,7 +18,7 @@ import { useMemo, useState } from 'react';
|
|
|
14
18
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
15
19
|
import { Meter, StatusMark } from '../evidence/marks';
|
|
16
20
|
import { useRunDetail, useRunList } from './catalog';
|
|
17
|
-
import { patched, useExplorerSearch } from './navigate';
|
|
21
|
+
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
18
22
|
import { runName } from './parts';
|
|
19
23
|
const RUN_ROWS = 50;
|
|
20
24
|
const RESULT_ROWS = 200;
|
|
@@ -60,8 +64,65 @@ function Counts({ run }: { run: RunSummary }) {
|
|
|
60
64
|
</Text>
|
|
61
65
|
);
|
|
62
66
|
}
|
|
67
|
+
/** Who holds a video, for a person: the test, one of its tries, or a step (in a try). */
|
|
68
|
+
function ownerText(video: VideoRef): string {
|
|
69
|
+
const attempt = video.attempt === undefined ? '' : `try ${video.attempt + 1}`;
|
|
70
|
+
if (video.owner === 'layer') return 'the run';
|
|
71
|
+
if (video.owner === 'step') return `step “${video.step ?? ''}”${attempt ? `, ${attempt}` : ''}`;
|
|
72
|
+
if (video.owner === 'attempt') return attempt || 'a try';
|
|
73
|
+
return 'the test';
|
|
74
|
+
}
|
|
75
|
+
const UNAVAILABLE = 'Listed in the report, but its file was never stored, so it cannot be played.';
|
|
76
|
+
/** The unavailable references of a video summary and why they cannot be watched. */
|
|
77
|
+
function Unavailable({ videos }: { videos: VideoCounts }) {
|
|
78
|
+
if (!videos.unavailable) return null;
|
|
79
|
+
return (
|
|
80
|
+
<Text component="div" size="xs" c="dimmed" data-video-unavailable>
|
|
81
|
+
<IconAlertTriangle size={16} aria-hidden="true" /> {plural(videos.unavailable, 'video', 'videos')} unavailable. {UNAVAILABLE}
|
|
82
|
+
</Text>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
/** A run's videos in its row: a link to the recordings, how many references they come from, and what cannot be played. */
|
|
86
|
+
function RunVideos({ run }: { run: RunSummary }) {
|
|
87
|
+
const videos = run.videos;
|
|
88
|
+
if (!videos) return <Text span inherit c="dimmed" data-video="none">No video</Text>;
|
|
89
|
+
return (
|
|
90
|
+
<Stack gap={0} data-video={videos.recordings > 0 ? 'available' : 'unavailable'}>
|
|
91
|
+
{videos.recordings > 0 && (
|
|
92
|
+
<Anchor
|
|
93
|
+
aria-label={`Watch ${plural(videos.recordings, 'recording', 'recordings')} in run ${runName(run.key)}`}
|
|
94
|
+
renderRoot={(props) => (
|
|
95
|
+
<Link {...props} to="/" search={patched({ openRun: run.key, video: true })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<IconVideo size={16} aria-hidden="true" /> {plural(videos.recordings, 'video', 'videos')}
|
|
99
|
+
</Anchor>
|
|
100
|
+
)}
|
|
101
|
+
{videos.references !== videos.recordings + videos.unavailable && (
|
|
102
|
+
<Text component="div" size="xs" c="dimmed">
|
|
103
|
+
{plural(videos.references, 'reference', 'references')}
|
|
104
|
+
</Text>
|
|
105
|
+
)}
|
|
106
|
+
<Unavailable videos={videos} />
|
|
107
|
+
</Stack>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
/** The Has video filter: a toggle named by its meaning and the count of what it matches. */
|
|
111
|
+
function HasVideo({ count, pressed, noun, onToggle }: { count: number; pressed: boolean; noun: [string, string]; onToggle: () => void }) {
|
|
112
|
+
return (
|
|
113
|
+
<Button type="button" aria-pressed={pressed} aria-label={`Has video: ${plural(count, noun[0], noun[1])}`} onClick={onToggle} variant="light" leftSection={<IconVideo size={16} aria-hidden="true" />}>
|
|
114
|
+
Has video{' '}
|
|
115
|
+
<Text span inherit>
|
|
116
|
+
{count}
|
|
117
|
+
</Text>
|
|
118
|
+
</Button>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
63
121
|
function RunTable() {
|
|
64
|
-
const
|
|
122
|
+
const search = useExplorerSearch();
|
|
123
|
+
const go = useExplorerNavigate();
|
|
124
|
+
const video = search.video === true;
|
|
125
|
+
const runs = useRunList(true, video);
|
|
65
126
|
const now = useNow();
|
|
66
127
|
const [shown, setShown] = useState(RUN_ROWS);
|
|
67
128
|
if (runs.isPending)
|
|
@@ -77,19 +138,30 @@ function RunTable() {
|
|
|
77
138
|
</Alert>
|
|
78
139
|
);
|
|
79
140
|
const list = runs.data.runs;
|
|
80
|
-
|
|
141
|
+
const withVideo = runs.data.withVideo;
|
|
142
|
+
if (!list.length && !video)
|
|
81
143
|
return (
|
|
82
144
|
<Text component="div" c="dimmed">
|
|
83
145
|
No runs recorded yet.
|
|
84
146
|
</Text>
|
|
85
147
|
);
|
|
148
|
+
const noun = video ? 'with a video' : '';
|
|
86
149
|
return (
|
|
87
150
|
<Box aria-label="Runs" component="section" mb="lg">
|
|
88
151
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
89
152
|
{runs.data.total > list.length
|
|
90
|
-
? `The newest ${list.length} of ${plural(runs.data.total, 'run', 'runs')}
|
|
91
|
-
: plural(list.length, 'run', 'runs')}
|
|
153
|
+
? `The newest ${list.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
|
|
154
|
+
: `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
|
|
155
|
+
{(withVideo !== undefined || video) && (
|
|
156
|
+
<HasVideo count={withVideo ?? list.length} pressed={video} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} />
|
|
157
|
+
)}
|
|
92
158
|
</Flex>
|
|
159
|
+
{!list.length && (
|
|
160
|
+
<Text component="div" c="dimmed">
|
|
161
|
+
No run has a playable video recording.{' '}
|
|
162
|
+
{runs.data.withVideo === undefined && 'This server does not report which runs have recordings.'}
|
|
163
|
+
</Text>
|
|
164
|
+
)}
|
|
93
165
|
<Table.ScrollContainer minWidth={600}>
|
|
94
166
|
<Table data-runs>
|
|
95
167
|
<Table.Thead>
|
|
@@ -101,6 +173,7 @@ function RunTable() {
|
|
|
101
173
|
<Table.Th scope="col">Tests</Table.Th>
|
|
102
174
|
<Table.Th scope="col">Outcomes</Table.Th>
|
|
103
175
|
<Table.Th scope="col">Span</Table.Th>
|
|
176
|
+
<Table.Th scope="col">Video</Table.Th>
|
|
104
177
|
<Table.Th scope="col">
|
|
105
178
|
<VisuallyHidden>CI run</VisuallyHidden>
|
|
106
179
|
</Table.Th>
|
|
@@ -113,7 +186,7 @@ function RunTable() {
|
|
|
113
186
|
<Anchor
|
|
114
187
|
aria-label={`Open run ${runLabel(run)}`}
|
|
115
188
|
renderRoot={(props) => (
|
|
116
|
-
<Link {...props} to="/" search={patched({ openRun: run.key })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
189
|
+
<Link {...props} to="/" search={patched({ openRun: run.key, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
117
190
|
)}
|
|
118
191
|
>
|
|
119
192
|
{ago(run.endedAtMs, now)}
|
|
@@ -139,6 +212,9 @@ function RunTable() {
|
|
|
139
212
|
<Counts run={run} />
|
|
140
213
|
</Table.Td>
|
|
141
214
|
<Table.Td>{formatMs(run.spanMs)}</Table.Td>
|
|
215
|
+
<Table.Td>
|
|
216
|
+
<RunVideos run={run} />
|
|
217
|
+
</Table.Td>
|
|
142
218
|
<Table.Td>
|
|
143
219
|
{run.runUrl ? (
|
|
144
220
|
<Anchor href={run.runUrl} target="_blank" rel="noreferrer">
|
|
@@ -159,6 +235,26 @@ function RunTable() {
|
|
|
159
235
|
</Box>
|
|
160
236
|
);
|
|
161
237
|
}
|
|
238
|
+
/** The videos of one result and who holds them, as text (the whole row is one link, so nothing here is interactive). */
|
|
239
|
+
function ResultVideos({ videos }: { videos: readonly VideoRef[] | undefined }) {
|
|
240
|
+
const counts = videoCounts(videos);
|
|
241
|
+
if (!counts || !videos) return null;
|
|
242
|
+
const owners = [...new Set(videos.filter((item) => item.available).map(ownerText))];
|
|
243
|
+
return (
|
|
244
|
+
<>
|
|
245
|
+
{counts.recordings > 0 && (
|
|
246
|
+
<Text span inherit data-video="available">
|
|
247
|
+
<IconVideo size={16} aria-hidden="true" /> {plural(counts.recordings, 'video', 'videos')} ({owners.join('; ')})
|
|
248
|
+
</Text>
|
|
249
|
+
)}
|
|
250
|
+
{counts.unavailable > 0 && (
|
|
251
|
+
<Text span inherit data-video-unavailable>
|
|
252
|
+
<IconAlertTriangle size={16} aria-hidden="true" /> {plural(counts.unavailable, 'video', 'videos')} unavailable
|
|
253
|
+
</Text>
|
|
254
|
+
)}
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
162
258
|
const RESULT_FILTERS = [
|
|
163
259
|
{ id: '', label: 'All' },
|
|
164
260
|
{ id: 'failed', label: 'Failed' },
|
|
@@ -166,6 +262,9 @@ const RESULT_FILTERS = [
|
|
|
166
262
|
{ id: 'not-run', label: 'Not run' }
|
|
167
263
|
] as const;
|
|
168
264
|
function RunDetailView({ runKey }: { runKey: string }) {
|
|
265
|
+
const search = useExplorerSearch();
|
|
266
|
+
const go = useExplorerNavigate();
|
|
267
|
+
const video = search.video === true;
|
|
169
268
|
const detail = useRunDetail(runKey);
|
|
170
269
|
const now = useNow();
|
|
171
270
|
const [status, setStatus] = useState('');
|
|
@@ -177,11 +276,12 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
177
276
|
return results.filter(
|
|
178
277
|
(result) =>
|
|
179
278
|
(!status || result.status === status) &&
|
|
279
|
+
(!video || (videoCounts(result.videos)?.recordings ?? 0) > 0) &&
|
|
180
280
|
terms.every((term) =>
|
|
181
281
|
`${result.title}\n${result.specPath ?? ''}\n${result.environment ?? ''}\n${result.producer}`.toLowerCase().includes(term)
|
|
182
282
|
)
|
|
183
283
|
);
|
|
184
|
-
}, [results, status, words]);
|
|
284
|
+
}, [results, status, words, video]);
|
|
185
285
|
const bySpec = useMemo(() => {
|
|
186
286
|
const groups = new Map<string, RunResult[]>();
|
|
187
287
|
for (const result of filtered.slice(0, shown)) {
|
|
@@ -275,6 +375,13 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
275
375
|
</Text>
|
|
276
376
|
<Counts run={run} />
|
|
277
377
|
</Stack>
|
|
378
|
+
{run.videos && (
|
|
379
|
+
<Text component="div" data-run-videos>
|
|
380
|
+
<IconVideo size={16} aria-hidden="true" /> {plural(run.videos.recordings, 'video', 'videos')}
|
|
381
|
+
{run.videos.references !== run.videos.recordings + run.videos.unavailable && ` from ${plural(run.videos.references, 'reference', 'references')}`}
|
|
382
|
+
<Unavailable videos={run.videos} />
|
|
383
|
+
</Text>
|
|
384
|
+
)}
|
|
278
385
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
279
386
|
<Anchor
|
|
280
387
|
renderRoot={(props) => (
|
|
@@ -321,6 +428,16 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
321
428
|
</Button>
|
|
322
429
|
))}
|
|
323
430
|
</Flex>
|
|
431
|
+
{(run.videos || video) && (
|
|
432
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
433
|
+
<HasVideo
|
|
434
|
+
count={results.filter((result) => (videoCounts(result.videos)?.recordings ?? 0) > 0).length}
|
|
435
|
+
pressed={video}
|
|
436
|
+
noun={['result', 'results']}
|
|
437
|
+
onToggle={() => go({ video: video ? undefined : true })}
|
|
438
|
+
/>
|
|
439
|
+
</Flex>
|
|
440
|
+
)}
|
|
324
441
|
{!filtered.length && (
|
|
325
442
|
<Text component="div" c="dimmed">
|
|
326
443
|
No result matches.
|
|
@@ -368,6 +485,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
368
485
|
<Text span inherit>
|
|
369
486
|
{statusWord(result.status)}
|
|
370
487
|
</Text>
|
|
488
|
+
<ResultVideos videos={result.videos} />
|
|
371
489
|
</Flex>
|
|
372
490
|
{result.status === 'failed' && result.detail && (
|
|
373
491
|
<Text span inherit c="red.7" size="xs" lineClamp={2}>
|
|
@@ -19,6 +19,7 @@ import type { CheckSlot, EvidenceView } from '@crvouga/atlas/reports/evidence-vi
|
|
|
19
19
|
import {
|
|
20
20
|
catalogModel,
|
|
21
21
|
haystackOf,
|
|
22
|
+
hasVideo,
|
|
22
23
|
runDetail,
|
|
23
24
|
runList,
|
|
24
25
|
testHistory,
|
|
@@ -233,15 +234,24 @@ export function useTestHistory(key: string | undefined) {
|
|
|
233
234
|
});
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
/**
|
|
237
|
-
|
|
237
|
+
/**
|
|
238
|
+
* CI runs, newest first. With `video`, only the runs that have a playable video recording: the server filters every run
|
|
239
|
+
* before it pages them, so the count is the whole dataset's. A server that does not know the parameter answers with
|
|
240
|
+
* every run (and no video information), which is filtered here.
|
|
241
|
+
*/
|
|
242
|
+
export function useRunList(enabled = true, video = false) {
|
|
238
243
|
const catalog = useTestCatalog();
|
|
239
244
|
return useQuery({
|
|
240
|
-
queryKey: ['test-runs', catalog.version],
|
|
241
|
-
queryFn: ({ signal }): Promise<RunList>
|
|
242
|
-
if (catalog.source === 'server')
|
|
245
|
+
queryKey: ['test-runs', catalog.version, video],
|
|
246
|
+
queryFn: async ({ signal }): Promise<RunList> => {
|
|
247
|
+
if (catalog.source === 'server') {
|
|
248
|
+
const list = await reportJson<RunList>(`/runs?limit=${RUNS_SHOWN}${video ? '&video=true' : ''}`, signal);
|
|
249
|
+
if (!video || list.withVideo !== undefined) return list;
|
|
250
|
+
const runs = list.runs.filter(hasVideo);
|
|
251
|
+
return { ...list, total: runs.length, runs };
|
|
252
|
+
}
|
|
243
253
|
const history = catalog.model?.history;
|
|
244
|
-
return history ? runList(history, catalog.version, RUNS_SHOWN) : { schemaVersion: 1, version: catalog.version, total: 0, runs: [] };
|
|
254
|
+
return history ? runList(history, catalog.version, RUNS_SHOWN, { video }) : { schemaVersion: 1, version: catalog.version, total: 0, runs: [] };
|
|
245
255
|
},
|
|
246
256
|
enabled: enabled && catalog.model !== null,
|
|
247
257
|
placeholderData: keepPreviousData,
|
|
@@ -38,7 +38,9 @@ export const ExplorerSearchFields = {
|
|
|
38
38
|
/** The run opened in the Runs tab. */
|
|
39
39
|
openRun: text,
|
|
40
40
|
/** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. */
|
|
41
|
-
layer: text
|
|
41
|
+
layer: text,
|
|
42
|
+
/** Only the runs, and the results of an opened run, that have a playable video recording. */
|
|
43
|
+
video: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined)
|
|
42
44
|
};
|
|
43
45
|
|
|
44
46
|
/** The explorer's search parameters as the route holds them. */
|
|
@@ -57,6 +59,7 @@ export type ExplorerSearch = {
|
|
|
57
59
|
readonly tab?: 'runs';
|
|
58
60
|
readonly openRun?: string;
|
|
59
61
|
readonly layer?: string;
|
|
62
|
+
readonly video?: true;
|
|
60
63
|
};
|
|
61
64
|
|
|
62
65
|
/** The values of a comma-separated filter, empty when it is not set. */
|