@crvouga/atlas 0.14.0 → 0.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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/artifact-tag-store.d.ts +20 -0
  3. package/dist/reports/src/artifact-tag-store.js +70 -0
  4. package/dist/reports/src/artifact-tags.d.ts +96 -0
  5. package/dist/reports/src/artifact-tags.js +96 -0
  6. package/dist/reports/src/evidence-detail.d.ts +20 -3
  7. package/dist/reports/src/evidence-detail.js +66 -13
  8. package/dist/reports/src/evidence-types.d.ts +33 -0
  9. package/dist/reports/src/layer-merge.js +2 -2
  10. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  11. package/dist/reports/src/reports-route.d.ts +8 -0
  12. package/dist/reports/src/test-catalog.d.ts +35 -26
  13. package/dist/reports/src/test-catalog.js +79 -15
  14. package/dist/schema/src/api.d.ts +246 -0
  15. package/dist/schema/src/api.js +59 -5
  16. package/dist/schema/src/layer.d.ts +33 -0
  17. package/dist/schema/src/layer.js +11 -1
  18. package/dist/server/src/app.js +35 -3
  19. package/dist/server/src/llms.js +2 -1
  20. package/dist/server/src/openapi.d.ts +1 -1
  21. package/dist/server/src/openapi.js +20 -3
  22. package/docs/extending.md +3 -0
  23. package/docs/reference/http-api.md +52 -2
  24. package/docs/reference/reports.md +327 -5
  25. package/docs/reference/schema.md +48 -0
  26. package/docs/rich-evidence.md +28 -0
  27. package/docs/server.md +25 -1
  28. package/llms-full.txt +108 -3
  29. package/package.json +1 -1
  30. package/packages/schema/schemas/check-detail.schema.json +28 -0
  31. package/packages/schema/schemas/effective-delta.schema.json +96 -0
  32. package/packages/schema/schemas/effective-evidence.schema.json +96 -0
  33. package/packages/schema/schemas/evidence-page.schema.json +48 -0
  34. package/packages/schema/schemas/layer-rows.schema.json +48 -0
  35. package/packages/schema/schemas/layer.schema.json +70 -0
  36. package/packages/schema/schemas/publish-checks.schema.json +35 -0
  37. package/packages/schema/schemas/report-run.schema.json +71 -0
  38. package/packages/schema/schemas/report-runs.schema.json +38 -1
  39. package/packages/schema/schemas/test-catalog.schema.json +83 -0
  40. package/packages/schema/schemas/test-history.schema.json +110 -0
  41. package/packages/server/openapi.json +1259 -33
  42. package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
  43. package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
  44. package/packages/visualizer/src/explorer/RunsView.tsx +128 -10
  45. package/packages/visualizer/src/explorer/catalog.tsx +16 -6
  46. package/packages/visualizer/src/explorer/search.ts +4 -1
