@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.
- package/CHANGELOG.md +12 -0
- package/dist/reports/src/evidence-detail.d.ts +37 -3
- package/dist/reports/src/evidence-detail.js +52 -20
- package/dist/reports/src/evidence-scope.d.ts +83 -0
- package/dist/reports/src/evidence-scope.js +129 -0
- package/dist/reports/src/evidence-types.d.ts +3 -1
- package/dist/reports/src/explorer-types.d.ts +1 -1
- package/dist/reports/src/export-evidence.js +1 -1
- package/dist/reports/src/layer-merge.js +3 -2
- package/dist/reports/src/layer-types.d.ts +1 -1
- package/dist/reports/src/layers-route.d.ts +2 -1
- package/dist/reports/src/layers-route.js +8 -4
- package/dist/reports/src/publish-checks.js +1 -0
- package/dist/reports/src/reporter.d.ts +1 -1
- package/dist/reports/src/reports-explorer-route.d.ts +276 -0
- package/dist/reports/src/reports-route.d.ts +276 -0
- package/dist/reports/src/test-catalog.d.ts +34 -1
- package/dist/reports/src/test-catalog.js +23 -5
- package/dist/schema/src/api.d.ts +542 -0
- package/dist/schema/src/api.js +17 -5
- package/dist/schema/src/layer.d.ts +981 -0
- package/dist/schema/src/layer.js +43 -4
- package/dist/server/src/openapi.js +3 -2
- package/docs/extending.md +1 -0
- package/docs/reference/http-api.md +6 -4
- package/docs/reference/reports.md +243 -14
- package/docs/reference/schema.md +23 -0
- package/docs/rich-evidence.md +70 -0
- package/docs/ui.md +1 -1
- package/llms-full.txt +78 -5
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +697 -1
- package/packages/schema/schemas/effective-delta.schema.json +192 -0
- package/packages/schema/schemas/effective-evidence.schema.json +192 -0
- package/packages/schema/schemas/evidence-page.schema.json +96 -0
- package/packages/schema/schemas/layer-rows.schema.json +96 -0
- package/packages/schema/schemas/layer.schema.json +1598 -62
- package/packages/schema/schemas/publish-checks.schema.json +768 -0
- package/packages/schema/schemas/report-run.schema.json +181 -0
- package/packages/schema/schemas/report-runs.schema.json +6 -0
- package/packages/schema/schemas/test-catalog.schema.json +128 -0
- package/packages/schema/schemas/test-history.schema.json +202 -0
- package/packages/server/openapi.json +2721 -48
- package/packages/visualizer/src/evidence/artifacts.tsx +77 -29
- package/packages/visualizer/src/evidence/evidence-scope.tsx +106 -0
- package/packages/visualizer/src/evidence/format.ts +11 -0
- package/packages/visualizer/src/explorer/Explorer.tsx +28 -5
- package/packages/visualizer/src/explorer/RunArtifacts.tsx +246 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +40 -9
- 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 {
|
|
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
|
-
/**
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
+
}
|