@crvouga/atlas 1.3.0 → 1.4.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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * @module Run artifacts
3
+ *
4
+ * The files a run carries, without opening a test: for each layer of the run, who recorded it and how many checks it
5
+ * holds (a layer of none is evidence only and is never shown as a pass), then the files it carries as a whole (review
6
+ * movies, archives, summaries) played or shown in place; and the recordings its tests reference, listed once however
7
+ * many tests share the same bytes. Each run-level file has an address of its own (`openRun`, `layer`, `artifact`) that
8
+ * selects that exact file of that immutable layer, whatever runs arrive later. Files are shown exactly as everywhere
9
+ * else: by `ArtifactCard`, so sandboxing, content types and safe downloads are the same.
10
+ */
11
+ import { artifactRef } from '@crvouga/atlas/reports/artifact-tags';
12
+ import { attachmentIdentity, type EvidenceHolder, type PartEvidence } from '@crvouga/atlas/reports/evidence-detail';
13
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
14
+ import type { RunDetail, RunLayer, RunResult } from '@crvouga/atlas/reports/test-catalog';
15
+ import type { Attachment } from '@crvouga/atlas/schema';
16
+ import { Alert, Anchor, Badge, Box, Button, Group, List, Stack, Text, Title } from '@mantine/core';
17
+ import { IconAlertTriangle, IconVideo } from '@tabler/icons-react';
18
+ import { useQueries } from '@tanstack/react-query';
19
+ import { Link } from '@tanstack/react-router';
20
+ import { useMemo } from 'react';
21
+
22
+ import { reportJson } from '../evidence/api';
23
+ import { ArtifactCard } from '../evidence/artifacts';
24
+ import { exactTime, plural } from '../evidence/format';
25
+ import { Bounded } from '../evidence/marks';
26
+ import { patched, useExplorerSearch } from './navigate';
27
+
28
+ /** Files shown per group before "more". */
29
+ const SHOWN = 12;
30
+ /** Tests named in a recording's sentence before "and N more". */
31
+ const TESTS_NAMED = 5;
32
+
33
+ /** What a layer carries as a whole, as `GET /evidence/detail/{backend}/{id}` answers without a check. */
34
+ type LayerFiles = { readonly layer: string; readonly run: EvidenceHolder; readonly parts?: readonly PartEvidence[] };
35
+
36
+ /** A group of files one holder of a layer carries: the layer itself or one of its parts. */
37
+ type Holding = { readonly owner: string; readonly attachments: readonly Attachment[] };
38
+
39
+ function holdings(files: LayerFiles): Holding[] {
40
+ return [
41
+ { owner: 'the layer', attachments: files.run.attachments ?? [] },
42
+ ...(files.parts ?? []).map((part) => ({ owner: `${part.title || part.id} (${part.kind})`, attachments: part.attachments ?? [] }))
43
+ ].filter((holding) => holding.attachments.length > 0);
44
+ }
45
+
46
+ /** The tests that reference each recording, by what identifies its bytes. */
47
+ function recordingsOf(results: readonly RunResult[]) {
48
+ const byBytes = new Map<string, { name: string; tests: Map<string, RunResult>; references: number }>();
49
+ let unavailable = 0;
50
+ for (const result of results)
51
+ for (const video of result.videos ?? []) {
52
+ if (!video.available || video.recording === undefined) {
53
+ unavailable += 1;
54
+ continue;
55
+ }
56
+ const held = byBytes.get(video.recording) ?? { name: video.name, tests: new Map<string, RunResult>(), references: 0 };
57
+ held.references += 1;
58
+ held.tests.set(result.key, held.tests.get(result.key) ?? result);
59
+ byBytes.set(video.recording, held);
60
+ }
61
+ return { byBytes, unavailable };
62
+ }
63
+
64
+ /** The tests of a recording as links that open each test pinned to this run's record of it. */
65
+ function TestLinks({ tests }: { tests: readonly RunResult[] }) {
66
+ return (
67
+ <>
68
+ {tests.slice(0, TESTS_NAMED).map((result, index) => (
69
+ <Text span inherit key={result.key}>
70
+ {index > 0 && ', '}
71
+ <Anchor renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: result.key, layer: result.layer })} resetScroll={false} activeOptions={{ exact: true }} />}>
72
+ {result.title}
73
+ </Anchor>
74
+ </Text>
75
+ ))}
76
+ {tests.length > TESTS_NAMED && ` and ${tests.length - TESTS_NAMED} more`}
77
+ </>
78
+ );
79
+ }
80
+
81
+ /** Where the run's layer is from, for a person: the producer, environment and when it was recorded. */
82
+ function provenance(layer: RunLayer): string {
83
+ return [layer.producer, layer.environment ? environmentLabel(layer.environment) : '', `recorded ${exactTime(layer.recordedAtMs)}`].filter(Boolean).join(' · ');
84
+ }
85
+
86
+ function LayerBlock({ runKey, layer, files, selected, shared }: { runKey: string; layer: RunLayer; files: { isPending: boolean; isError: boolean; refetch: () => unknown; data: LayerFiles | undefined }; selected: string | undefined; shared: (attachment: Attachment) => RunResult[] }) {
87
+ const groups = files.data ? holdings(files.data) : [];
88
+ return (
89
+ <Box component="section" aria-label={`Layer ${layer.producer}`} data-layer={layer.id} mb="lg">
90
+ <Title order={4}>{provenance(layer)}</Title>
91
+ <Text c="dimmed" data-layer-checks={layer.checks}>
92
+ {layer.checks > 0
93
+ ? `${plural(layer.checks, 'check', 'checks')} recorded in this layer.`
94
+ : 'No checks in this layer: it carries files only, so it says nothing about whether anything passed.'}{' '}
95
+ {plural(layer.attachments, 'file', 'files')} of its own.
96
+ </Text>
97
+ {files.isPending && (
98
+ <Text role="status" component="div" c="dimmed">
99
+ Reading the files of this layer…
100
+ </Text>
101
+ )}
102
+ {files.isError && (
103
+ <Alert role="alert" color="red" my="sm" title="The files of this layer could not be read">
104
+ <Button variant="light" onClick={() => void files.refetch()}>
105
+ Try again
106
+ </Button>
107
+ </Alert>
108
+ )}
109
+ {groups.map((group) => (
110
+ <Stack key={group.owner} gap="xs" mt="sm">
111
+ {groups.length > 1 || group.owner !== 'the layer' ? <Text c="dimmed">From {group.owner}</Text> : null}
112
+ <List spacing="xs" listStyleType="none">
113
+ <Bounded items={group.attachments} size={SHOWN} more="More files">
114
+ {(attachment, index) => {
115
+ const tests = shared(attachment);
116
+ const ref = artifactRef(attachment);
117
+ return (
118
+ <ArtifactCard
119
+ key={`${ref}:${index}`}
120
+ attachment={attachment}
121
+ layer={layer.id}
122
+ selected={selected === `${layer.id}\u0000${ref}`}
123
+ link={
124
+ <Anchor
125
+ aria-label={`Link to ${attachment.name}`}
126
+ renderRoot={(props) => (
127
+ <Link {...props} to="/" search={patched({ tab: 'runs', openRun: runKey, layer: layer.id, artifact: ref, test: undefined, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
128
+ )}
129
+ >
130
+ Link to this file
131
+ </Anchor>
132
+ }
133
+ note={
134
+ tests.length > 0 ? (
135
+ <Text component="div" c="dimmed" data-shared>
136
+ The same bytes are also referenced by {plural(tests.length, 'test', 'tests')} (<TestLinks tests={tests} />
137
+ ). That is one recording, not {tests.length + 1}.
138
+ </Text>
139
+ ) : undefined
140
+ }
141
+ />
142
+ );
143
+ }}
144
+ </Bounded>
145
+ </List>
146
+ </Stack>
147
+ ))}
148
+ </Box>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * The artifacts of one run: every layer's files, then the recordings its tests share. The selected file comes from the
154
+ * address (`layer` and `artifact`); one that cannot be found is said so, never silently replaced by another.
155
+ */
156
+ export function RunArtifacts({ detail }: { detail: RunDetail }) {
157
+ const search = useExplorerSearch();
158
+ const { run, results } = detail;
159
+ const layers = detail.layers ?? [];
160
+ const carrying = layers.filter((layer) => layer.attachments > 0);
161
+ const queries = useQueries({
162
+ queries: carrying.map((layer) => ({
163
+ queryKey: ['run-files', layer.backend, layer.id],
164
+ queryFn: ({ signal }: { signal: AbortSignal }) => reportJson<LayerFiles>(`/evidence/detail/${encodeURIComponent(layer.backend)}/${encodeURIComponent(layer.id)}`, signal),
165
+ enabled: /^[0-9a-f]{64}$/.test(layer.id),
166
+ staleTime: Number.POSITIVE_INFINITY,
167
+ retry: false
168
+ }))
169
+ });
170
+ const { byBytes, unavailable } = useMemo(() => recordingsOf(results), [results]);
171
+ const loaded = queries.map((query) => query.data);
172
+ // What identifies the bytes of each run-level file, so a recording a test also references is one thing.
173
+ const runLevel = new Set<string>();
174
+ for (const files of loaded)
175
+ for (const group of files ? holdings(files) : [])
176
+ for (const attachment of group.attachments) {
177
+ const identity = attachmentIdentity(attachment);
178
+ if (identity) runLevel.add(identity);
179
+ }
180
+ const sharedWith = (attachment: Attachment): RunResult[] => {
181
+ const identity = attachmentIdentity(attachment);
182
+ return identity ? [...(byBytes.get(identity)?.tests.values() ?? [])] : [];
183
+ };
184
+ const inTests = [...byBytes.entries()].filter(([identity]) => !runLevel.has(identity));
185
+ const wanted = search.layer && search.artifact ? `${search.layer}\u0000${search.artifact}` : undefined;
186
+ const everyRead = queries.every((query) => !query.isPending);
187
+ const found = wanted
188
+ ? loaded.some((files, index) => files && carrying[index]!.id === search.layer && holdings(files).some((group) => group.attachments.some((attachment) => artifactRef(attachment) === search.artifact)))
189
+ : true;
190
+ const wantedLayer = layers.find((layer) => layer.id === search.layer);
191
+ const nothing = !carrying.length && !inTests.length && !unavailable;
192
+ return (
193
+ <Box aria-label="Run artifacts" component="section" mb="lg" data-run-artifacts>
194
+ <Title order={3} mb="sm">
195
+ Artifacts
196
+ </Title>
197
+ {wanted && everyRead && !found && (
198
+ <Alert role="alert" color="orange" my="sm" icon={<IconAlertTriangle size={16} aria-hidden="true" />} title="That file is not in this run">
199
+ {wantedLayer
200
+ ? 'The layer the link names is in this run, but it no longer lists that file. It is not shown as another file.'
201
+ : 'The link names a layer that is not part of this run (it may belong to another project or have been removed). It is not shown as another file.'}
202
+ </Alert>
203
+ )}
204
+ {nothing && (
205
+ <Text component="div" c="dimmed">
206
+ This run carries no files of its own and none of its tests references a recording.
207
+ </Text>
208
+ )}
209
+ {carrying.map((layer, index) => (
210
+ <LayerBlock key={layer.id} runKey={run.key} layer={layer} files={queries[index]!} selected={wanted} shared={sharedWith} />
211
+ ))}
212
+ {(inTests.length > 0 || unavailable > 0) && (
213
+ <Box aria-label="Recordings in tests" component="section" data-test-recordings>
214
+ <Title order={4}>Recordings in tests</Title>
215
+ <Text c="dimmed">
216
+ A recording that several tests reference is listed once. Open a test to watch it.
217
+ </Text>
218
+ <List spacing="xs" listStyleType="none">
219
+ <Bounded items={inTests} size={SHOWN} more="More recordings">
220
+ {([identity, held]) => (
221
+ <List.Item key={identity} data-recording>
222
+ <Group gap="xs">
223
+ <IconVideo size={16} aria-hidden="true" />
224
+ <Text span inherit fw={600}>
225
+ {held.name}
226
+ </Text>
227
+ {held.tests.size > 1 && <Badge variant="light">shared by {held.tests.size} tests</Badge>}
228
+ </Group>
229
+ <Text component="div" c="dimmed">
230
+ {held.references > 1 ? `${plural(held.references, 'reference', 'references')} to the same recording, in ` : 'In '}
231
+ <TestLinks tests={[...held.tests.values()]} />
232
+ </Text>
233
+ </List.Item>
234
+ )}
235
+ </Bounded>
236
+ </List>
237
+ {unavailable > 0 && (
238
+ <Text component="div" c="dimmed" data-video-unavailable>
239
+ <IconAlertTriangle size={16} aria-hidden="true" /> {plural(unavailable, 'video reference', 'video references')} name no stored file, so they cannot be played.
240
+ </Text>
241
+ )}
242
+ </Box>
243
+ )}
244
+ </Box>
245
+ );
246
+ }
@@ -7,7 +7,8 @@ import { Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text
7
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
8
  * every test it ran, filterable, with a way to show exactly those tests in the explorer. Videos are counted as
9
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.
10
+ * was never stored is shown as unavailable, never as a recording. An opened run lists its artifacts first (see
11
+ * `RunArtifacts`), and a run of no checks says so instead of reading as passed.
11
12
  */
12
13
  import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
13
14
  import type { VideoCounts, VideoRef } from '@crvouga/atlas/reports/evidence-types';
@@ -19,6 +20,7 @@ import { useMemo, useState } from 'react';
19
20
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
20
21
  import { Meter, StatusMark } from '../evidence/marks';
21
22
  import { useRunDetail, useRunList } from './catalog';
23
+ import { RunArtifacts } from './RunArtifacts';
22
24
  import { FilterBar } from './filters';
23
25
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
24
26
  import { runName } from './parts';
@@ -44,6 +46,13 @@ function Freshness({ run }: { run: RunSummary }) {
44
46
  );
45
47
  }
46
48
  function Counts({ run }: { run: RunSummary }) {
49
+ // A run of no checks (only files) has not passed anything: say so, rather than "0 passed".
50
+ if (run.tests === 0 && Object.values(run.counts).every((count) => count === 0))
51
+ return (
52
+ <Text span inherit c="dimmed" data-status="none">
53
+ No checks recorded
54
+ </Text>
55
+ );
47
56
  return (
48
57
  <Text span inherit>
49
58
  {run.counts.failed > 0 && (
@@ -88,14 +97,35 @@ function Unavailable({ videos }: { videos: VideoCounts }) {
88
97
  /** A run's videos in its row: a link to the recordings, how many references they come from, and what cannot be played. */
89
98
  function RunVideos({ run }: { run: RunSummary }) {
90
99
  const videos = run.videos;
91
- if (!videos) return <Text span inherit c="dimmed" data-video="none">No video</Text>;
100
+ const files = run.attachments ? (
101
+ <Anchor
102
+ aria-label={`Open the ${plural(run.attachments, 'file', 'files')} of run ${runName(run.key)}`}
103
+ renderRoot={(props) => (
104
+ <Link {...props} to="/" search={patched({ openRun: run.key, layer: undefined, artifact: undefined, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
105
+ )}
106
+ >
107
+ <Text span inherit data-run-files>
108
+ {plural(run.attachments, 'run file', 'run files')}
109
+ </Text>
110
+ </Anchor>
111
+ ) : null;
112
+ if (!videos)
113
+ return (
114
+ <Stack gap={0}>
115
+ <Text span inherit c="dimmed" data-video="none">
116
+ No video
117
+ </Text>
118
+ {files}
119
+ </Stack>
120
+ );
92
121
  return (
93
122
  <Stack gap={0} data-video={videos.recordings > 0 ? 'available' : 'unavailable'}>
123
+ {files}
94
124
  {videos.recordings > 0 && (
95
125
  <Anchor
96
126
  aria-label={`Watch ${plural(videos.recordings, 'recording', 'recordings')} in run ${runName(run.key)}`}
97
127
  renderRoot={(props) => (
98
- <Link {...props} to="/" search={patched({ openRun: run.key, video: true })} resetScroll={false} activeOptions={{ exact: true }} />
128
+ <Link {...props} to="/" search={patched({ openRun: run.key, layer: undefined, artifact: undefined, video: true })} resetScroll={false} activeOptions={{ exact: true }} />
99
129
  )}
100
130
  >
101
131
  <IconVideo size={16} aria-hidden="true" /> {plural(videos.recordings, 'video', 'videos')}
@@ -204,12 +234,12 @@ function RunTable() {
204
234
  </Table.Thead>
205
235
  <Table.Tbody>
206
236
  {list.slice(0, shown).map((run) => (
207
- <Table.Tr key={run.key} data-status={run.counts.failed ? 'failed' : 'passed'}>
237
+ <Table.Tr key={run.key} data-status={run.counts.failed ? 'failed' : run.tests === 0 ? 'not-run' : 'passed'}>
208
238
  <Table.Td title={exactTime(run.endedAtMs)}>
209
239
  <Anchor
210
240
  aria-label={`Open run ${runLabel(run)}`}
211
241
  renderRoot={(props) => (
212
- <Link {...props} to="/" search={patched({ openRun: run.key, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
242
+ <Link {...props} to="/" search={patched({ openRun: run.key, layer: undefined, artifact: undefined, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
213
243
  )}
214
244
  >
215
245
  {ago(run.endedAtMs, now)}
@@ -319,7 +349,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
319
349
  const back = (
320
350
  <Anchor
321
351
  renderRoot={(props) => (
322
- <Link {...props} to="/" search={patched({ openRun: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
352
+ <Link {...props} to="/" search={patched({ openRun: undefined, layer: undefined, artifact: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
323
353
  )}
324
354
  >
325
355
  <Text aria-hidden="true" span inherit>
@@ -395,7 +425,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
395
425
  <Meter counts={run.counts} />
396
426
  </Text>
397
427
  <Text span inherit>
398
- {plural(run.tests, 'test', 'tests')}
428
+ {run.tests === 0 ? 'No tests' : plural(run.tests, 'test', 'tests')}
399
429
  </Text>
400
430
  <Counts run={run} />
401
431
  </Stack>
@@ -406,13 +436,14 @@ function RunDetailView({ runKey }: { runKey: string }) {
406
436
  <Unavailable videos={run.videos} />
407
437
  </Text>
408
438
  )}
439
+ <RunArtifacts detail={detail.data} />
409
440
  <Flex gap="xs" align="center" wrap="wrap">
410
441
  <Anchor
411
442
  renderRoot={(props) => (
412
443
  <Link
413
444
  {...props}
414
445
  to="/"
415
- search={patched({ tab: undefined, openRun: undefined, inRun: run.key, test: undefined, layer: undefined })}
446
+ search={patched({ tab: undefined, openRun: undefined, inRun: run.key, test: undefined, layer: undefined, artifact: undefined })}
416
447
  resetScroll={false}
417
448
  activeOptions={{ exact: true }}
418
449
  />
@@ -488,7 +519,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
488
519
  <Link
489
520
  {...props}
490
521
  to="/"
491
- search={patched({ tab: undefined, openRun: undefined, test: result.key, layer: result.layer })}
522
+ search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: result.key, layer: result.layer })}
492
523
  resetScroll={false}
493
524
  activeOptions={{ exact: true }}
494
525
  />
@@ -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, and the exact layer a test's detail is pinned to. Filters that
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, 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
  *
@@ -60,8 +60,10 @@ export const ExplorerSearchFields = {
60
60
  tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
61
61
  /** The run opened in the Runs tab. */
62
62
  openRun: text,
63
- /** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. */
63
+ /** 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. */
64
64
  layer: text,
65
+ /** With `openRun` and `layer`: the one file of that layer (an `artifactRef`: `id:…`, `key:…` or `name:…`) the run's Artifacts select. */
66
+ artifact: text,
65
67
  /** Only the runs, and the results of an opened run, that have a playable video recording. */
66
68
  video: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined)
67
69
  };
@@ -91,6 +93,7 @@ export type ExplorerSearch = {
91
93
  readonly tab?: 'runs' | 'artifacts';
92
94
  readonly openRun?: string;
93
95
  readonly layer?: string;
96
+ readonly artifact?: string;
94
97
  readonly video?: true;
95
98
  };
96
99