@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
@@ -17,13 +17,25 @@ import type { EvidenceStatus } from '@crvouga/atlas/reports/explorer-types';
17
17
  import type { LayerEvidence, LayerRows, StatusCounts } from '@crvouga/atlas/reports/evidence-types';
18
18
  import type { CheckSlot, EvidenceView } from '@crvouga/atlas/reports/evidence-view';
19
19
  import {
20
+ facetsOf,
21
+ filterParams,
22
+ forRuns,
23
+ matchesFilter,
24
+ usedFilters,
25
+ type ArtifactFilter,
26
+ type FilterName,
27
+ type QueryFacets
28
+ } from '@crvouga/atlas/reports/artifact-query';
29
+ import {
30
+ artifactList,
20
31
  catalogModel,
32
+ fieldsOfRun,
21
33
  haystackOf,
22
- hasVideo,
23
34
  runDetail,
24
35
  runList,
25
36
  testHistory,
26
37
  TestHistory,
38
+ type ArtifactList,
27
39
  type CatalogModel,
28
40
  type RunDetail,
29
41
  type RunList,
@@ -40,6 +52,8 @@ import { useEvidence } from '../evidence/evidence-context';
40
52
 
41
53
  /** How many runs the Runs tab lists. */
42
54
  export const RUNS_SHOWN = 500;
55
+ /** How many recorded results the Artifacts tab lists. */
56
+ export const ARTIFACTS_SHOWN = 200;
43
57
  /** The server's catalog is read again at most this often while results keep arriving. */
44
58
  const SERVER_REREAD_MS = 5_000;
45
59
  /** Layers read one after another are folded into the page's catalog at most this often. */
@@ -234,24 +248,95 @@ export function useTestHistory(key: string | undefined) {
234
248
  });
235
249
  }
236
250
 
251
+ /** The runs a query matched, and what this server could not do about it. */
252
+ export type RunsAnswer = RunList & {
253
+ /** What the viewer's query asks for that the answer does not reflect: filters an older server ignores and the viewer cannot apply itself (tags, types, labels need data it does not send). Say so; never present the list as narrowed by them. */
254
+ readonly notApplied: readonly FilterName[];
255
+ /** The values each dimension offers, from the server or worked out from the runs it sent. */
256
+ readonly facets: QueryFacets;
257
+ };
258
+
259
+ /** What an older server's run summary can answer by itself: the viewer filters its complete list for these. */
260
+ const ANSWERABLE_HERE: ReadonlySet<FilterName> = new Set(['q', 'environment', 'producer', 'branch', 'sha', 'outcome', 'since', 'until', 'video']);
261
+
237
262
  /**
238
- * CI runs, newest first. With `video`, only the runs that have a playable video recording: the server filters every run
239
- * before it pages them, so the count is the whole dataset's. A server that does not know the parameter answers with
240
- * every run (and no video information), which is filtered here.
263
+ * Makes a server's answer honest about the filters it did not apply (an older server ignores parameters it does not know
264
+ * and answers with every run). Those the run summary can answer are applied here, to the complete list; the rest are
265
+ * reported in `notApplied` so the page can say the list is not narrowed by them.
241
266
  */