@@ -0,0 +1,285 @@
1
+ import {
2
+ artifactRef,
3
+ artifactTagsOf,
4
+ displayTag,
5
+ foldArtifactTags,
6
+ hasArtifactTag,
7
+ normalizeTag,
8
+ normalizeTags,
9
+ targetKey,
10
+ type ArtifactTag,
11
+ type ArtifactTagEntry,
12
+ type ReviewerTag
13
+ } from '@crvouga/atlas/reports/artifact-tags';
14
+ import type { EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
15
+ import type { Attachment } from '@crvouga/atlas/schema';
16
+ import { Alert, Badge, Button, Chip, CloseButton, Group, PasswordInput, Popover, Stack, Switch, Text, TextInput } from '@mantine/core';
17
+ import { IconTag } from '@tabler/icons-react';
18
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
19
+ import { createContext, useContext, useMemo, useState, type FormEvent, type ReactNode } from 'react';
20
+ import { reportAssetUrl, reportJson, staticEvidence } from './api';
21
+ import { exactTime } from './format';
22
+
23
+ /** What the tags of attachments are filtered by: a tag, and whether tags inherited from the check and run count. */
24
+ type TagFilter = { readonly tag: string; readonly inherited: boolean };
25
+
26
+ type TagChange = { readonly add?: readonly string[]; readonly remove?: readonly string[] };
27
+
28
+ type TagsContext = {
29
+ readonly layer: string;
30
+ readonly check: string;
31
+ readonly kind: string;
32
+ readonly inherited: readonly string[];
33
+ readonly reviewer: ReadonlyMap<string, readonly ReviewerTag[]>;
34
+ /** True when the server records reviewer tags. Whether this visitor may is decided by the server when they try. */
35
+ readonly editable: boolean;
36
+ readonly filter: TagFilter | null;
37
+ readonly setFilter: (filter: TagFilter | null) => void;
38
+ readonly token: string;
39
+ readonly setToken: (token: string) => void;
40
+ readonly edit: (attachment: Attachment, change: TagChange) => Promise<void>;
41
+ };
42
+
43
+ const Tags = createContext<TagsContext | null>(null);
44
+
45
+ /** The tags an attachment shows here: its producer's, its reviewers' and those inherited from its check and run. */
46
+ function shownTags(attachment: Attachment, context: TagsContext | null): ArtifactTag[] {
47
+ if (!context) return artifactTagsOf(attachment);
48
+ const target = { layer: context.layer, check: context.check, kind: context.kind, artifact: artifactRef(attachment) };
49
+ return artifactTagsOf(attachment, { reviewer: context.reviewer.get(targetKey(target)) ?? [], inherited: context.inherited });
50
+ }
51
+
52
+ /** Every attachment a holder carries, on its steps and attempts too. */
53
+ function everyAttachment(holder: EvidenceHolder): Attachment[] {
54
+ const out: Attachment[] = [...(holder.attachments ?? [])];
55
+ for (const step of holder.steps ?? []) out.push(...(step.attachments ?? []));
56
+ for (const attempt of holder.attempts ?? []) {
57
+ out.push(...(attempt.attachments ?? []));
58
+ for (const step of attempt.steps ?? []) out.push(...(step.attachments ?? []));
59
+ }
60
+ return out;
61
+ }
62
+
63
+ async function readProblem(response: Response): Promise<string> {
64
+ try {
65
+ const body = (await response.json()) as { detail?: string; error?: string };
66
+ return body.detail ?? body.error ?? `The server refused the change (${response.status}).`;
67
+ } catch {
68
+ return `The server refused the change (${response.status}).`;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Makes the tags of the attachments of one result live: reviewers' tags read from the server (when it has them),
74
+ * the tags a result's attachments inherit from their check and run, the filter chosen by selecting a tag, and
75
+ * the edit that records a reviewer's tag. Without this around them, attachments still show their producer's tags.
76
+ */
77
+ export function ArtifactTagsProvider({ layer, kind, check, holders, children }: { layer: string; kind: string; check: string; holders: readonly EvidenceHolder[]; children: ReactNode }) {
78
+ const client = useQueryClient();
79
+ const [filter, setFilter] = useState<TagFilter | null>(null);
80
+ const [token, setToken] = useState('');
81
+ const overlay = useQuery({
82
+ queryKey: ['artifact-tags'],
83
+ queryFn: ({ signal }) => reportJson<{ entries: ArtifactTagEntry[] }>('/artifact-tags', signal),
84
+ enabled: !staticEvidence,
85
+ staleTime: 5000,
86
+ retry: false
87
+ });
88
+ const reviewer = useMemo(() => foldArtifactTags(overlay.data?.entries ?? []), [overlay.data]);
89
+ const inherited = useMemo(() => normalizeTags(holders.flatMap((holder) => holder.tags ?? [])), [holders]);
90
+ const value = useMemo<TagsContext>(
91
+ () => ({
92
+ layer,
93
+ check,
94
+ kind,
95
+ inherited,
96
+ reviewer,
97
+ editable: overlay.isSuccess,
98
+ filter,
99
+ setFilter,
100
+ token,
101
+ setToken,
102
+ edit: async (attachment, change) => {
103
+ const response = await fetch(reportAssetUrl('/artifact-tags'), {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
106
+ body: JSON.stringify({ target: { layer, check, kind, artifact: artifactRef(attachment) }, ...change })
107
+ });
108
+ if (!response.ok) throw new Error(await readProblem(response));
109
+ await client.invalidateQueries({ queryKey: ['artifact-tags'] });
110
+ }
111
+ }),
112
+ [layer, check, kind, inherited, reviewer, overlay.isSuccess, filter, token, client]
113
+ );
114
+ return <Tags.Provider value={value}>{children}</Tags.Provider>;
115
+ }
116
+
117
+ /** The attachments that pass the chosen tag filter, or all of them when there is none. */
118
+ export function useFilteredAttachments(attachments: readonly Attachment[]): readonly Attachment[] {
119
+ const context = useContext(Tags);
120
+ const filter = context?.filter;
121
+ if (!context || !filter) return attachments;
122
+ return attachments.filter((attachment) => hasArtifactTag(shownTags(attachment, context), filter.tag, { inherited: filter.inherited }));
123
+ }
124
+
125
+ /** Says which tag the evidence is filtered by, how many attachments match, whether inherited tags count, and clears the filter. Nothing when no tag is chosen. */
126
+ export function TagFilterBar({ holders }: { holders: readonly EvidenceHolder[] }) {
127
+ const context = useContext(Tags);
128
+ const filter = context?.filter;
129
+ if (!context || !filter) return null;
130
+ const all = holders.flatMap(everyAttachment);
131
+ const matching = all.filter((attachment) => hasArtifactTag(shownTags(attachment, context), filter.tag, { inherited: filter.inherited })).length;
132
+ return (
133
+ <Group aria-label="Tag filter" role="group" gap="sm" align="center">
134
+ <Text role="status" component="div">
135
+ {matching} of {all.length} {all.length === 1 ? 'attachment' : 'attachments'} tagged {displayTag(filter.tag)}
136
+ </Text>
137
+ <Switch
138
+ label="Include tags inherited from the check and run"
139
+ checked={filter.inherited}
140
+ onChange={(event) => context.setFilter({ tag: filter.tag, inherited: event.currentTarget.checked })}
141
+ />
142
+ <Button type="button" variant="subtle" onClick={() => context.setFilter(null)}>
143
+ Clear filter
144
+ </Button>
145
+ </Group>
146
+ );
147
+ }
148
+
149
+ function Provenance({ tag }: { tag: ArtifactTag }) {
150
+ if (tag.source === 'inherited')
151
+ return (
152
+ <Text span inherit c="dimmed">
153
+ {' '}
154
+ inherited
155
+ </Text>
156
+ );
157
+ if (tag.source === 'reviewer')
158
+ return (
159
+ <Text span inherit c="dimmed">
160
+ {' '}
161
+ added by {tag.by}
162
+ {tag.atMs !== undefined ? `, ${exactTime(tag.atMs)}` : ''}
163
+ </Text>
164
+ );
165
+ return null;
166
+ }
167
+
168
+ function TagEditor({ attachment }: { attachment: Attachment }) {
169
+ const context = useContext(Tags)!;
170
+ const [opened, setOpened] = useState(false);
171
+ const [text, setText] = useState('');
172
+ const [error, setError] = useState('');
173
+ const [busy, setBusy] = useState(false);
174
+ const normalized = normalizeTag(text);
175
+ const submit = async (event: FormEvent) => {
176
+ event.preventDefault();
177
+ if (normalized === null || busy) return;
178
+ setBusy(true);
179
+ setError('');
180
+ try {
181
+ await context.edit(attachment, { add: [normalized] });
182
+ setText('');
183
+ setOpened(false);
184
+ } catch (problem) {
185
+ setError(problem instanceof Error ? problem.message : 'The tag could not be saved.');
186
+ } finally {
187
+ setBusy(false);
188
+ }
189
+ };
190
+ return (
191
+ <Popover opened={opened} onChange={setOpened} position="bottom-start" trapFocus withArrow>
192
+ <Popover.Target>
193
+ <Button type="button" variant="subtle" size="compact-sm" leftSection={<IconTag size={16} aria-hidden="true" />} onClick={() => setOpened((now) => !now)}>
194
+ Add tag
195
+ </Button>
196
+ </Popover.Target>
197
+ <Popover.Dropdown>
198
+ <form onSubmit={submit} aria-label={`Add a tag to ${attachment.name}`}>
199
+ <Stack gap="sm">
200
+ <TextInput
201
+ label="Tag"
202
+ description="demo and #demo are the same tag."
203
+ value={text}
204
+ onChange={(event) => setText(event.currentTarget.value)}
205
+ data-autofocus
206
+ />
207
+ <PasswordInput
208
+ label="Token"
209
+ description="A token that may publish. Kept only in this tab. Not needed on a server without tokens."
210
+ value={context.token}
211
+ onChange={(event) => context.setToken(event.currentTarget.value)}
212
+ autoComplete="off"
213
+ />
214
+ {error && (
215
+ <Alert color="red" role="alert">
216
+ {error}
217
+ </Alert>
218
+ )}
219
+ <Group>
220
+ <Button type="submit" variant="filled" loading={busy} disabled={normalized === null}>
221
+ Save tag
222
+ </Button>
223
+ </Group>
224
+ </Stack>
225
+ </form>
226
+ </Popover.Dropdown>
227
+ </Popover>
228
+ );
229
+ }
230
+
231
+ /**
232
+ * The tags of one attachment, as chips under its card: `#demo` from its producer, from a reviewer (with who and
233
+ * when) or inherited from its check and run (said so). Selecting a chip filters the evidence to the attachments
234
+ * with that tag. A reviewer's tag can be removed, and any attachment tagged, when the server records reviewer tags.
235
+ */
236
+ export function ArtifactTagList({ attachment }: { attachment: Attachment }) {
237
+ const context = useContext(Tags);
238
+ const error = useRemovalError();
239
+ const tags = shownTags(attachment, context);
240
+ if (!tags.length && !context?.editable) return null;
241
+ return (
242
+ <Stack gap="xs" aria-label={`Tags of ${attachment.name}`}>
243
+ <Group gap="xs" align="center">
244
+ {tags.map((tag) =>
245
+ context ? (
246
+ <Group key={tag.tag} gap={0} wrap="nowrap">
247
+ <Chip
248
+ size="xs"
249
+ variant="light"
250
+ checked={context.filter?.tag === tag.tag}
251
+ onChange={() => context.setFilter(context.filter?.tag === tag.tag ? null : { tag: tag.tag, inherited: tag.source === 'inherited' })}
252
+ >
253
+ {displayTag(tag.tag)}
254
+ <Provenance tag={tag} />
255
+ </Chip>
256
+ {tag.source === 'reviewer' && context.editable && (
257
+ <CloseButton
258
+ size="sm"
259
+ aria-label={`Remove ${displayTag(tag.tag)} from ${attachment.name}`}
260
+ onClick={() => void context.edit(attachment, { remove: [tag.tag] }).catch((problem: unknown) => error.set(problem instanceof Error ? problem.message : 'The tag could not be removed.'))}
261
+ />
262
+ )}
263
+ </Group>
264
+ ) : (
265
+ <Badge key={tag.tag} variant="light" tt="none">
266
+ {displayTag(tag.tag)}
267
+ <Provenance tag={tag} />
268
+ </Badge>
269
+ )
270
+ )}
271
+ {context?.editable && <TagEditor attachment={attachment} />}
272
+ </Group>
273
+ {error.message && (
274
+ <Alert color="red" role="alert">
275
+ {error.message}
276
+ </Alert>
277
+ )}
278
+ </Stack>
279
+ );
280
+ }
281
+
282
+ function useRemovalError() {
283
+ const [message, set] = useState('');
284
+ return { message, set };
285
+ }
@@ -6,6 +6,7 @@ import type { Annotation, Attachment, Attempt, Step } from '@crvouga/atlas/schem
6
6
  import { Accordion, Alert, Anchor, Box, Code, Flex, Image, List, Stack, Text, Title } from '@mantine/core';
7
7
  import { useQuery } from '@tanstack/react-query';
8
8
  import { useMemo, type ReactNode } from 'react';
9
+ import { ArtifactTagList, ArtifactTagsProvider, TagFilterBar, useFilteredAttachments } from './artifact-tags';
9
10
  import { reportAssetUrl, reportJson, reportText } from './api';
10
11
  import { duration, plural } from './format';
11
12
  import { StatusMark } from './marks';
@@ -171,10 +172,12 @@ function Artifact({ attachment, layer }: { attachment: Attachment; layer: string
171
172
  </Text>
172
173
  )}
173
174
  </Box>
175
+ <ArtifactTagList attachment={attachment} />
174
176
  </List.Item>
175
177
  );
176
178
  }
177
- export function Attachments({ attachments, layer }: { attachments: readonly Attachment[]; layer: string }) {
179
+ export function Attachments({ attachments: all, layer }: { attachments: readonly Attachment[]; layer: string }) {
180
+ const attachments = useFilteredAttachments(all);
178
181
  if (!attachments.length) return null;
179
182
  return (
180
183
  <Box aria-label="Attachments" component="section" mb="lg">
@@ -410,16 +413,19 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
410
413
  Array.isArray(value) ? value.length > 0 : value && typeof value === 'object' ? Object.keys(value).length > 0 : false
411
414
  );
412
415
  return (
413
- <Stack aria-label="Evidence" gap="sm" miw={0}>
414
- <Evidence holder={check} layer={layer.id} steps />
415
- {hasRun && (
416
- <Box aria-label="From the run" component="section" mb="lg">
417
- <Title order={4} mb="sm">
418
- From the run
419
- </Title>
420
- <Evidence holder={run} layer={layer.id} />
421
- </Box>
422
- )}
423
- </Stack>
416
+ <ArtifactTagsProvider layer={layer.id} kind={row.kind} check={row.id} holders={[check, run]}>
417
+ <Stack aria-label="Evidence" gap="sm" miw={0}>
418
+ <TagFilterBar holders={[check, run]} />
419
+ <Evidence holder={check} layer={layer.id} steps />
420
+ {hasRun && (
421
+ <Box aria-label="From the run" component="section" mb="lg">
422
+ <Title order={4} mb="sm">
423
+ From the run
424
+ </Title>
425
+ <Evidence holder={run} layer={layer.id} />
426
+ </Box>
427
+ )}
428
+ </Stack>
429
+ </ArtifactTagsProvider>
424
430
  );
425
431
  }
@@ -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 every test it ran, filterable,
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 runs = useRunList();
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
- if (!list.length)
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
- /** CI runs, newest first. */
237
- export function useRunList(enabled = true) {
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> | RunList => {
242
- if (catalog.source === 'server') return reportJson<RunList>(`/runs?limit=${RUNS_SHOWN}`, signal);
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. */