@crvouga/atlas 1.0.0 → 1.2.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 (45) 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.js +2 -1
  5. package/dist/reports/src/evidence-types.d.ts +2 -0
  6. package/dist/reports/src/test-catalog.d.ts +101 -5
  7. package/dist/reports/src/test-catalog.js +140 -5
  8. package/dist/schema/src/api.d.ts +225 -0
  9. package/dist/schema/src/api.js +41 -2
  10. package/dist/server/src/app.js +16 -5
  11. package/dist/server/src/openapi.js +41 -3
  12. package/docs/reference/http-api.md +84 -2
  13. package/docs/reference/reports.md +371 -7
  14. package/docs/reference/schema.md +32 -0
  15. package/docs/rich-evidence.md +31 -0
  16. package/docs/server.md +4 -1
  17. package/docs/ui.md +8 -1
  18. package/llms-full.txt +127 -4
  19. package/package.json +1 -1
  20. package/packages/schema/schemas/effective-delta.schema.json +14 -0
  21. package/packages/schema/schemas/effective-evidence.schema.json +14 -0
  22. package/packages/schema/schemas/evidence-page.schema.json +7 -0
  23. package/packages/schema/schemas/layer-rows.schema.json +7 -0
  24. package/packages/schema/schemas/report-run.schema.json +35 -0
  25. package/packages/schema/schemas/report-runs.schema.json +221 -0
  26. package/packages/schema/schemas/test-catalog.schema.json +7 -0
  27. package/packages/schema/schemas/test-history.schema.json +14 -0
  28. package/packages/server/openapi.json +2473 -1035
  29. package/packages/visualizer/src/evidence/check-detail.tsx +6 -1
  30. package/packages/visualizer/src/evidence/evidence-context.tsx +9 -2
  31. package/packages/visualizer/src/evidence/load-state.tsx +65 -0
  32. package/packages/visualizer/src/evidence/overview.tsx +4 -6
  33. package/packages/visualizer/src/evidence/recording-page.tsx +17 -7
  34. package/packages/visualizer/src/evidence/spec-header.tsx +8 -6
  35. package/packages/visualizer/src/evidence/spec-page.tsx +98 -15
  36. package/packages/visualizer/src/explorer/ArtifactsView.tsx +162 -0
  37. package/packages/visualizer/src/explorer/Explorer.tsx +28 -3
  38. package/packages/visualizer/src/explorer/RunsView.tsx +43 -19
  39. package/packages/visualizer/src/explorer/catalog.tsx +97 -12
  40. package/packages/visualizer/src/explorer/filters.tsx +279 -0
  41. package/packages/visualizer/src/explorer/search.ts +81 -6
  42. package/packages/visualizer/src/explorer/views.ts +75 -0
  43. package/packages/visualizer/src/find/Finder.tsx +45 -7
  44. package/packages/visualizer/src/spec-kind.test.ts +14 -1
  45. package/packages/visualizer/src/spec-kind.ts +28 -2
@@ -303,7 +303,7 @@ export function CheckDetail(props: Props) {
303
303
  );
304
304
  }
305
305
  function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pin }: Props) {
306
- const { evidence, backends } = useEvidence();
306
+ const { evidence, backends, ready, pending } = useEvidence();
307
307
  const now = useNow();
308
308
  const [variant, setVariant] = useState(0);
309
309
  const history = useMemo(() => checkHistory(evidence, slot.specPath, slot.id), [evidence, slot.specPath, slot.id]);
@@ -350,6 +350,11 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pi
350
350
  {note}
351
351
  </Alert>
352
352
  )}
