@crvouga/atlas 1.2.0 → 1.3.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 +6 -0
- package/dist/reports/src/evidence-detail.d.ts +14 -3
- package/dist/reports/src/evidence-detail.js +33 -19
- 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/layer-merge.js +3 -2
- package/dist/reports/src/layer-types.d.ts +1 -1
- 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 +7 -1
- package/dist/reports/src/test-catalog.js +15 -3
- package/dist/schema/src/api.d.ts +520 -0
- package/dist/schema/src/api.js +7 -4
- package/dist/schema/src/layer.d.ts +981 -0
- package/dist/schema/src/layer.js +43 -4
- package/dist/server/src/openapi.js +1 -0
- package/docs/extending.md +1 -0
- package/docs/reference/http-api.md +2 -0
- package/docs/reference/reports.md +179 -7
- package/docs/reference/schema.md +23 -0
- package/docs/rich-evidence.md +47 -0
- package/llms-full.txt +50 -0
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +673 -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 +96 -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 +2581 -23
- package/packages/visualizer/src/evidence/artifacts.tsx +24 -18
- 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 +19 -0
- package/packages/visualizer/src/explorer/search.ts +5 -1
|
@@ -1,5 +1,6 @@
|
|
|
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';
|
|
@@ -8,7 +9,8 @@ import { useQuery } from '@tanstack/react-query';
|
|
|
8
9
|
import { useMemo, 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,16 +35,6 @@ 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
|
-
}
|
|
45
|
-
}
|
|
46
38
|
function TextValue({ attachment, path }: { attachment: Attachment; path: string | null }) {
|
|
47
39
|
const stored = useQuery({
|
|
48
40
|
queryKey: ['artifact-text', path ?? ''],
|
|
@@ -146,8 +138,12 @@ function Artifact({ attachment, layer }: { attachment: Attachment; layer: string
|
|
|
146
138
|
const url = path ? reportAssetUrl(path) : safeUri(attachment.uri);
|
|
147
139
|
const renderer = artifactRenderers.find((candidate) => candidate.matches(attachment, type));
|
|
148
140
|
const body = renderer?.render(attachment, { url, path, type });
|
|
141
|
+
const scope = effectiveScope(attachment.scope, useScope());
|
|
142
|
+
// A recording says what it shows before it is played; evidence that declared a scope of its own says so too.
|
|
143
|
+
const scoped = isVideoAttachment(attachment) || attachment.scope !== undefined;
|
|
149
144
|
return (
|
|
150
145
|
<List.Item data-wide={renderer?.wide || undefined} data-role={attachment.role ?? roleOfType(type)}>
|
|
146
|
+
{scoped && <ScopeNotice scope={scope} name={attachment.name} />}
|
|
151
147
|
{body}
|
|
152
148
|
<Box component="figcaption">
|
|
153
149
|
<Text span inherit fw={600}>
|
|
@@ -300,7 +296,7 @@ function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: strin
|
|
|
300
296
|
</Title>
|
|
301
297
|
<List spacing="xs" listStyleType="none" type="ordered">
|
|
302
298
|
{attempts.map((attempt) => (
|
|
303
|
-
<List.Item key={attempt.index}>
|
|
299
|
+
<List.Item key={attempt.index} data-attempt={attempt.index}>
|
|
304
300
|
<Flex gap="xs" align="center" wrap="wrap">
|
|
305
301
|
<StatusMark status={attempt.status} size={14} />
|
|
306
302
|
<Text span inherit>
|
|
@@ -312,7 +308,9 @@ function Tries({ attempts, layer }: { attempts: readonly Attempt[]; layer: strin
|
|
|
312
308
|
</Text>
|
|
313
309
|
</Flex>
|
|
314
310
|
{attempt.detail && <Text component="div">{attempt.detail}</Text>}
|
|
315
|
-
|
|
311
|
+
<ScopeProvider scope={attempt.scope}>
|
|
312
|
+
{attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
|
|
313
|
+
</ScopeProvider>
|
|
316
314
|
</List.Item>
|
|
317
315
|
))}
|
|
318
316
|
</List>
|
|
@@ -371,14 +369,14 @@ function Facts({ holder }: { holder: EvidenceHolder }) {
|
|
|
371
369
|
}
|
|
372
370
|
function Evidence({ holder, layer, steps }: { holder: EvidenceHolder; layer: string; steps?: boolean }) {
|
|
373
371
|
return (
|
|
374
|
-
|
|
372
|
+
<ScopeProvider scope={holder.scope}>
|
|
375
373
|
<Facts holder={holder} />
|
|
376
374
|
{holder.annotations && <Notes notes={holder.annotations} />}
|
|
377
375
|
{steps && holder.steps && holder.steps.length > 0 && <Timeline steps={holder.steps} layer={layer} />}
|
|
378
376
|
{holder.attempts && holder.attempts.length > 1 && <Tries attempts={holder.attempts} layer={layer} />}
|
|
379
377
|
{holder.attachments && <Attachments attachments={holder.attachments} layer={layer} />}
|
|
380
378
|
{holder.extensions && <Raw extensions={holder.extensions} />}
|
|
381
|
-
|
|
379
|
+
</ScopeProvider>
|
|
382
380
|
);
|
|
383
381
|
}
|
|
384
382
|
/** Everything one result carries beyond its outcome, read when it is opened, and what the run around it carried. */
|
|
@@ -408,7 +406,8 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
|
|
|
408
406
|
What this result carries could not be read.
|
|
409
407
|
</Text>
|
|
410
408
|
);
|
|
411
|
-
const { check, run } = detail.data;
|
|
409
|
+
const { check, run, inheritedScope } = detail.data;
|
|
410
|
+
const declared = effectiveScope(check.scope, inheritedScope);
|
|
412
411
|
const hasRun = Object.values(run).some((value) =>
|
|
413
412
|
Array.isArray(value) ? value.length > 0 : value && typeof value === 'object' ? Object.keys(value).length > 0 : false
|
|
414
413
|
);
|
|
@@ -416,7 +415,14 @@ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerR
|
|
|
416
415
|
<ArtifactTagsProvider layer={layer.id} kind={row.kind} check={row.id} holders={[check, run]}>
|
|
417
416
|
<Stack aria-label="Evidence" gap="sm" miw={0}>
|
|
418
417
|
<TagFilterBar holders={[check, run]} />
|
|
419
|
-
|
|
418
|
+
{declared && (
|
|
419
|
+
<Box aria-label="Scope of this result" component="section">
|
|
420
|
+
<ScopeNotice scope={declared} name={row.title} />
|
|
421
|
+
</Box>
|
|
422
|
+
)}
|
|
423
|
+
<ScopeProvider scope={inheritedScope}>
|
|
424
|
+
<Evidence holder={check} layer={layer.id} steps />
|
|
425
|
+
</ScopeProvider>
|
|
420
426
|
{hasRun && (
|
|
421
427
|
<Box aria-label="From the run" component="section" mb="lg">
|
|
422
428
|
<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
|
+
}
|
|
@@ -511,6 +511,7 @@ function Toolbar({
|
|
|
511
511
|
const chips = [
|
|
512
512
|
...values(search.spec).map((path) => ({ label: `Spec: ${specTitle(path)}`, clear: { spec: toggled(search.spec, path) } })),
|
|
513
513
|
...values(search.tag).map((tag) => ({ label: `Tag: ${tag}`, clear: { tag: toggled(search.tag, tag) } })),
|
|
514
|
+
...values(search.scope).map((token) => ({ label: `Scope: ${token}`, clear: { scope: toggled(search.scope, token) } })),
|
|
514
515
|
...values(search.artifactTag).map((tag) => ({ label: `Artifact tag: #${tag}`, clear: { artifactTag: toggled(search.artifactTag, tag) } })),
|
|
515
516
|
...(search.inRun ? [{ label: `Run: ${runName(search.inRun)}`, clear: { inRun: undefined } }] : [])
|
|
516
517
|
];
|
|
@@ -654,6 +655,23 @@ function Toolbar({
|
|
|
654
655
|
</NativeSelect>
|
|
655
656
|
</Box>
|
|
656
657
|
)}
|
|
658
|
+
{(model.facets.scopes?.length ?? 0) > 0 && (
|
|
659
|
+
<Box component="label">
|
|
660
|
+
<VisuallyHidden>Recording scope</VisuallyHidden>
|
|
661
|
+
<NativeSelect
|
|
662
|
+
aria-label="Recording scope"
|
|
663
|
+
value=""
|
|
664
|
+
onChange={(event) => event.target.value && go({ scope: toggled(search.scope, event.target.value) })}
|
|
665
|
+
>
|
|
666
|
+
<option value="">Any recording scope</option>
|
|
667
|
+
{model.facets.scopes!.filter(({ value }) => !values(search.scope).includes(value)).map(({ value, count }) => (
|
|
668
|
+
<option key={value} value={value}>
|
|
669
|
+
{value === 'unknown' ? 'Scope unknown' : value} ({count})
|
|
670
|
+
</option>
|
|
671
|
+
))}
|
|
672
|
+
</NativeSelect>
|
|
673
|
+
</Box>
|
|
674
|
+
)}
|
|
657
675
|
</Flex>
|
|
658
676
|
<Flex role="status" gap="xs" align="center" wrap="wrap">
|
|
659
677
|
<Text span inherit>
|
|
@@ -682,6 +700,7 @@ function Toolbar({
|
|
|
682
700
|
domain: undefined,
|
|
683
701
|
spec: undefined,
|
|
684
702
|
tag: undefined,
|
|
703
|
+
scope: undefined,
|
|
685
704
|
artifactTag: undefined,
|
|
686
705
|
inherited: undefined,
|
|
687
706
|
inRun: undefined
|
|
@@ -35,6 +35,8 @@ export const ExplorerSearchFields = {
|
|
|
35
35
|
domain: text,
|
|
36
36
|
spec: text,
|
|
37
37
|
tag: text,
|
|
38
|
+
/** Only tests with one recording that declares every one of these scope tokens (`device:physical,audio:recorded`). */
|
|
39
|
+
scope: text,
|
|
38
40
|
/** Artifact tags (`demo`, shown `#demo`): tests with an attachment carrying one; runs and artifacts likewise. */
|
|
39
41
|
artifactTag: text,
|
|
40
42
|
/** With `artifactTag`: also the tags attachments inherit from their check and run. */
|
|
@@ -74,6 +76,7 @@ export type ExplorerSearch = {
|
|
|
74
76
|
readonly domain?: string;
|
|
75
77
|
readonly spec?: string;
|
|
76
78
|
readonly tag?: string;
|
|
79
|
+
readonly scope?: string;
|
|
77
80
|
readonly artifactTag?: string;
|
|
78
81
|
readonly inherited?: true;
|
|
79
82
|
readonly type?: string;
|
|
@@ -117,6 +120,7 @@ export function queryOf(search: ExplorerSearch): TestQuery {
|
|
|
117
120
|
domain: values(search.domain),
|
|
118
121
|
spec: values(search.spec),
|
|
119
122
|
tag: values(search.tag),
|
|
123
|
+
...(values(search.scope).length ? { scope: values(search.scope) } : {}),
|
|
120
124
|
artifactTag: values(search.artifactTag),
|
|
121
125
|
...(search.inherited ? { inherited: true } : {}),
|
|
122
126
|
sort: search.sort ?? 'spec'
|
|
@@ -130,7 +134,7 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
|
|
|
130
134
|
|
|
131
135
|
/** True when any filter or search narrows the list. */
|
|
132
136
|
export function narrowed(search: ExplorerSearch): boolean {
|
|
133
|
-
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.artifactTag || search.inRun);
|
|
137
|
+
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun);
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
/** The filters of the Runs and Artifacts tabs, in the address. */
|