@crvouga/atlas 1.2.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/evidence-detail.d.ts +37 -3
  3. package/dist/reports/src/evidence-detail.js +52 -20
  4. package/dist/reports/src/evidence-scope.d.ts +83 -0
  5. package/dist/reports/src/evidence-scope.js +129 -0
  6. package/dist/reports/src/evidence-types.d.ts +3 -1
  7. package/dist/reports/src/explorer-types.d.ts +1 -1
  8. package/dist/reports/src/export-evidence.js +1 -1
  9. package/dist/reports/src/layer-merge.js +3 -2
  10. package/dist/reports/src/layer-types.d.ts +1 -1
  11. package/dist/reports/src/layers-route.d.ts +2 -1
  12. package/dist/reports/src/layers-route.js +8 -4
  13. package/dist/reports/src/publish-checks.js +1 -0
  14. package/dist/reports/src/reporter.d.ts +1 -1
  15. package/dist/reports/src/reports-explorer-route.d.ts +276 -0
  16. package/dist/reports/src/reports-route.d.ts +276 -0
  17. package/dist/reports/src/test-catalog.d.ts +34 -1
  18. package/dist/reports/src/test-catalog.js +23 -5
  19. package/dist/schema/src/api.d.ts +542 -0
  20. package/dist/schema/src/api.js +17 -5
  21. package/dist/schema/src/layer.d.ts +981 -0
  22. package/dist/schema/src/layer.js +43 -4
  23. package/dist/server/src/openapi.js +3 -2
  24. package/docs/extending.md +1 -0
  25. package/docs/reference/http-api.md +6 -4
  26. package/docs/reference/reports.md +243 -14
  27. package/docs/reference/schema.md +23 -0
  28. package/docs/rich-evidence.md +70 -0
  29. package/docs/ui.md +1 -1
  30. package/llms-full.txt +78 -5
  31. package/package.json +1 -1
  32. package/packages/schema/schemas/check-detail.schema.json +697 -1
  33. package/packages/schema/schemas/effective-delta.schema.json +192 -0
  34. package/packages/schema/schemas/effective-evidence.schema.json +192 -0
  35. package/packages/schema/schemas/evidence-page.schema.json +96 -0
  36. package/packages/schema/schemas/layer-rows.schema.json +96 -0
  37. package/packages/schema/schemas/layer.schema.json +1598 -62
  38. package/packages/schema/schemas/publish-checks.schema.json +768 -0
  39. package/packages/schema/schemas/report-run.schema.json +181 -0
  40. package/packages/schema/schemas/report-runs.schema.json +6 -0
  41. package/packages/schema/schemas/test-catalog.schema.json +128 -0
  42. package/packages/schema/schemas/test-history.schema.json +202 -0
  43. package/packages/server/openapi.json +2721 -48
  44. package/packages/visualizer/src/evidence/artifacts.tsx +77 -29
  45. package/packages/visualizer/src/evidence/evidence-scope.tsx +106 -0
  46. package/packages/visualizer/src/evidence/format.ts +11 -0
  47. package/packages/visualizer/src/explorer/Explorer.tsx +28 -5
  48. package/packages/visualizer/src/explorer/RunArtifacts.tsx +246 -0
  49. package/packages/visualizer/src/explorer/RunsView.tsx +40 -9
  50. package/packages/visualizer/src/explorer/search.ts +10 -3