353
+ {layerId && !earlier && ready && pending === 0 && (
354
+ <Alert role="status" color={'blue'} my="sm">
355
+ The layer this link names is not among the results held for this check. Showing the current result instead.
356
+ </Alert>
357
+ )}
353
358
  {failure && (
354
359
  <Code block c="red.7">
355
360
  {failure}
@@ -36,6 +36,8 @@ export type EvidenceState = {
36
36
  readonly retry: () => void;
37
37
  /** Layers whose checks are still arriving. */
38
38
  readonly pending: number;
39
+ /** Layers whose checks could not be read. The report shown leaves them out, so it may be incomplete. */
40
+ readonly failed: readonly LayerHeader[];
39
41
  /** Every distinct layer, whether or not it contributes. */
40
42
  readonly layers: readonly LayerHeader[];
41
43
  /** The layers that contribute, with their checks, once read. */
@@ -175,7 +177,7 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
175
177
  staleTime: Number.POSITIVE_INFINITY,
176
178
  retry: 1
177
179
  })),
178
- combine: (results) => ({ data: results.map((result) => result.data), stamp: results.map((result) => result.dataUpdatedAt).join(',') })
180
+ combine: (results) => ({ data: results.map((result) => result.data), failed: results.map((result) => result.isError && result.data === undefined), stamp: results.map((result) => result.dataUpdatedAt).join(',') })
179
181
  });
180
182
 
181
183
  // A running suite's layer is re-read as tests finish. Its last read stays on screen meanwhile.
@@ -195,6 +197,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
195
197
  // `stamp` changes exactly when a layer's rows do.
196
198
  }, [wanted, rows.stamp]);
197
199
 
200
+ const failed = useMemo(() => wanted.filter((_, i) => rows.failed[i]), [wanted, rows.failed]);
201
+
198
202
  const shown = effective ? overview.data?.layers ?? NONE : evidence;
199
203
  const { tests: liveTests } = useLiveTests();
200
204
  const view = useMemo(() => withLiveProgress(evidenceView(combineEvidence(outline, shown)), liveTests), [outline, shown, liveTests]);
@@ -206,11 +210,14 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
206
210
  unavailable: effective ? (overview.data === undefined && overview.error ? overview.error.message : null) : pages === undefined && index.error ? index.error.message : null,
207
211
  retry: () => {
208
212
  if (effective) { void Promise.all([overview.refetch(), serverOutline.refetch()]); return; }
213
+ // A layer whose rows could not be read is asked for again alone, so the layers already read stay on screen.
214
+ void client.refetchQueries({ queryKey: ['evidence-rows'], predicate: (query) => query.state.status === 'error' });
209
215
  void reportJson<EvidencePage>('/evidence?retry=1')
210
216
  .catch(() => null)
211
217
  .then(() => Promise.all([index.refetch(), serverOutline.refetch()]));
212
218
  },
213
- pending: wanted.length - evidence.length,
219
+ pending: Math.max(0, wanted.length - evidence.length - failed.length),
220
+ failed,
214
221
  layers: effective ? effectiveHeaders : layers,
215
222
  evidence: shown,
216
223
  backends: effective ? overview.data?.backends ?? [] : first?.backends ?? [],