242
- export function useRunList(enabled = true, video = false) {
267
+ export function runsAnswer(list: RunList, filter: ArtifactFilter): RunsAnswer {
268
+ const asked = usedFilters(forRuns(filter)).filter((name) => name !== 'inherited');
269
+ // A server that predates `filters` but has `withVideo` already filtered by `video`.
270
+ const applied = new Set<FilterName>(list.filters ?? (list.withVideo !== undefined ? ['video'] : []));
271
+ const missing = asked.filter((name) => !applied.has(name));
272
+ if (!missing.length && list.facets) return { ...list, facets: list.facets, notApplied: [] };
273
+ const here = missing.filter((name) => ANSWERABLE_HERE.has(name));
274
+ const sub = Object.fromEntries(here.map((name) => [name, filter[name]])) as ArtifactFilter;
275
+ const runs = here.length ? list.runs.filter((run) => matchesFilter(fieldsOfRun(run), sub)) : list.runs;
276
+ return {
277
+ ...list,
278
+ total: here.length ? runs.length : list.total,
279
+ runs,
280
+ facets: list.facets ?? facetsOf(list.runs.map(fieldsOfRun), sub),
281
+ notApplied: missing.filter((name) => !ANSWERABLE_HERE.has(name))
282
+ };
283
+ }
284
+
285
+ /**
286
+ * CI runs, newest first, narrowed by a query (see `artifact-query.ts`). The server filters every run before it pages
287
+ * them, so counts are the whole dataset's; a server that does not know a filter is handled by {@link runsAnswer}.
288
+ */
289
+ export function useRunList(enabled = true, filter: ArtifactFilter = {}) {
243
290
  const catalog = useTestCatalog();
291
+ const query = forRuns(filter);
292
+ const key = filterParams(query).toString();
244
293
  return useQuery({
245
- queryKey: ['test-runs', catalog.version, video],
246
- queryFn: async ({ signal }): Promise<RunList> => {
294
+ queryKey: ['test-runs', catalog.version, key],
295
+ queryFn: async ({ signal }): Promise<RunsAnswer> => {
247
296
  if (catalog.source === 'server') {
248
- const list = await reportJson<RunList>(`/runs?limit=${RUNS_SHOWN}${video ? '&video=true' : ''}`, signal);
249
- if (!video || list.withVideo !== undefined) return list;
250
- const runs = list.runs.filter(hasVideo);
251
- return { ...list, total: runs.length, runs };
297
+ const params = filterParams(query);
298
+ params.set('limit', String(RUNS_SHOWN));
299
+ return runsAnswer(await reportJson<RunList>(`/runs?${params}`, signal), query);
252
300
  }
253
301
  const history = catalog.model?.history;
254
- return history ? runList(history, catalog.version, RUNS_SHOWN, { video }) : { schemaVersion: 1, version: catalog.version, total: 0, runs: [] };
302
+ const list = history ? runList(history, catalog.version, RUNS_SHOWN, query) : { schemaVersion: 1 as const, version: catalog.version, total: 0, runs: [] };
303
+ return runsAnswer(list, query);
304
+ },
305
+ enabled: enabled && catalog.model !== null,
306
+ placeholderData: keepPreviousData,
307
+ staleTime: Number.POSITIVE_INFINITY,
308
+ retry: false
309
+ });
310
+ }
311
+
312
+ /** The recorded results that carry evidence and match a query, or `unsupported` when the server has no artifact search. */
313
+ export type ArtifactsAnswer = { readonly unsupported: false; readonly list: ArtifactList } | { readonly unsupported: true };
314
+
315
+ /**
316
+ * Recorded results that carry evidence (recordings, screenshots, traces) across the whole history, newest first. A
317
+ * server without `/artifacts` (an older one) answers `unsupported`, and the page says so instead of showing an empty list.
318
+ */
319
+ export function useArtifactList(filter: ArtifactFilter, enabled = true) {
320
+ const catalog = useTestCatalog();
321
+ const key = filterParams(filter).toString();
322
+ return useQuery({
323
+ queryKey: ['test-artifacts', catalog.version, key],
324
+ queryFn: async ({ signal }): Promise<ArtifactsAnswer> => {
325
+ if (catalog.source === 'server') {
326
+ const params = filterParams(filter);
327
+ params.set('limit', String(ARTIFACTS_SHOWN));
328
+ try {
329
+ return { unsupported: false, list: await reportJson<ArtifactList>(`/artifacts?${params}`, signal) };
330
+ } catch (error) {
331
+ if (notFound(error)) return { unsupported: true };
332
+ throw error;
333
+ }
334
+ }
335
+ const model = catalog.model!;
336
+ const domains = new Map(model.facets.specs.map((spec) => [spec.path, spec.domain]));
337
+ const history = model.history;
338
+ if (!history) return { unsupported: true };
339
+ return { unsupported: false, list: artifactList(history, catalog.version, filter, { limit: ARTIFACTS_SHOWN, domainOf: (path) => domains.get(path) }) };
255
340
  },
256
341
  enabled: enabled && catalog.model !== null,
257
342
  placeholderData: keepPreviousData,
@@ -0,0 +1,279 @@
1
+ /**
2
+ * @module Run and artifact filters
3
+ *
4
+ * The filter bar of the Runs and Artifacts tabs: words, artifact tag, media type, label, environment, branch, outcome,
5
+ * feature domain and a time range, each offering the values the report has with counts in the scope of the list. What is
6
+ * chosen shows as removable chips and is the address, so copying it or reloading restores the same view; named views keep
7
+ * a query to open again. Choices combine with AND across the controls and OR within one.
8
+ */
9
+ import { IconBookmark, IconLink, IconTrash, IconX } from '@tabler/icons-react';
10
+ import { Anchor, Badge, Box, Button, Checkbox, CopyButton, Flex, Group, MultiSelect, Popover, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
11
+ import type { QueryFacets, QueryFacetValue } from '@crvouga/atlas/reports/artifact-query';
12
+ import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
13
+ import { Link } from '@tanstack/react-router';
14
+ import { useEffect, useMemo, useState, type ReactNode } from 'react';
15
+
16
+ import { currentProject } from '../runtime';
17
+ import { patched, useExplorerNavigate } from './navigate';
18
+ import { filtered, joined, NO_FILTERS, toggled, values, type ExplorerSearch } from './search';
19
+ import { filtersOf, isView, readViews, writeViews, type SavedView } from './views';
20
+
21
+ type Param = 'artifactTag' | 'type' | 'label' | 'env' | 'branch' | 'outcome' | 'domain' | 'producer';
22
+
23
+ const NOUN: Record<Param, string> = {
24
+ artifactTag: 'Tag',
25
+ type: 'Type',
26
+ label: 'Label',
27
+ env: 'Environment',
28
+ branch: 'Branch',
29
+ outcome: 'Outcome',
30
+ domain: 'Domain',
31
+ producer: 'Producer'
32
+ };
33
+
34
+ /** How a chosen value reads: tags with their hash, environments by name. */
35
+ function shown(param: Param, value: string): string {
36
+ if (param === 'artifactTag') return `#${value}`;
37
+ if (param === 'env') return environmentLabel(value);
38
+ if (param === 'domain' && value === '(no spec)') return 'No spec';
39
+ return value;
40
+ }
41
+
42
+ function Facet({ param, facets, search }: { param: Param; facets: readonly QueryFacetValue[]; search: ExplorerSearch }) {
43
+ const go = useExplorerNavigate();
44
+ const chosen = values(search[param]);
45
+ const data = useMemo(() => facets.map((facet) => ({ value: facet.value, label: `${shown(param, facet.value)} (${facet.count})` })), [facets, param]);
46
+ if (!data.length && !chosen.length) return null;
47
+ return (
48
+ <MultiSelect
49
+ label={NOUN[param]}
50
+ data={data}
51
+ value={chosen}
52
+ searchable
53
+ clearable
54
+ placeholder={chosen.length ? undefined : `Any ${NOUN[param].toLowerCase()}`}
55
+ nothingFoundMessage="Nothing found"
56
+ onChange={(next) => go({ [param]: joined(next) } as never)}
57
+ />
58
+ );
59
+ }
60
+
61
+ /** Saved views: name the current filters, open one again, delete it. They are live queries, said so beside the list. */
62
+ function Views({
63
+ search,
64
+ tab,
65
+ views,
66
+ onChange,
67
+ failed
68
+ }: {
69
+ search: ExplorerSearch;
70
+ tab: 'runs' | 'artifacts';
71
+ views: readonly SavedView[];
72
+ onChange: (next: SavedView[]) => void;
73
+ failed: boolean;
74
+ }) {
75
+ const project = currentProject() ?? '';
76
+ const [name, setName] = useState('');
77
+ const mine = views.filter((view) => view.project === project && view.tab === tab);
78
+ const keep = onChange;
79
+ const narrowed = filtered(search);
80
+ return (
81
+ <Popover width={360} position="bottom-start" withArrow shadow="md" trapFocus>
82
+ <Popover.Target>
83
+ <Button type="button" variant="default" leftSection={<IconBookmark size={16} aria-hidden="true" />}>
84
+ Views{mine.length ? ` (${mine.length})` : ''}
85
+ </Button>
86
+ </Popover.Target>
87
+ <Popover.Dropdown>
88
+ <Stack gap="sm" aria-label="Saved views">
89
+ <Text size="sm" c="dimmed">
90
+ A saved view is a live query: opening it asks again, so it shows what matches the newest report now. It is not a snapshot of the results you see today.
91
+ </Text>
92
+ {mine.length === 0 && (
93
+ <Text size="sm" c="dimmed">
94
+ No saved views for this tab yet.
95
+ </Text>
96
+ )}
97
+ {mine.map((view) => (
98
+ <Group key={view.id} justify="space-between" wrap="nowrap">
99
+ <Anchor
100
+ aria-label={`Open view ${view.name}`}
101
+ renderRoot={(props) => (
102
+ <Link
103
+ {...props}
104
+ to="/"
105
+ search={patched({ ...NO_FILTERS, ...view.filters, tab: view.tab, openRun: undefined, test: undefined, layer: undefined } as never)}
106
+ resetScroll={false}
107
+ activeOptions={{ exact: true }}
108
+ />
109
+ )}
110
+ >
111
+ {view.name}
112
+ </Anchor>
113
+ <Badge variant="outline" color="gray">
114
+ Live query
115
+ </Badge>
116
+ <Button
117
+ type="button"
118
+ variant="subtle"
119
+ color="red"
120
+ aria-label={`Delete view ${view.name}`}
121
+ onClick={() => keep(views.filter((entry) => entry.id !== view.id))}
122
+ >
123
+ <IconTrash size={16} aria-hidden="true" />
124
+ </Button>
125
+ </Group>
126
+ ))}
127
+ <TextInput label="View name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Demo review" />
128
+ <Button
129
+ type="button"
130
+ disabled={!name.trim() || !narrowed}
131
+ onClick={() => {
132
+ const saved: SavedView = {
133
+ id: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
134
+ name: name.trim(),
135
+ tab,
136
+ project,
137
+ filters: filtersOf(search),
138
+ savedAtMs: Date.now()
139
+ };
140
+ keep([...views.filter((view) => !(view.project === project && view.tab === tab && view.name === saved.name)), saved]);
141
+ setName('');
142
+ }}
143
+ >
144
+ Save the current filters
145
+ </Button>
146
+ {!narrowed && (
147
+ <Text size="sm" c="dimmed">
148
+ Choose at least one filter to save a view.
149
+ </Text>
150
+ )}
151
+ {failed && (
152
+ <Text size="sm" c="red.7" role="alert">
153
+ This browser would not keep the view, so it will be gone when the page closes.
154
+ </Text>
155
+ )}
156
+ </Stack>
157
+ </Popover.Dropdown>
158
+ </Popover>
159
+ );
160
+ }
161
+
162
+ /**
163
+ * The filter bar for a tab. `facets` are the values on offer with their counts; `extra` is shown beside the controls (the
164
+ * Runs tab's Has video button); `domains` offers the feature domain (artifacts only).
165
+ */
166
+ export function FilterBar({
167
+ facets,
168
+ search,
169
+ tab,
170
+ extra
171
+ }: {
172
+ facets: QueryFacets;
173
+ search: ExplorerSearch;
174
+ tab: 'runs' | 'artifacts';
175
+ extra?: ReactNode;
176
+ }) {
177
+ const go = useExplorerNavigate();
178
+ const [draft, setDraft] = useState(search.q ?? '');
179
+ // The address may change from elsewhere (a link, a saved view, back): follow it.
180
+ useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
181
+ const params: Param[] = ['artifactTag', 'type', 'label', 'env', 'branch', 'outcome', ...(tab === 'artifacts' ? (['domain'] as const) : []), 'producer'];
182
+ const chips = [
183
+ ...params.flatMap((param) =>
184
+ values(search[param]).map((value) => ({ label: `${NOUN[param]}: ${shown(param, value)}`, clear: { [param]: toggled(search[param], value) } as never }))
185
+ ),
186
+ ...(search.since ? [{ label: `From ${search.since}`, clear: { since: undefined } as never }] : []),
187
+ ...(search.until ? [{ label: `Until ${search.until}`, clear: { until: undefined } as never }] : []),
188
+ ...(search.q ? [{ label: `Words: ${search.q}`, clear: { q: undefined } as never }] : []),
189
+ ...(search.video ? [{ label: 'Has video', clear: { video: undefined } as never }] : [])
190
+ ];
191
+ const [views, setViews] = useState<SavedView[]>(() => readViews());
192
+ const [failed, setFailed] = useState(false);
193
+ const saved = views.find((view) => view.project === (currentProject() ?? '') && isView(view, search));
194
+ return (
195
+ <Box aria-label="Filters" component="section" mb="md">
196
+ <Stack gap="sm">
197
+ <Flex gap="sm" align="flex-end" wrap="wrap">
198
+ <TextInput
199
+ flex={1}
200
+ miw={{ base: '100%', sm: '15rem' }}
201
+ type="search"
202
+ aria-label={`Search ${tab}`}
203
+ placeholder={tab === 'runs' ? 'Search runs by title, branch, commit, tag…' : 'Search by test, spec, tag, label…'}
204
+ value={draft}
205
+ onChange={(event) => {
206
+ setDraft(event.target.value);
207
+ go({ q: event.target.value.trim() || undefined }, { replace: true });
208
+ }}
209
+ />
210
+ <Views
211
+ search={search}
212
+ tab={tab}
213
+ views={views}
214
+ failed={failed}
215
+ onChange={(next) => {
216
+ setViews(next);
217
+ setFailed(!writeViews(next));
218
+ }}
219
+ />
220
+ <CopyButton value={typeof window === 'undefined' ? '' : window.location.href}>
221
+ {({ copied, copy }) => (
222
+ <Button type="button" variant="default" onClick={copy} leftSection={<IconLink size={16} aria-hidden="true" />}>
223
+ {copied ? 'Link copied' : 'Copy link'}
224
+ </Button>
225
+ )}
226
+ </CopyButton>
227
+ {extra}
228
+ </Flex>
229
+ <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
230
+ <Facet param="artifactTag" facets={facets.artifactTags} search={search} />
231
+ <Facet param="type" facets={facets.types} search={search} />
232
+ <Facet param="label" facets={facets.labels} search={search} />
233
+ <Facet param="env" facets={facets.environments} search={search} />
234
+ <Facet param="branch" facets={facets.branches} search={search} />
235
+ <Facet param="outcome" facets={facets.outcomes} search={search} />
236
+ {tab === 'artifacts' && facets.domains && <Facet param="domain" facets={facets.domains} search={search} />}
237
+ {facets.producers.length > 1 && <Facet param="producer" facets={facets.producers} search={search} />}
238
+ <TextInput type="date" label="From" value={search.since ?? ''} onChange={(event) => go({ since: event.target.value || undefined })} />
239
+ <TextInput type="date" label="Until" value={search.until ?? ''} onChange={(event) => go({ until: event.target.value || undefined })} />
240
+ </SimpleGrid>
241
+ {values(search.artifactTag).length > 0 && (
242
+ <Checkbox
243
+ label="Also match tags inherited from the check and run"
244
+ description="Off: only tags written on the recording itself."
245
+ checked={search.inherited === true}
246
+ onChange={(event) => go({ inherited: event.currentTarget.checked ? true : undefined })}
247
+ />
248
+ )}
249
+ {(chips.length > 0 || saved) && (
250
+ <Flex gap="xs" align="center" wrap="wrap" aria-label="Active filters">
251
+ {saved && (
252
+ <Badge variant="light" aria-label={`Live query ${saved.name}`}>
253
+ Live query: {saved.name}
254
+ </Badge>
255
+ )}
256
+ {chips.map((chip) => (
257
+ <Button key={chip.label} type="button" onClick={() => go(chip.clear)} aria-label={`Remove ${chip.label}`} variant="light">
258
+ {chip.label}{' '}
259
+ <Text aria-hidden="true" span inherit>
260
+ <IconX size={16} aria-hidden="true" />
261
+ </Text>
262
+ </Button>
263
+ ))}
264
+ {filtered(search) && (
265
+ <Button type="button" variant="subtle" onClick={() => go(NO_FILTERS)}>
266
+ Clear filters
267
+ </Button>
268
+ )}
269
+ {saved && (
270
+ <Text size="sm" c="dimmed">
271
+ A live query, not a snapshot: it follows the newest report.
272
+ </Text>
273
+ )}
274
+ </Flex>
275
+ )}
276
+ </Stack>
277
+ </Box>
278
+ );
279
+ }
@@ -3,8 +3,13 @@
3
3
  *
4
4
  * Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
5
5
  * the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, and the exact layer a test's detail is pinned to. Filters that
6
- * take several values are comma-separated (`status=failed,flaky`). Anything unreadable is dropped, never an error.
6
+ * take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
7
+ * or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
8
+ *
9
+ * The Runs and Artifacts tabs share the tests' `q`, `env`, `producer`, `domain` and `spec` and add `artifactTag`,
10
+ * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
7
11
  */
12
+ import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
8
13
  import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
9
14
  import { z } from 'zod/v4';
10
15
 
@@ -30,11 +35,29 @@ export const ExplorerSearchFields = {
30
35
  domain: text,
31
36
  spec: text,
32
37
  tag: text,
38
+ /** Only tests with one recording that declares every one of these scope tokens (`device:physical,audio:recorded`). */
39
+ scope: text,
40
+ /** Artifact tags (`demo`, shown `#demo`): tests with an attachment carrying one; runs and artifacts likewise. */
41
+ artifactTag: text,
42
+ /** With `artifactTag`: also the tags attachments inherit from their check and run. */
43
+ inherited: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
44
+ /** Media types of attachments (`video`, `image`…): runs and artifacts. */
45
+ type: text,
46
+ /** `key=value` labels: runs and artifacts. */
47
+ label: text,
48
+ /** Branches: runs and artifacts. */
49
+ branch: text,
50
+ /** Outcomes of a run or recorded result (`passed`, `failed`…). */
51
+ outcome: text,
52
+ /** Only records from this day (`YYYY-MM-DD`, the viewer's local time). */
53
+ since: text,
54
+ /** Only records until the end of this day (`YYYY-MM-DD`, local time). */
55
+ until: text,
33
56
  /** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
34
57
  inRun: text,
35
58
  group: z.enum(TEST_GROUPS).optional().catch(undefined),
36
59
  sort: z.enum(TEST_SORTS).optional().catch(undefined),
37
- tab: z.enum(['runs']).optional().catch(undefined),
60
+ tab: z.enum(['runs', 'artifacts']).optional().catch(undefined),
38
61
  /** The run opened in the Runs tab. */
39
62
  openRun: text,
40
63
  /** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. */
@@ -53,10 +76,19 @@ export type ExplorerSearch = {
53
76
  readonly domain?: string;
54
77
  readonly spec?: string;
55
78
  readonly tag?: string;
79
+ readonly scope?: string;
80
+ readonly artifactTag?: string;
81
+ readonly inherited?: true;
82
+ readonly type?: string;
83
+ readonly label?: string;
84
+ readonly branch?: string;
85
+ readonly outcome?: string;
86
+ readonly since?: string;
87
+ readonly until?: string;
56
88
  readonly inRun?: string;
57
89
  readonly group?: TestGroup;
58
90
  readonly sort?: TestSort;
59
- readonly tab?: 'runs';
91
+ readonly tab?: 'runs' | 'artifacts';
60
92
  readonly openRun?: string;
61
93
  readonly layer?: string;
62
94
  readonly video?: true;
@@ -64,12 +96,12 @@ export type ExplorerSearch = {
64
96
 
65
97
  /** The values of a comma-separated filter, empty when it is not set. */
66
98
  export function values(value: string | undefined): string[] {
67
- return value ? value.split(',').map((item) => item.trim()).filter(Boolean) : [];
99
+ return value ? splitList(value) : [];
68
100
  }
69
101
 
70
102
  /** A list of values as one comma-separated parameter, or undefined (left out of the address) when empty. */
71
103
  export function joined(list: readonly string[]): string | undefined {
72
- return list.length ? list.join(',') : undefined;
104
+ return joinList(list);
73
105
  }
74
106
 
75
107
  /** Adds a value to a comma-separated filter, or removes it when it is there. */
@@ -88,6 +120,9 @@ export function queryOf(search: ExplorerSearch): TestQuery {
88
120
  domain: values(search.domain),
89
121
  spec: values(search.spec),
90
122
  tag: values(search.tag),
123
+ ...(values(search.scope).length ? { scope: values(search.scope) } : {}),
124
+ artifactTag: values(search.artifactTag),
125
+ ...(search.inherited ? { inherited: true } : {}),
91
126
  sort: search.sort ?? 'spec'
92
127
  };
93
128
  }
@@ -99,5 +134,49 @@ export function explorerSearch(search: Record<string, unknown>): ExplorerSearch
99
134
 
100
135
  /** True when any filter or search narrows the list. */
101
136
  export function narrowed(search: ExplorerSearch): boolean {
102
- return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.inRun);
137
+ return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.scope || search.artifactTag || search.inRun);
138
+ }
139
+
140
+ /** The filters of the Runs and Artifacts tabs, in the address. */
141
+ export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
142
+
143
+ /** A patch that removes every filter of the Runs and Artifacts tabs. */
144
+ export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
145
+
146
+ /** True when an address narrows the Runs and Artifacts tabs. */
147
+ export function filtered(search: ExplorerSearch): boolean {
148
+ return FILTER_FIELDS.some((field) => field !== 'inherited' && search[field] !== undefined);
149
+ }
150
+
151
+ /** Midnight at the start (or the last millisecond) of a `YYYY-MM-DD` day in the viewer's local time, or undefined. */
152
+ function dayMs(day: string | undefined, end: boolean): number | undefined {
153
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day ?? '');
154
+ if (!match) return undefined;
155
+ const [year, month, date] = [Number(match[1]), Number(match[2]) - 1, Number(match[3])];
156
+ const value = new Date(year, month, date + (end ? 1 : 0)).getTime() - (end ? 1 : 0);
157
+ return Number.isFinite(value) ? value : undefined;
158
+ }
159
+
160
+ /** The query the Runs and Artifacts tabs ask for: the address's filters in the shape the report API and `matchesFilter` read. */
161
+ export function filterOf(search: ExplorerSearch): ArtifactFilter {
162
+ const list = (value: string | undefined) => (values(value).length ? values(value) : undefined);
163
+ const since = dayMs(search.since, false);
164
+ const until = dayMs(search.until, true);
165
+ const parts = {
166
+ q: search.q,
167
+ artifactTag: list(search.artifactTag),
168
+ inherited: search.inherited,
169
+ type: list(search.type),
170
+ label: list(search.label),
171
+ environment: list(search.env),
172
+ producer: list(search.producer),
173
+ branch: list(search.branch),
174
+ outcome: list(search.outcome),
175
+ domain: list(search.domain),
176
+ spec: list(search.spec),
177
+ since,
178
+ until,
179
+ video: search.video
180
+ };
181
+ return Object.fromEntries(Object.entries(parts).filter(([, value]) => value !== undefined)) as ArtifactFilter;
103
182
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @module Saved views
3
+ *
4
+ * A saved view is a name for a set of filters on the Runs or Artifacts tab of one project, kept in this browser. It is a
5
+ * live query, not a snapshot: opening it asks again and shows what matches the newest report, so its results change as
6
+ * runs arrive. (An immutable collection of reviewed results is a different thing, and is not what this stores.) Unreadable
7
+ * storage is treated as no saved views, never as an error.
8
+ */
9
+ import { FILTER_FIELDS, type ExplorerSearch } from './search';
10
+
11
+ /** The storage key of the saved views of this browser, all projects. */
12
+ export const VIEWS_KEY = 'atlas.savedViews.v1';
13
+
14
+ /** A saved live query. `filters` holds only the filter fields of the address, as the address writes them. */
15
+ export type SavedView = {
16
+ readonly id: string;
17
+ readonly name: string;
18
+ /** The tab it opens on. */
19
+ readonly tab: 'runs' | 'artifacts';
20
+ /** The project it belongs to (the page's `?project=`), or an empty string. */
21
+ readonly project: string;
22
+ readonly filters: Readonly<Record<string, string | true>>;
23
+ readonly savedAtMs: number;
24
+ };
25
+
26
+ /** The filters of an address, ready to save: only the fields that narrow, with their values. */
27
+ export function filtersOf(search: ExplorerSearch): Record<string, string | true> {
28
+ const filters: Record<string, string | true> = {};
29
+ for (const field of FILTER_FIELDS) {
30
+ const value = search[field];
31
+ if (value !== undefined) filters[field] = value;
32
+ }
33
+ return filters;
34
+ }
35
+
36
+ /** Whether an address is exactly a saved view's query on its tab (ignoring anything that is not a filter). */
37
+ export function isView(view: SavedView, search: ExplorerSearch): boolean {
38
+ if ((search.tab ?? 'tests') !== view.tab) return false;
39
+ const now = filtersOf(search);
40
+ const keys = Object.keys(now);
41
+ return keys.length === Object.keys(view.filters).length && keys.every((key) => now[key] === view.filters[key]);
42
+ }
43
+
44
+ /** The saved views of this browser, oldest first; empty when none or when storage cannot be read. */
45
+ export function readViews(storage: Pick<Storage, 'getItem'> | undefined = safeStorage()): SavedView[] {
46
+ try {
47
+ const parsed: unknown = JSON.parse(storage?.getItem(VIEWS_KEY) ?? '[]');
48
+ if (!Array.isArray(parsed)) return [];
49
+ return parsed.filter(
50
+ (item): item is SavedView =>
51
+ !!item && typeof item.id === 'string' && typeof item.name === 'string' && (item.tab === 'runs' || item.tab === 'artifacts') && typeof item.project === 'string' && !!item.filters && typeof item.filters === 'object'
52
+ );
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /** Keeps the saved views. Returns false when storage refused them (full, private or unavailable), so the page can say so. */
59
+ export function writeViews(views: readonly SavedView[], storage: Pick<Storage, 'setItem'> | undefined = safeStorage()): boolean {
60
+ try {
61
+ if (!storage) return false;
62
+ storage.setItem(VIEWS_KEY, JSON.stringify(views));
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function safeStorage(): Storage | undefined {
70
+ try {
71
+ return typeof localStorage === 'undefined' ? undefined : localStorage;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }