@crvouga/atlas 1.1.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 (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/artifact-query.d.ts +123 -0
  3. package/dist/reports/src/artifact-query.js +234 -0
  4. package/dist/reports/src/evidence-detail.d.ts +14 -3
  5. package/dist/reports/src/evidence-detail.js +34 -19
  6. package/dist/reports/src/evidence-scope.d.ts +83 -0
  7. package/dist/reports/src/evidence-scope.js +129 -0
  8. package/dist/reports/src/evidence-types.d.ts +5 -1
  9. package/dist/reports/src/explorer-types.d.ts +1 -1
  10. package/dist/reports/src/layer-merge.js +3 -2
  11. package/dist/reports/src/layer-types.d.ts +1 -1
  12. package/dist/reports/src/publish-checks.js +1 -0
  13. package/dist/reports/src/reporter.d.ts +1 -1
  14. package/dist/reports/src/reports-explorer-route.d.ts +276 -0
  15. package/dist/reports/src/reports-route.d.ts +276 -0
  16. package/dist/reports/src/test-catalog.d.ts +108 -6
  17. package/dist/reports/src/test-catalog.js +155 -8
  18. package/dist/schema/src/api.d.ts +745 -0
  19. package/dist/schema/src/api.js +48 -6
  20. package/dist/schema/src/layer.d.ts +981 -0
  21. package/dist/schema/src/layer.js +43 -4
  22. package/dist/server/src/app.js +16 -5
  23. package/dist/server/src/openapi.js +42 -3
  24. package/docs/extending.md +1 -0
  25. package/docs/reference/http-api.md +86 -2
  26. package/docs/reference/reports.md +550 -14
  27. package/docs/reference/schema.md +55 -0
  28. package/docs/rich-evidence.md +78 -0
  29. package/docs/server.md +4 -1
  30. package/docs/ui.md +1 -1
  31. package/llms-full.txt +170 -4
  32. package/package.json +1 -1
  33. package/packages/schema/schemas/check-detail.schema.json +673 -1
  34. package/packages/schema/schemas/effective-delta.schema.json +206 -0
  35. package/packages/schema/schemas/effective-evidence.schema.json +206 -0
  36. package/packages/schema/schemas/evidence-page.schema.json +103 -0
  37. package/packages/schema/schemas/layer-rows.schema.json +103 -0
  38. package/packages/schema/schemas/layer.schema.json +1598 -62
  39. package/packages/schema/schemas/publish-checks.schema.json +768 -0
  40. package/packages/schema/schemas/report-run.schema.json +131 -0
  41. package/packages/schema/schemas/report-runs.schema.json +221 -0
  42. package/packages/schema/schemas/test-catalog.schema.json +135 -0
  43. package/packages/schema/schemas/test-history.schema.json +216 -0
  44. package/packages/server/openapi.json +4731 -735
  45. package/packages/visualizer/src/evidence/artifacts.tsx +24 -18
  46. package/packages/visualizer/src/evidence/evidence-scope.tsx +106 -0
  47. package/packages/visualizer/src/evidence/format.ts +11 -0
  48. package/packages/visualizer/src/explorer/ArtifactsView.tsx +162 -0
  49. package/packages/visualizer/src/explorer/Explorer.tsx +47 -3
  50. package/packages/visualizer/src/explorer/RunsView.tsx +43 -19
  51. package/packages/visualizer/src/explorer/catalog.tsx +97 -12
  52. package/packages/visualizer/src/explorer/filters.tsx +279 -0
  53. package/packages/visualizer/src/explorer/search.ts +85 -6
  54. package/packages/visualizer/src/explorer/views.ts +75 -0
@@ -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
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @module Artifacts view
3
+ *
4
+ * Every recorded result that carries evidence (recordings, screenshots, traces) across the project's history, newest first,
5
+ * found by artifact tag, media type, label, feature, environment, branch, outcome and time. A result is listed with the
6
+ * run, commit and status it was recorded with, so a recording tagged `demo` is found even when a later attempt is
7
+ * untagged. Each row opens that exact result in the test detail. An empty answer for a filter says nothing matches and
8
+ * offers to clear it; it never says the project has nothing recorded.
9
+ */
10
+ import { IconVideo } from '@tabler/icons-react';
11
+ import { Alert, Anchor, Box, Button, Code, Stack, Table, Text } from '@mantine/core';
12
+ import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
13
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
14
+ import { Link } from '@tanstack/react-router';
15
+ import { useMemo, useState } from 'react';
16
+
17
+ import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
18
+ import { StatusMark } from '../evidence/marks';
19
+ import { useArtifactList } from './catalog';
20
+ import { FilterBar } from './filters';
21
+ import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
22
+ import { runName } from './parts';
23
+ import { filtered, filterOf, NO_FILTERS } from './search';
24
+
25
+ const ARTIFACT_ROWS = 50;
26
+
27
+ /** The Artifacts tab: recorded results that carry evidence, filtered by the address. */
28
+ export function ArtifactsView() {
29
+ const search = useExplorerSearch();
30
+ const go = useExplorerNavigate();
31
+ const filter = useMemo(() => filterOf(search), [search]);
32
+ const narrow = filtered(search);
33
+ const answer = useArtifactList(filter);
34
+ const now = useNow();
35
+ const [shown, setShown] = useState(ARTIFACT_ROWS);
36
+ if (answer.isPending)
37
+ return (
38
+ <Text role="status" component="div" c="dimmed">
39
+ Reading the artifacts…
40
+ </Text>
41
+ );
42
+ if (answer.isError)
43
+ return (
44
+ <Alert role="alert" color="red" my="sm">
45
+ The artifacts could not be read. {answer.error.message}
46
+ </Alert>
47
+ );
48
+ if (answer.data.unsupported)
49
+ return (
50
+ <Alert role="status" color="blue" my="sm" data-unsupported>
51
+ This server cannot search artifacts, so nothing is shown here rather than a list that looks narrowed. Update the server to find recordings by tag, type and label; the Tests tab still filters by artifact tag.
52
+ </Alert>
53
+ );
54
+ const { list } = answer.data;
55
+ return (
56
+ <Box aria-label="Artifacts" component="section" mb="lg">
57
+ {(narrow || list.results.length > 0) && <FilterBar facets={list.facets} search={search} tab="artifacts" />}
58
+ {!list.results.length && !narrow && (
59
+ <Text component="div" c="dimmed">
60
+ No recorded result carries evidence yet. Recordings, screenshots and traces published with a result are listed here.
61
+ </Text>
62
+ )}
63
+ {list.results.length > 0 && (
64
+ <Text role="status" component="div">
65
+ {list.total > list.results.length
66
+ ? `The newest ${list.results.length} of ${plural(list.total, 'result', 'results')}${narrow ? ' matching' : ''}`
67
+ : `${plural(list.results.length, 'result', 'results')}${narrow ? ' matching' : ''}`}
68
+ </Text>
69
+ )}
70
+ {!list.results.length && narrow && (
71
+ <Stack gap="xs" align="flex-start" role="status" data-no-match>
72
+ <Text component="div" c="dimmed">
73
+ Nothing matches these filters.
74
+ </Text>
75
+ <Button type="button" variant="light" onClick={() => go(NO_FILTERS)}>
76
+ Clear filters
77
+ </Button>
78
+ </Stack>
79
+ )}
80
+ {list.results.length > 0 && (
81
+ <Table.ScrollContainer minWidth={700}>
82
+ <Table data-artifacts>
83
+ <Table.Thead>
84
+ <Table.Tr>
85
+ <Table.Th scope="col">Recorded</Table.Th>
86
+ <Table.Th scope="col">Test</Table.Th>
87
+ <Table.Th scope="col">Outcome</Table.Th>
88
+ <Table.Th scope="col">Environment</Table.Th>
89
+ <Table.Th scope="col">Branch</Table.Th>
90
+ <Table.Th scope="col">Commit</Table.Th>
91
+ <Table.Th scope="col">Run</Table.Th>
92
+ <Table.Th scope="col">Evidence</Table.Th>
93
+ <Table.Th scope="col">Tags</Table.Th>
94
+ </Table.Tr>
95
+ </Table.Thead>
96
+ <Table.Tbody>
97
+ {list.results.slice(0, shown).map((result) => {
98
+ const videos = videoCounts(result.videos);
99
+ return (
100
+ <Table.Tr key={`${result.layer}|${result.key}|${result.kind}|${result.producer}|${result.environment}`} data-status={result.status}>
101
+ <Table.Td title={exactTime(result.at)}>{ago(result.at, now)}</Table.Td>
102
+ <Table.Td>
103
+ <Anchor
104
+ aria-label={`Open ${result.title} as recorded in run ${runName(result.run)}`}
105
+ renderRoot={(props) => (
106
+ <Link
107
+ {...props}
108
+ to="/"
109
+ search={patched({ tab: undefined, openRun: undefined, test: result.key, layer: result.layer })}
110
+ resetScroll={false}
111
+ activeOptions={{ exact: true }}
112
+ />
113
+ )}
114
+ >
115
+ {result.title}
116
+ </Anchor>
117
+ <Text c="dimmed" fz="xs">
118
+ {result.producer}
119
+ </Text>
120
+ </Table.Td>
121
+ <Table.Td>
122
+ <StatusMark status={result.status} size={14} /> {statusWord(result.status)}
123
+ </Table.Td>
124
+ <Table.Td>{environmentLabel(result.environment)}</Table.Td>
125
+ <Table.Td c="dimmed">{result.branch ?? '—'}</Table.Td>
126
+ <Table.Td>
127
+ <Code>{shortSha(result.sha) ?? '—'}</Code>
128
+ </Table.Td>
129
+ <Table.Td>
130
+ <Anchor
131
+ aria-label={`Open run ${runName(result.run)}`}
132
+ renderRoot={(props) => (
133
+ <Link {...props} to="/" search={patched({ tab: 'runs', openRun: result.run, test: undefined, layer: undefined, video: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
134
+ )}
135
+ >
136
+ {runName(result.run)}
137
+ </Anchor>
138
+ </Table.Td>
139
+ <Table.Td>
140
+ {result.types.join(', ') || '—'}
141
+ {videos && videos.recordings > 0 && (
142
+ <Text component="div" size="xs" c="dimmed" data-video="available">
143
+ <IconVideo size={16} aria-hidden="true" /> {plural(videos.recordings, 'video', 'videos')}
144
+ </Text>
145
+ )}
146
+ </Table.Td>
147
+ <Table.Td data-tags>{result.artifactTags.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
148
+ </Table.Tr>
149
+ );
150
+ })}
151
+ </Table.Tbody>
152
+ </Table>
153
+ </Table.ScrollContainer>
154
+ )}
155
+ {list.results.length > shown && (
156
+ <Button type="button" onClick={() => setShown(shown + ARTIFACT_ROWS)} variant="subtle">
157
+ Show more ({list.results.length - shown} more)
158
+ </Button>
159
+ )}
160
+ </Box>
161
+ );
162
+ }
@@ -12,6 +12,7 @@ import {
12
12
  Flex,
13
13
  Group,
14
14
  List,
15
+ MultiSelect,
15
16
  NativeSelect,
16
17
  Paper,
17
18
  SimpleGrid,
@@ -53,8 +54,9 @@ import { useRunDetail, useTestCatalog } from './catalog';
53
54
  import { groupTests, tally, type TestGroupView } from './groups';
54
55
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
55
56
  import { Badges, EnvironmentChips, projectTitle, runName } from './parts';
57
+ import { ArtifactsView } from './ArtifactsView';
56
58
  import { RunsView } from './RunsView';
57
- import { narrowed, queryOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type TestGroup } from './search';
59
+ import { joined, narrowed, queryOf, TEST_GROUPS, toggled, values, type ExplorerSearch, type TestGroup } from './search';
58
60
  import { TestDetail } from './TestDetail';
59
61
  /** Groups start closed when there are more of them than this, unless the list is short. */
60
62
  const OPEN_GROUPS = 40;
@@ -132,7 +134,7 @@ export function Explorer() {
132
134
  const model = catalog.model;
133
135
  const selected = search.test ? (model?.byKey.get(search.test) ?? null) : null;
134
136
  const empty = model !== null && model.tests.length === 0;
135
- const split = !!search.test && search.tab !== 'runs';
137
+ const split = !!search.test && search.tab !== 'runs' && search.tab !== 'artifacts';
136
138
  return (
137
139
  <Container size="xl" py="xl">
138
140
  <Box aria-label="Test explorer" component="article" miw={0}>
@@ -143,7 +145,7 @@ export function Explorer() {
143
145
  {model && model.tests.length > 0 && (
144
146
  <Tabs
145
147
  value={search.tab ?? 'tests'}
146
- onChange={(value) => go({ tab: value === 'runs' ? 'runs' : undefined, test: undefined, openRun: undefined, layer: undefined })}
148
+ onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' ? value : undefined, test: undefined, openRun: undefined, layer: undefined })}
147
149
  >
148
150
  <Tabs.List aria-label="Explorer view">
149
151
  <Tabs.Tab
@@ -166,6 +168,7 @@ export function Explorer() {
166
168
  >
167
169
  Runs
168
170
  </Tabs.Tab>
171
+ <Tabs.Tab value="artifacts">Artifacts</Tabs.Tab>
169
172
  </Tabs.List>
170
173
  </Tabs>
171
174
  )}
@@ -224,6 +227,8 @@ export function Explorer() {
224
227
  listing &&
225
228
  (search.tab === 'runs' ? (
226
229
  <RunsView />
230
+ ) : search.tab === 'artifacts' ? (
231
+ <ArtifactsView />
227
232
  ) : (
228
233
  <>
229
234
  <InsightStrip model={model} scope={listing.scope} search={search} />
@@ -506,6 +511,8 @@ function Toolbar({
506
511
  const chips = [
507
512
  ...values(search.spec).map((path) => ({ label: `Spec: ${specTitle(path)}`, clear: { spec: toggled(search.spec, path) } })),
508
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) } })),
515
+ ...values(search.artifactTag).map((tag) => ({ label: `Artifact tag: #${tag}`, clear: { artifactTag: toggled(search.artifactTag, tag) } })),
509
516
  ...(search.inRun ? [{ label: `Run: ${runName(search.inRun)}`, clear: { inRun: undefined } }] : [])
510
517
  ];
511
518
  return (
@@ -614,6 +621,23 @@ function Toolbar({
614
621
  </NativeSelect>
615
622
  </Box>
616
623
  )}
624
+ {((model.facets.artifactTags?.length ?? 0) > 0 || values(search.artifactTag).length > 0) && (
625
+ <MultiSelect
626
+ aria-label="Artifact tag"
627
+ placeholder={values(search.artifactTag).length ? undefined : 'Any artifact tag'}
628
+ data={[
629
+ ...(model.facets.artifactTags ?? []).map(({ value, count }) => ({ value, label: `#${value} (${count})` })),
630
+ ...values(search.artifactTag)
631
+ .filter((tag) => !model.facets.artifactTags?.some((facet) => facet.value === tag))
632
+ .map((tag) => ({ value: tag, label: `#${tag} (0)` }))
633
+ ]}
634
+ value={values(search.artifactTag)}
635
+ searchable
636
+ clearable
637
+ nothingFoundMessage="Nothing found"
638
+ onChange={(next) => go({ artifactTag: joined(next) })}
639
+ />
640
+ )}
617
641
  {model.facets.domains.length > 1 && (
618
642
  <Box component="label">
619
643
  <VisuallyHidden>Domain</VisuallyHidden>
@@ -631,6 +655,23 @@ function Toolbar({
631
655
  </NativeSelect>
632
656
  </Box>
633
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
+ )}
634
675
  </Flex>
635
676
  <Flex role="status" gap="xs" align="center" wrap="wrap">
636
677
  <Text span inherit>
@@ -659,6 +700,9 @@ function Toolbar({
659
700
  domain: undefined,
660
701
  spec: undefined,
661
702
  tag: undefined,
703
+ scope: undefined,
704
+ artifactTag: undefined,
705
+ inherited: undefined,
662
706
  inRun: undefined
663
707
  });
664
708
  }}
@@ -12,14 +12,17 @@ import { Alert, Anchor, Box, Button, Code, Flex, Group, List, Stack, Table, Text
12
12
  import { videoCounts } from '@crvouga/atlas/reports/evidence-detail';
13
13
  import type { VideoCounts, VideoRef } from '@crvouga/atlas/reports/evidence-types';
14
14
  import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
15
+ import type { FilterName } from '@crvouga/atlas/reports/artifact-query';
15
16
  import { formatMs, type RunResult, type RunSummary } from '@crvouga/atlas/reports/test-catalog';
16
17
  import { Link } from '@tanstack/react-router';
17
18
  import { useMemo, useState } from 'react';
18
19
  import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
19
20
  import { Meter, StatusMark } from '../evidence/marks';
20
21
  import { useRunDetail, useRunList } from './catalog';
22
+ import { FilterBar } from './filters';
21
23
  import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
22
24
  import { runName } from './parts';
25
+ import { filtered, filterOf, NO_FILTERS } from './search';
23
26
  const RUN_ROWS = 50;
24
27
  const RESULT_ROWS = 200;
25
28
  /** The Runs tab: the run list, or the run opened in it. */
@@ -118,11 +121,25 @@ function HasVideo({ count, pressed, noun, onToggle }: { count: number; pressed:
118
121
  </Button>
119
122
  );
120
123
  }
124
+ /** What a filter is called when a page says a server could not apply it. */
125
+ const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec' };
126
+ /** Says which filters the list is not narrowed by, because the server that answered ignored them. */
127
+ export function NotApplied({ names, noun }: { names: readonly FilterName[]; noun: string }) {
128
+ if (!names.length) return null;
129
+ return (
130
+ <Alert role="status" color="blue" my="sm" data-not-applied>
131
+ This server cannot filter {noun} by {names.map((name) => FILTER_WORDS[name] ?? name).join(', ')}, so the list below is not narrowed by {names.length > 1 ? 'them' : 'it'}. Update the server to use{' '}
132
+ {names.length > 1 ? 'these filters' : 'this filter'}.
133
+ </Alert>
134
+ );
135
+ }
121
136
  function RunTable() {
122
137
  const search = useExplorerSearch();
123
138
  const go = useExplorerNavigate();
124
139
  const video = search.video === true;
125
- const runs = useRunList(true, video);
140
+ const filter = useMemo(() => filterOf(search), [search]);
141
+ const narrow = filtered(search);
142
+ const runs = useRunList(true, filter);
126
143
  const now = useNow();
127
144
  const [shown, setShown] = useState(RUN_ROWS);
128
145
  if (runs.isPending)
@@ -139,29 +156,34 @@ function RunTable() {
139
156
  );
140
157
  const list = runs.data.runs;
141
158
  const withVideo = runs.data.withVideo;
142
- if (!list.length && !video)
143
- return (
144
- <Text component="div" c="dimmed">
145
- No runs recorded yet.
146
- </Text>
147
- );
148
- const noun = video ? 'with a video' : '';
159
+ const noun = narrow ? 'matching' : '';
149
160
  return (
150
161
  <Box aria-label="Runs" component="section" mb="lg">
151
- <Flex gap="xs" align="center" wrap="wrap">
152
- {runs.data.total > list.length
153
- ? `The newest ${list.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
154
- : `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
155
- {(withVideo !== undefined || video) && (
156
- <HasVideo count={withVideo ?? list.length} pressed={video} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} />
157
- )}
158
- </Flex>
159
- {!list.length && (
162
+ {(narrow || list.length > 0) && <FilterBar facets={runs.data.facets} search={search} tab="runs" extra={withVideo !== undefined || video ? <HasVideo count={withVideo ?? list.length} pressed={video} noun={['run', 'runs']} onToggle={() => go({ video: video ? undefined : true })} /> : undefined} />}
163
+ <NotApplied names={runs.data.notApplied} noun="runs" />
164
+ {!list.length && !narrow && (
160
165
  <Text component="div" c="dimmed">
161
- No run has a playable video recording.{' '}
162
- {runs.data.withVideo === undefined && 'This server does not report which runs have recordings.'}
166
+ No runs recorded yet.
167
+ </Text>
168
+ )}
169
+ {list.length > 0 && (
170
+ <Text role="status" component="div">
171
+ {runs.data.total > list.length
172
+ ? `The newest ${list.length} of ${plural(runs.data.total, 'run', 'runs')} ${noun}`.trim()
173
+ : `${plural(list.length, 'run', 'runs')} ${noun}`.trim()}
163
174
  </Text>
164
175
  )}
176
+ {!list.length && narrow && (
177
+ <Stack gap="xs" align="flex-start" role="status" data-no-match>
178
+ <Text component="div" c="dimmed">
179
+ No run matches these filters.
180
+ {video && withVideo === undefined && ' This server does not report which runs have recordings.'}
181
+ </Text>
182
+ <Button type="button" variant="light" onClick={() => go(NO_FILTERS)}>
183
+ Clear filters
184
+ </Button>
185
+ </Stack>
186
+ )}
165
187
  <Table.ScrollContainer minWidth={600}>
166
188
  <Table data-runs>
167
189
  <Table.Thead>
@@ -174,6 +196,7 @@ function RunTable() {
174
196
  <Table.Th scope="col">Outcomes</Table.Th>
175
197
  <Table.Th scope="col">Span</Table.Th>
176
198
  <Table.Th scope="col">Video</Table.Th>
199
+ <Table.Th scope="col">Tags</Table.Th>
177
200
  <Table.Th scope="col">
178
201
  <VisuallyHidden>CI run</VisuallyHidden>
179
202
  </Table.Th>
@@ -215,6 +238,7 @@ function RunTable() {
215
238
  <Table.Td>
216
239
  <RunVideos run={run} />
217
240
  </Table.Td>
241
+ <Table.Td data-tags>{run.artifactTags?.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
218
242
  <Table.Td>
219
243
  {run.runUrl ? (
220
244
  <Anchor href={run.runUrl} target="_blank" rel="noreferrer">