@@ -0,0 +1,65 @@
1
+ import { Alert, Button, Group, Text } from '@mantine/core';
2
+ import { useEffect, useState } from 'react';
3
+ import { LoadError } from '../data/adapters';
4
+ import { useEvidence } from './evidence-context';
5
+ import { plural } from './format';
6
+ import { ReportsError } from './api';
7
+
8
+ /**
9
+ * How long a read may take before the page says so. Waiting is never silent past this: the page names what is
10
+ * affected, keeps whatever else is readable, and offers another attempt. A test shortens it.
11
+ */
12
+ export const loadingPolicy = { stallMs: 10_000 };
13
+
14
+ /** True once `active` has stayed true for the loading policy's bound. It starts over when `active` or `scope` changes. */
15
+ export function useStalled(active: boolean, scope = ''): boolean {
16
+ const [stalled, setStalled] = useState(false);
17
+ useEffect(() => {
18
+ setStalled(false);
19
+ if (!active) return;
20
+ const timer = setTimeout(() => setStalled(true), loadingPolicy.stallMs);
21
+ return () => clearTimeout(timer);
22
+ }, [active, scope]);
23
+ return active && stalled;
24
+ }
25
+
26
+ /** Is this error the server saying the thing is not there, as opposed to it failing to answer? */
27
+ export function isMissing(error: unknown): boolean {
28
+ return (error instanceof LoadError || error instanceof ReportsError) && error.status === 404;
29
+ }
30
+
31
+ /** Recorded results are still arriving. After the loading policy's bound it says so and offers another attempt. */
32
+ export function EvidenceWaiting({ label = 'Loading recorded results…', usable }: { label?: string; usable?: string }) {
33
+ const { retry } = useEvidence();
34
+ const stalled = useStalled(true);
35
+ return stalled ? (
36
+ <Alert role="status" color="orange" my="sm" title="Recorded results are taking longer than expected">
37
+ <Group gap="sm">
38
+ <Text span inherit>
39
+ {usable ? `${usable} ` : ''}Results will appear here when they arrive.
40
+ </Text>
41
+ <Button type="button" onClick={retry} variant="subtle">
42
+ Try again
43
+ </Button>
44
+ </Group>
45
+ </Alert>
46
+ ) : (
47
+ <Text role="status" component="div" c="dimmed">
48
+ {label}
49
+ </Text>
50
+ );
51
+ }
52
+
53
+ /** Says when some layers could not be read, so counts and results are not taken for the whole report. */
54
+ export function EvidenceIncomplete() {
55
+ const { failed, retry } = useEvidence();
56
+ if (failed.length === 0) return null;
57
+ return (
58
+ <Alert role="status" color="orange" my="sm">
59
+ {plural(failed.length, 'layer', 'layers')} could not be read, so the results shown here may be incomplete.{' '}
60
+ <Button type="button" onClick={retry} variant="subtle">
61
+ Try again
62
+ </Button>
63
+ </Alert>
64
+ );
65
+ }
@@ -7,6 +7,7 @@ import { folderOf, outlinePath, type SpecKind } from '../spec-kind';
7
7
  import { useUiStore } from '../state/ui-store';
8
8
  import { useEvidence } from './evidence-context';
9
9
  import { ago, plural, useNow } from './format';
10
+ import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
10
11
  import { backendLabel, Bounded, CountLine, Meter, StatusMark, Tally } from './marks';
11
12
  /** A kind's specs at a glance: how many have results, which are failing, and what just ran. */
12
13
  export function Overview({ kind }: { kind: SpecKind }) {
@@ -104,11 +105,8 @@ export function Overview({ kind }: { kind: SpecKind }) {
104
105
  </Button>
105
106
  </Alert>
106
107
  ))}