@@ -1,14 +1,16 @@
1
1
  import type { CheckDetail, EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
2
- import { roleOfType } from '@crvouga/atlas/reports/evidence-detail';
2
+ import { isVideoAttachment, roleOfType } from '@crvouga/atlas/reports/evidence-detail';
3
+ import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
3
4
  import type { LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
4
5
  import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
5
6
  import type { Annotation, Attachment, Attempt, Step } from '@crvouga/atlas/schema';
6
- import { Accordion, Alert, Anchor, Box, Code, Flex, Image, List, Stack, Text, Title } from '@mantine/core';
7
+ import { Accordion, Alert, Anchor, Badge, Box, Code, Flex, Image, List, Stack, Text, Title } from '@mantine/core';
7
8
  import { useQuery } from '@tanstack/react-query';
8
- import { useMemo, type ReactNode } from 'react';
9
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
9
10
  import { ArtifactTagList, ArtifactTagsProvider, TagFilterBar, useFilteredAttachments } from './artifact-tags';
10
11
  import { reportAssetUrl, reportJson, reportText } from './api';
11
- import { duration, plural } from './format';
12
+ import { ScopeNotice, ScopeProvider, useScope } from './evidence-scope';
13
+ import { duration, plural, safeUri } from './format';
12
14
  import { StatusMark } from './marks';
13
15
  /**
14
16
  * How one kind of attachment is shown. The first renderer that matches wins, so a new kind of artifact (a
@@ -33,15 +35,22 @@ export type ArtifactRenderer = {
33
35
  const mediaType = (attachment: Attachment) =>
34
36
  (attachment.contentType ?? contentTypeOf(attachment.name)).split(';')[0]!.trim().toLowerCase();
35
37
  const label = (attachment: Attachment) => attachment.description ?? attachment.name;
36
- /** Only addresses a browser may follow: never `javascript:` or `data:` from a producer. */
37
- function safeUri(uri: string | undefined): string | null {
38
- if (!uri) return null;
39
- try {
40
- const url = new URL(uri, 'https://atlas.invalid/');
41
- return url.protocol === 'http:' || url.protocol === 'https:' ? uri : null;
42
- } catch {
43
- return null;
44
- }
38
+ /** A video that plays inline; when the file cannot be loaded or this browser cannot play it, it says so and offers the file, instead of an empty player. */
39
+ function VideoPlayer({ attachment, url }: { attachment: Attachment; url: string }) {
40
+ const [failed, setFailed] = useState(false);
41
+ if (failed)
42
+ return (
43
+ <Alert role="alert" color="orange" title="This video cannot be played">
44
+ <Text component="div">
45
+ {label(attachment)} could not be loaded, or this browser cannot play its format. Nothing is wrong with the report: the file may be missing from the
46
+ store or in an unsupported codec.{' '}
47
+ <Anchor href={url} download={attachment.name} target="_blank" rel="noreferrer">
48
+ Download {attachment.name}
49
+ </Anchor>
50
+ </Text>
51
+ </Alert>
52
+ );
53
+ return <Box src={url} controls preload="metadata" aria-label={label(attachment)} component="video" maw="100%" w="100%" onError={() => setFailed(true)} />;
45
54
  }
46
55
  function TextValue({ attachment, path }: { attachment: Attachment; path: string | null }) {
47
56
  const stored = useQuery({
@@ -100,8 +109,7 @@ export const artifactRenderers: ArtifactRenderer[] = [
100
109
  id: 'video',
101
110
  wide: true,
102
111
  matches: (_, type) => type.startsWith('video/'),
103
- render: (a, { url }) =>
104
- url ? <Box src={url} controls preload="metadata" aria-label={label(a)} component="video" maw="100%" w="100%" /> : null
112
+ render: (a, { url }) => (url ? <VideoPlayer attachment={a} url={url} /> : null)
105
113
  },
106
114
  {
107
115
  id: 'audio',
@@ -138,7 +146,13 @@ function sizeOf(bytes: number | undefined) {
138
146
  if (bytes < 1024) return `${bytes} B`;
139
147
  return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
140
148
  }
141
- function Artifact({ attachment, layer }: { attachment: Attachment; layer: string }) {
149
+ /**
150
+ * One attachment as a card: it plays or shows itself when it can, and always says what it is (name, role, type, size,
151
+ * duration, tags) and how to get it. A file that has no address, has an address that is not a web address, or that
152
+ * nothing can preview says so in words. Pass `link` for a control that opens this one artifact, `note` for how it
153
+ * relates to the rest of the report, and `selected` to mark the one a link pointed at (it scrolls into view).
154
+ */
155
+ export function ArtifactCard({ attachment, layer, link, note, selected }: { attachment: Attachment; layer: string; link?: ReactNode; note?: ReactNode; selected?: boolean }) {
142
156
  const type = mediaType(attachment);
143
157
  const path = attachment.bundleKey
144
158
  ? `/layers/${encodeURIComponent(layer)}/blobs/${attachment.bundleKey.split('/').map(encodeURIComponent).join('/')}`
@@ -146,13 +160,28 @@ function Artifact({ attachment, layer }: { attachment: Attachment; layer: string
146
160
  const url = path ? reportAssetUrl(path) : safeUri(attachment.uri);
147
161
  const renderer = artifactRenderers.find((candidate) => candidate.matches(attachment, type));
148
162
  const body = renderer?.render(attachment, { url, path, type });
163
+ const item = useRef<HTMLLIElement>(null);
164
+ useEffect(() => {
165
+ if (selected) item.current?.scrollIntoView?.({ block: 'center' });
166
+ }, [selected]);
167
+ const scope = effectiveScope(attachment.scope, useScope());
168
+ // A recording says what it shows before it is played; evidence that declared a scope of its own says so too.
169
+ const scoped = isVideoAttachment(attachment) || attachment.scope !== undefined;
149
170
  return (
150
- <List.Item data-wide={renderer?.wide || undefined} data-role={attachment.role ?? roleOfType(type)}>
171
+ <List.Item
172
+ ref={item}
173
+ data-wide={renderer?.wide || undefined}
174
+ data-role={attachment.role ?? roleOfType(type)}
175
+ data-selected={selected || undefined}
176
+ {...(selected ? { bg: 'blue.0', p: 'sm', bd: '2px solid var(--mantine-color-blue-6)' } : {})}
177
+ >
178
+ {scoped && <ScopeNotice scope={scope} name={attachment.name} />}
151
179
  {body}
152
180
  <Box component="figcaption">
153
181
  <Text span inherit fw={600}>
154
182
  {attachment.name}
155
183
  </Text>
184
+ {selected && <Badge variant="light">Selected</Badge>}
156
185
  {attachment.role && (
157
186
  <Text span inherit>
158
187
  {attachment.role}
@@ -166,12 +195,21 @@ function Artifact({ attachment, layer }: { attachment: Attachment; layer: string
166
195
  {body ? 'Download' : 'Open'}
167
196
  </Anchor>
168
197
  )}
169
- {!url && attachment.text === undefined && (
170
- <Text span inherit>
171
- No file to show
172
- </Text>
173
- )}
198
+ {link}
174
199
  </Box>
200
+ {!url && attachment.text === undefined && (
201
+ <Alert role={selected ? 'alert' : undefined} color="orange" data-unavailable my="xs">
202
+ {attachment.uri
203
+ ? 'No file to show. This file is only given by an address that is not a web address, so Atlas will not follow it. Ask the producer for a web address or to upload the file.'
204
+ : 'No file to show. The report lists this file, but it was never stored, so it cannot be shown or downloaded. Publish it again with the file attached.'}
205
+ </Alert>
206
+ )}
207
+ {url && !renderer && (
208
+ <Text component="div" c="dimmed" data-no-preview>
209
+ Atlas has no preview for this kind of file. Open or download it to inspect it.
210
+ </Text>
211
+ )}
212
+ {note}
175
213
  <ArtifactTagList attachment={attachment} />
176
214
  </List.Item>
177
215
  );
@@ -186,7 +224,7 @@ export function Attachments({ attachments: all, layer }: { attachments: readonly
186
224
  </Title>
187
225
  <List spacing="xs" listStyleType="none">
188
226
  {attachments.map((attachment, index) => (
189
- <Artifact key={`${attachment.id ?? attachment.name}:${index}`} attachment={attachment} layer={layer} />
227
+ <ArtifactCard key={`${attachment.id ?? attachment.name}:${index}`} attachment={attachment} layer={layer} />
190
228
  ))}
191
229
  </List>
192
230
  </Box>
@@ -300,7 +338,7 @@ function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: strin
300
338
  </Title>
301
339
  <List spacing="xs" listStyleType="none" type="ordered">
302
340
  {attempts.map((attempt) => (
303
- <List.Item key={attempt.index}>
341
+ <List.Item key={attempt.index} data-attempt={attempt.index}>
304
342
  <Flex gap="xs" align="center" wrap="wrap">
305
343
  <StatusMark status={attempt.status} size={14} />
306
344
  <Text span inherit>
@@ -312,7 +350,9 @@ function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: strin
312
350
  </Text>
313
351
  </Flex>
314
352
  {attempt.detail && <Text component="div">{attempt.detail}</Text>}
315
- {attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
353
+ <ScopeProvider scope={attempt.scope}>
354
+ {attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
355
+ </ScopeProvider>
316
356
  </List.Item>
317
357
  ))}
318
358
  </List>
@@ -371,14 +411,14 @@ function Facts({ holder }: { holder: EvidenceHolder }) {
371
411
  }
372
412
  function Evidence({ holder, layer, steps }: { holder: EvidenceHolder; layer: string; steps?: boolean }) {
373
413
  return (
374
- <>
414
+ <ScopeProvider scope={holder.scope}>
375
415
  <Facts holder={holder} />
376
416
  {holder.annotations && <Notes notes={holder.annotations} />}
377
417
  {steps && holder.steps && holder.steps.length > 0 && <Timeline steps={holder.steps} layer={layer} />}
378
418
  {holder.attempts && holder.attempts.length > 1 && <Tries attempts={holder.attempts} layer={layer} />}
379
419
  {holder.attachments && <Attachments attachments={holder.attachments} layer={layer} />}
380
420
  {holder.extensions && <Raw extensions={holder.extensions} />}
381
- </>
421
+ </ScopeProvider>
382
422
  );
383
423
  }
384
424
  /** Everything one result carries beyond its outcome, read when it is opened, and what the run around it carried. */
@@ -408,7 +448,8 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
408
448
  What this result carries could not be read.
409
449
  </Text>
410
450
  );
411
- const { check, run } = detail.data;
451
+ const { check, run, inheritedScope } = detail.data;
452
+ const declared = effectiveScope(check.scope, inheritedScope);
412
453
  const hasRun = Object.values(run).some((value) =>
413
454
  Array.isArray(value) ? value.length > 0 : value && typeof value === 'object' ? Object.keys(value).length > 0 : false
414
455
  );
@@ -416,7 +457,14 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
416
457
  <ArtifactTagsProvider layer={layer.id} kind={row.kind} check={row.id} holders={[check, run]}>
417
458
  <Stack aria-label="Evidence" gap="sm" miw={0}>
418
459
  <TagFilterBar holders={[check, run]} />
419
- <Evidence holder={check} layer={layer.id} steps />
460
+ {declared && (
461
+ <Box aria-label="Scope of this result" component="section">
462
+ <ScopeNotice scope={declared} name={row.title} />
463
+ </Box>
464
+ )}
465
+ <ScopeProvider scope={inheritedScope}>
466
+ <Evidence holder={check} layer={layer.id} steps />
467
+ </ScopeProvider>
420
468
  {hasRun && (
421
469
  <Box aria-label="From the run" component="section" mb="lg">
422
470
  <Title order={4} mb="sm">
@@ -0,0 +1,106 @@
1
+ import { scopeBadges, scopeCaveats, scopeFacts, type ScopeBasis } from '@crvouga/atlas/reports/evidence-scope';
2
+ import type { EvidenceScope } from '@crvouga/atlas/schema';
3
+ import { Accordion, Anchor, Badge, Group, List, Stack, Table, Text } from '@mantine/core';
4
+ import { createContext, useContext, type ReactNode } from 'react';
5
+ import { safeUri } from './format';
6
+
7
+ /** The scope declared by the nearest holder around an attachment: its attempt, its check, or the run. */
8
+ const Around = createContext<EvidenceScope | undefined>(undefined);
9
+
10
+ /**
11
+ * Makes `scope` the one that applies to the attachments inside, unless it is undefined, in which case the scope
12
+ * declared further out still applies. The nearest declaration wins as a whole and is never mixed with another.
13
+ */
14
+ export function ScopeProvider({ scope, children }: { scope: EvidenceScope | undefined; children: ReactNode }) {
15
+ const outer = useContext(Around);
16
+ return <Around.Provider value={scope ?? outer}>{children}</Around.Provider>;
17
+ }
18
+
19
+ /** The scope in effect here, undefined when none was declared. */
20
+ export function useScope(): EvidenceScope | undefined {
21
+ return useContext(Around);
22
+ }
23
+
24
+ const BASIS_WORD: Record<ScopeBasis, string> = { verified: 'Verified', declared: 'Declared', undeclared: 'Not declared' };
25
+
26
+ /**
27
+ * What a recording does and does not show, in the producer's declaration: concise badges that are visible before it
28
+ * is played, and the exact declaration, caveats and provenance on demand. A recording that declared nothing says its
29
+ * scope is unknown. `scope` is the declaration that applies to this one source (see `effectiveScope`).
30
+ */
31
+ export function ScopeNotice({ scope, name }: { scope: EvidenceScope | undefined; name: string }) {
32
+ const badges = scopeBadges(scope);
33
+ const unknown = badges[0]?.dimension === 'unknown';
34
+ const links = (scope?.provenance ?? []).flatMap((link) => {
35
+ const url = safeUri(link.url);
36
+ return url ? [{ url, label: link.name ?? link.rel ?? link.url }] : [];
37
+ });
38
+ return (
39
+ <Stack gap="xs" aria-label={`Evidence scope of ${name}`} data-scope={unknown ? 'unknown' : 'declared'} mb="xs">
40
+ <Group gap="xs" role="list" aria-label="Scope badges">
41
+ {badges.map((badge) => (
42
+ <Badge
43
+ key={badge.text}
44
+ role="listitem"
45
+ variant={badge.basis === 'verified' ? 'filled' : 'light'}
46
+ color={unknown ? 'gray' : undefined}
47
+ tt="none"
48
+ data-basis={badge.basis}
49
+ >
50
+ {badge.text}
51
+ {badge.basis === 'verified' ? ' (verified)' : ''}
52
+ </Badge>
53
+ ))}
54
+ </Group>
55
+ <Accordion variant="separated">
56
+ <Accordion.Item value="scope">
57
+ <Accordion.Control>Scope details</Accordion.Control>
58
+ <Accordion.Panel>
59
+ <Stack gap="sm">
60
+ <Table.ScrollContainer minWidth={280}>
61
+ <Table aria-label="Declared scope">
62
+ <Table.Thead>
63
+ <Table.Tr>
64
+ <Table.Th>Dimension</Table.Th>
65
+ <Table.Th>Value</Table.Th>
66
+ <Table.Th>Basis</Table.Th>
67
+ </Table.Tr>
68
+ </Table.Thead>
69
+ <Table.Tbody>
70
+ {scopeFacts(scope).map((fact) => (
71
+ <Table.Tr key={fact.dimension}>
72
+ <Table.Td>{fact.label}</Table.Td>
73
+ <Table.Td>{fact.value}</Table.Td>
74
+ <Table.Td>{BASIS_WORD[fact.basis]}</Table.Td>
75
+ </Table.Tr>
76
+ ))}
77
+ </Table.Tbody>
78
+ </Table>
79
+ </Table.ScrollContainer>
80
+ <Text component="div">
81
+ {unknown ? 'The producer declared no scope for this recording. ' : scope?.declaredBy ? `Declared by ${scope.declaredBy}. ` : 'The producer declared this; Atlas did not check it. '}
82
+ {scope?.verified?.length ? `Verified by ${scope.verifiedBy ?? 'something independent of the producer'}: ${scope.verified.join(', ')}.` : ''}
83
+ </Text>
84
+ <List aria-label="What this does not show" spacing="xs">
85
+ {scopeCaveats(scope).map((caveat) => (
86
+ <List.Item key={caveat}>{caveat}</List.Item>
87
+ ))}
88
+ </List>
89
+ {links.length > 0 && (
90
+ <List aria-label="Provenance" spacing="xs">
91
+ {links.map((link) => (
92
+ <List.Item key={link.url}>
93
+ <Anchor href={link.url} target="_blank" rel="noreferrer">
94
+ {link.label}
95
+ </Anchor>
96
+ </List.Item>
97
+ ))}
98
+ </List>
99
+ )}
100
+ </Stack>
101
+ </Accordion.Panel>
102
+ </Accordion.Item>
103
+ </Accordion>
104
+ </Stack>
105
+ );
106
+ }
@@ -54,3 +54,14 @@ export function useNow(everyMs = 30_000) {
54
54
  }, [everyMs]);
55
55
  return now;
56
56
  }
57
+
58
+ /** Only addresses a browser may follow: never `javascript:` or `data:` from a producer. */
59
+ export function safeUri(uri: string | undefined): string | null {
60
+ if (!uri) return null;
61
+ try {
62
+ const url = new URL(uri, 'https://atlas.invalid/');
63
+ return url.protocol === 'http:' || url.protocol === 'https:' ? uri : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
@@ -142,10 +142,10 @@ export function Explorer() {
142
142
  <Title order={1} mb="sm">
143
143
  {projectTitle()}
144
144
  </Title>
145
- {model && model.tests.length > 0 && (
145
+ {model && (model.tests.length > 0 || model.summary.runs > 0) && (
146
146
  <Tabs
147
147
  value={search.tab ?? 'tests'}
148
- onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined })}
148
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined })}
149
149
  >
150
150
  <Tabs.List aria-label="Explorer view">
151
151
  <Tabs.Tab
@@ -212,7 +212,12 @@ export function Explorer() {
212
212
  Loading tests…
213
213
  </Text>
214
214
  )}
215
- {empty && !kinds.length && (
215
+ {empty && model && model.summary.runs > 0 && search.tab !== 'runs' && (
216
+ <Text component="div" c="dimmed">
217
+ No test results recorded, but {model.summary.runs === 1 ? '1 run carries' : `${model.summary.runs} runs carry`} files. Open the Runs tab to see them.
218
+ </Text>
219
+ )}
220
+ {empty && !kinds.length && !(model && model.summary.runs > 0) && (
216
221
  <Text component="div" c="dimmed">
217
222
  No reports yet. Publish tests and specs to this project to populate its report.
218
223
  </Text>
@@ -223,13 +228,12 @@ export function Explorer() {
223
228
  </Text>
224
229
  )}
225
230
  {model &&
226
- !empty &&
227
231
  listing &&
228
232
  (search.tab === 'runs' ? (
229
233
  <RunsView />
230
234
  ) : search.tab === 'artifacts' ? (
231
235
  <ArtifactsView />
232
- ) : (
236
+ ) : empty ? null : (
233
237
  <>
234
238
  <InsightStrip model={model} scope={listing.scope} search={search} />
235
239
  <Toolbar
@@ -511,6 +515,7 @@ function Toolbar({
511
515
  const chips = [
512
516
  ...values(search.spec).map((path) => ({ label: `Spec: ${specTitle(path)}`, clear: { spec: toggled(search.spec, path) } })),
513
517
  ...values(search.tag).map((tag) => ({ label: `Tag: ${tag}`, clear: { tag: toggled(search.tag, tag) } })),
518
+ ...values(search.scope).map((token) => ({ label: `Scope: ${token}`, clear: { scope: toggled(search.scope, token) } })),
514
519
  ...values(search.artifactTag).map((tag) => ({ label: `Artifact tag: #${tag}`, clear: { artifactTag: toggled(search.artifactTag, tag) } })),
515
520
  ...(search.inRun ? [{ label: `Run: ${runName(search.inRun)}`, clear: { inRun: undefined } }] : [])
516
521
  ];
@@ -654,6 +659,23 @@ function Toolbar({
654
659
  </NativeSelect>
655
660
  </Box>
656
661
  )}
662
+ {(model.facets.scopes?.length ?? 0) > 0 && (
663
+ <Box component="label">
664
+ <VisuallyHidden>Recording scope</VisuallyHidden>
665
+ <NativeSelect
666
+ aria-label="Recording scope"
667
+ value=""
668
+ onChange={(event) => event.target.value && go({ scope: toggled(search.scope, event.target.value) })}
669
+ >
670
+ <option value="">Any recording scope</option>
671
+ {model.facets.scopes!.filter(({ value }) => !values(search.scope).includes(value)).map(({ value, count }) => (
672
+ <option key={value} value={value}>
673
+ {value === 'unknown' ? 'Scope unknown' : value} ({count})
674
+ </option>
675
+ ))}
676
+ </NativeSelect>
677
+ </Box>
678
+ )}
657
679
  </Flex>
658
680
  <Flex role="status" gap="xs" align="center" wrap="wrap">
659
681
  <Text span inherit>
@@ -682,6 +704,7 @@ function Toolbar({
682
704
  domain: undefined,
683
705
  spec: undefined,
684
706
  tag: undefined,
707
+ scope: undefined,
685
708
  artifactTag: undefined,
686
709
  inherited: undefined,
687
710
  inRun: undefined
@@ -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
+ }