@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/reports/src/evidence-detail.d.ts +14 -3
  3. package/dist/reports/src/evidence-detail.js +33 -19
  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/layer-merge.js +3 -2
  9. package/dist/reports/src/layer-types.d.ts +1 -1
  10. package/dist/reports/src/publish-checks.js +1 -0
  11. package/dist/reports/src/reporter.d.ts +1 -1
  12. package/dist/reports/src/reports-explorer-route.d.ts +276 -0
  13. package/dist/reports/src/reports-route.d.ts +276 -0
  14. package/dist/reports/src/test-catalog.d.ts +7 -1
  15. package/dist/reports/src/test-catalog.js +15 -3
  16. package/dist/schema/src/api.d.ts +520 -0
  17. package/dist/schema/src/api.js +7 -4
  18. package/dist/schema/src/layer.d.ts +981 -0
  19. package/dist/schema/src/layer.js +43 -4
  20. package/dist/server/src/openapi.js +1 -0
  21. package/docs/extending.md +1 -0
  22. package/docs/reference/http-api.md +2 -0
  23. package/docs/reference/reports.md +179 -7
  24. package/docs/reference/schema.md +23 -0
  25. package/docs/rich-evidence.md +47 -0
  26. package/llms-full.txt +50 -0
  27. package/package.json +1 -1
  28. package/packages/schema/schemas/check-detail.schema.json +673 -1
  29. package/packages/schema/schemas/effective-delta.schema.json +192 -0
  30. package/packages/schema/schemas/effective-evidence.schema.json +192 -0
  31. package/packages/schema/schemas/evidence-page.schema.json +96 -0
  32. package/packages/schema/schemas/layer-rows.schema.json +96 -0
  33. package/packages/schema/schemas/layer.schema.json +1598 -62
  34. package/packages/schema/schemas/publish-checks.schema.json +768 -0
  35. package/packages/schema/schemas/report-run.schema.json +96 -0
  36. package/packages/schema/schemas/test-catalog.schema.json +128 -0
  37. package/packages/schema/schemas/test-history.schema.json +202 -0
  38. package/packages/server/openapi.json +2581 -23
  39. package/packages/visualizer/src/evidence/artifacts.tsx +24 -18
  40. package/packages/visualizer/src/evidence/evidence-scope.tsx +106 -0
  41. package/packages/visualizer/src/evidence/format.ts +11 -0
  42. package/packages/visualizer/src/explorer/Explorer.tsx +19 -0
  43. 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 { 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,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
- {attempt.attachments && <Attachments attachments={attempt.attachments} layer={layer} />}
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
- <Evidence holder={check} layer={layer.id} steps />
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. */