107
- {!evidence.ready && !evidence.unavailable && (
108
- <Text role="status" component="div" c="dimmed">
109
- Loading recorded results…
110
- </Text>
111
- )}
108
+ {!evidence.ready && !evidence.unavailable && <EvidenceWaiting usable={`The list of ${kind.many} is readable.`} />}
109
+ {evidence.ready && <EvidenceIncomplete />}
112
110
  {evidence.ready && evidence.pending > 0 && (
113
111
  <Text role="status" component="div" c="dimmed">
114
112
  Reading {plural(evidence.pending, 'more layer', 'more layers')}…
@@ -168,7 +166,7 @@ export function Overview({ kind }: { kind: SpecKind }) {
168
166
  </Box>
169
167
  )}
170
168
 
171
- {evidence.ready && evidence.pending === 0 && withResults.length === 0 && !evidence.unavailable && (
169
+ {evidence.ready && evidence.pending === 0 && evidence.failed.length === 0 && withResults.length === 0 && !evidence.unavailable && (
172
170
  <Text component="div" c="dimmed">
173
171
  {evidence.layers.length === 0
174
172
  ? `Nothing has been recorded yet. Results appear here as a spec run finishes each test.`
@@ -10,6 +10,7 @@ import { useRecordedReport } from './check-detail';
10
10
  import { useEvidence } from './evidence-context';
11
11
  import { duration, statusWord, useNow } from './format';
12
12
  import { Origin, StatusMark } from './marks';
13
+ import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
13
14
  import { Timeline } from './recording';
14
15
  type Props = {
15
16
  /** The check whose recording is open, once the combined report holds it. */
@@ -40,7 +41,7 @@ function needsStylesheet(report: RunReport | undefined): boolean {
40
41
  /** One recorded test, opened in place of its spec. */
41
42
  export function RecordingPage({ slot, layerId, entry, label, back, moment, onMoment, onClose }: Props) {
42
43
  const progress = useTestProgress(slot ?? null);
43
- const { evidence, backends, pending, ready, unavailable } = useEvidence();
44
+ const { evidence, backends, pending, ready, unavailable, failed, retry } = useEvidence();
44
45
  const now = useNow();
45
46
  const history = useMemo(() => (slot ? checkHistory(evidence, slot.specPath, slot.id) : []), [evidence, slot]);
46
47
  const winner = slot?.outcomes[0];
@@ -86,17 +87,26 @@ export function RecordingPage({ slot, layerId, entry, label, back, moment, onMom
86
87
  )}
87
88
  </Box>
88
89
  <TestProgress {...progress} />
89
- {waiting && (
90
- <Text role="status" component="div" c="dimmed">
91
- Loading the recording…
92
- </Text>
93
- )}
90
+ {waiting && <EvidenceWaiting label="Loading the recording…" usable="The way back to the spec is available." />}
94
91
  {!chosen && !waiting && !progress.tests.length && (
95
92
  <Alert color={'blue'} my="sm">
96
93
  {unavailable ??
97
- 'This result is not in the combined report any more. A newer run may have replaced it, or its source is switched off.'}
94
+ (failed.length > 0
95
+ ? 'This result was not found in the layers that could be read, and some layers could not be read.'
96
+ : 'This result is not in the combined report any more. A newer run may have replaced it, or its source is switched off.')}{' '}
97
+ {(unavailable || failed.length > 0) && (
98
+ <Button type="button" onClick={retry} variant="subtle">
99
+ Try again
100
+ </Button>
101
+ )}
102
+ </Alert>
103
+ )}
104
+ {chosen && layerId && !entry && chosen.layer.id !== layerId && ready && pending === 0 && (
105
+ <Alert role="status" color={'blue'} my="sm">
106
+ The layer this link names is not among the results held for this test. Showing the current result instead.
98
107
  </Alert>
99
108
  )}
109
+ {ready && !chosen && <EvidenceIncomplete />}
100
110
  {chosen && !paths && (
101
111
  <Alert color={'blue'} my="sm">
102
112
  This result has an outcome and no recorded timeline.
@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
6
6
  import type { SpecKind } from '../spec-kind';
7
7
  import { useEvidence } from './evidence-context';
8
8
  import { useNow } from './format';
9
+ import { EvidenceIncomplete, EvidenceWaiting } from './load-state';
9
10
  import { CountLine, Meter, Origin } from './marks';
10
11
  /** A spec's name, where it lives, and what the combined report holds for it. */
11
12
  export function SpecHeader({
@@ -23,7 +24,7 @@ export function SpecHeader({
23
24
  planned?: number;
24
25
  children?: ReactNode;
25
26
  }) {
26
- const { backends, ready, unavailable, pending } = useEvidence();
27
+ const { backends, ready, unavailable, pending, failed } = useEvidence();
27
28
  const now = useNow();
28
29
  const summary = entry?.summary ?? NO_EVIDENCE;
29
30
  const newest = entry?.slots
@@ -50,13 +51,13 @@ export function SpecHeader({
50
51
  <Text span inherit>
51
52
  Recorded results are unavailable.
52
53
  </Text>
53
- ) : !ready ? (
54
- <Text role="status" span inherit>
55
- Loading recorded results…
56
- </Text>
54
+ ) : !ready || (summary.status === 'none' && pending > 0) ? (
55
+ <EvidenceWaiting usable={`The ${kind.one} below is readable.`} />
57
56
  ) : summary.status === 'none' ? (
58
57
  <Text span inherit>
59
- {pending > 0 ? 'Loading recorded results…' : `No completed test report for this ${kind.one} yet.`}
58
+ {failed.length > 0
59
+ ? `No completed test report for this ${kind.one} in the layers that could be read.`
60
+ : `No completed test report for this ${kind.one} yet.`}
60
61
  </Text>
61
62
  ) : (
62
63
  <>
@@ -69,6 +70,7 @@ export function SpecHeader({
69
70
  </>
70
71
  )}
71
72
  </Flex>
73
+ {ready && <EvidenceIncomplete />}
72
74
  {newest && (
73
75
  <Flex gap="xs" align="center" wrap="wrap" c="dimmed">
74
76
  Latest from <Origin layer={newest.layer} backends={backends} now={now} />
@@ -1,11 +1,13 @@
1
- import { Alert, Button, Container, Text, Title } from '@mantine/core';
2
- import { useQuery } from '@tanstack/react-query';
3
- import { useNavigate, useParams, useSearch } from '@tanstack/react-router';
1
+ import { Alert, Anchor, Button, Container, Group, List, Stack, Text, Title } from '@mantine/core';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router';
4
4
  import { lazy, Suspense } from 'react';
5
5
  import { keys, specKey, useAdapter } from '../data/queries';
6
- import { outlinePath, specFile, specKind } from '../spec-kind';
6
+ import { outlinePath, resolveSpec, similarSpecs, specFile, specKind, type SpecKind } from '../spec-kind';
7
7
  import type { SelectCheck } from './check-detail';
8
8
  import { useEvidence } from './evidence-context';
9
+ import { isMissing, useStalled } from './load-state';
10
+ import { SpecHeader } from './spec-header';
9
11
  import { Overview } from './overview';
10
12
  // Each kind of spec has a page with a parser of its own (Gherkin, Quint, DMN); a page loads only the one it shows.
11
13
  const DocumentPage = lazy(() => import('./document-page').then((m) => ({ default: m.DocumentPage })));
@@ -27,6 +29,63 @@ function Loading({ children }: { children: React.ReactNode }) {
27
29
  </Suspense>
28
30
  );
29
31
  }
32
+ /** What a spec that cannot be shown still says: where it was looked for, what is recorded for it, and the ways on. */
33
+ function SpecUnavailable({
34
+ kind,
35
+ file,
36
+ missing,
37
+ detail,
38
+ retry,
39
+ run
40
+ }: {
41
+ kind: SpecKind;
42
+ file: string;
43
+ missing: boolean;
44
+ detail: string;
45
+ retry: () => void;
46
+ run: string | undefined;
47
+ }) {
48
+ const evidence = useEvidence();
49
+ const listed = evidence.files.filter(kind.accepts);
50
+ const similar = similarSpecs(file, listed);
51
+ const entry = evidence.view.specs.get(outlinePath(file));
52
+ return (
53
+ <Container component="article" size="xl" w="100%" py="xl">
54
+ <SpecHeader title={file.split('/').at(-1)!} file={file} kind={kind} entry={entry}>
55
+ <Alert role={missing ? 'status' : 'alert'} color={missing ? 'orange' : 'red'} my="sm" title={missing ? `This ${kind.one} is not available` : `The ${kind.one} could not be read`}>
56
+ <Stack gap="sm">
57
+ <Text component="div">
58
+ {missing ? `specs/${file} is not in this spec set. ` : ''}
59
+ {detail}
60
+ {evidence.filesPending || evidence.filesError ? '' : ` ${listed.length} ${listed.length === 1 ? kind.one : kind.many} are listed.`}
61
+ {entry && entry.slots.length > 0 ? ' Results recorded against it are shown above.' : ''}
62
+ </Text>
63
+ {similar.length > 0 && (
64
+ <>
65
+ <Text component="div">Did you mean:</Text>
66
+ <List listStyleType="none">
67
+ {similar.map((candidate) => (
68
+ <List.Item key={candidate}>
69
+ <Anchor renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run, spec: candidate }} />}>{candidate}</Anchor>
70
+ </List.Item>
71
+ ))}
72
+ </List>
73
+ </>
74
+ )}
75
+ <Group gap="sm">
76
+ <Button type="button" onClick={retry} variant="light">
77
+ Try again
78
+ </Button>
79
+ <Button variant="subtle" renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run }} />}>
80
+ Back to {kind.label}
81
+ </Button>
82
+ </Group>
83
+ </Stack>
84
+ </Alert>
85
+ </SpecHeader>
86
+ </Container>
87
+ );
88
+ }
30
89
  /** One spec tab: its overview, one of its documents, or a recording opened from that document. */
31
90
  export function SpecPage() {
32
91
  const params = useParams({ from: '/specs/$kind' });
@@ -35,7 +94,8 @@ export function SpecPage() {
35
94
  const adapter = useAdapter();
36
95
  const evidence = useEvidence();
37
96
  const kind = specKind(params.kind);
38
- const file = search.spec ? specFile(search.spec) : '';
97
+ // A link may name a spec by another form of its path (with the folder, in other letter case): the listed one is shown.
98
+ const file = search.spec ? resolveSpec(specFile(search.spec), evidence.files) : '';
39
99
  const readable = kind !== null && file.length > 0 && kind.accepts(file);
40
100
  const text = useQuery({
41
101
  queryKey: keys.specFile(specKey(adapter), file),
@@ -43,6 +103,12 @@ export function SpecPage() {
43
103
  enabled: readable,
44
104
  retry: false
45
105
  });
106
+ const stalled = useStalled(text.isLoading, file);
107
+ const client = useQueryClient();
108
+ // A read that has not answered is dropped before asking again; asking again beside it would only wait for the same answer.
109
+ const retryText = () => {
110
+ void client.cancelQueries({ queryKey: keys.specFile(specKey(adapter), file) }).then(() => text.refetch());
111
+ };
46
112
  if (!kind) {
47
113
  return (
48
114
  <Container component="article" size="xl" w="100%" py="xl">
@@ -101,24 +167,41 @@ export function SpecPage() {
101
167
  </Loading>
102
168
  );
103
169
  }
104
- if (text.isPending)
170
+ if (text.isPending || (text.isError && evidence.filesPending))
105
171
  return (
106
172
  <Container component="article" size="xl" w="100%" py="xl">
107
173
  <Text role="status" component="div" c="dimmed">
108
- Loading the {kind.one}…
174
+ Loading specs/{file}…
109
175
  </Text>
176
+ {stalled && (
177
+ <Alert role="status" color="orange" my="sm" title={`Reading the ${kind.one} is taking longer than expected`}>
178
+ <Group gap="sm">
179
+ <Text span inherit>
180
+ specs/{file} has not arrived yet. Recorded results for other specs are not affected.
181
+ </Text>
182
+ <Button type="button" onClick={retryText} variant="light">
183
+ Try again
184
+ </Button>
185
+ <Button variant="subtle" renderRoot={(props) => <Link {...props} to="/specs/$kind" params={{ kind: kind.id }} search={{ run: search.run }} />}>
186
+ Back to {kind.label}
187
+ </Button>
188
+ </Group>
189
+ </Alert>
190
+ )}
110
191
  </Container>
111
192
  );
112
193
  if (text.isError) {
194
+ const listedElsewhere = !evidence.filesPending && !evidence.filesError && !evidence.files.includes(file);
195
+ const missing = isMissing(text.error) || listedElsewhere;
113
196
  return (
114
- <Container component="article" size="xl" w="100%" py="xl">
115
- <Alert role="alert" color={'red'} my="sm">
116
- The {kind.one} could not be read.{' '}
117
- <Button type="button" onClick={() => void text.refetch()} variant="subtle">
118
- Try again
119
- </Button>
120
- </Alert>
121
- </Container>
197
+ <SpecUnavailable
198
+ kind={kind}
199
+ file={file}
200
+ missing={missing}
201
+ detail={text.error instanceof Error ? text.error.message : ''}
202
+ retry={retryText}
203
+ run={search.run}
204
+ />
122
205
  );
123
206
  }
124
207
  const page = { file, text: text.data, kind, entry, selection: { check: search.check, layer: search.layer }, select };
@@ -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
+ }