@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 1.2.0 (2026-10-28)
4
+
5
+ ### Features
6
+
7
+ - filter runs and artifacts by tags, labels and media type ([ae89175](https://github.com/crvouga/atlas/commit/ae8917551ab82e469e3b35244bf9d570986be76d))
8
+
9
+ ## 1.1.0 (2026-10-28)
10
+
11
+ ### Features
12
+
13
+ - make focused evidence navigation resilient to slow, missing or large catalogs ([db7932c](https://github.com/crvouga/atlas/commit/db7932cf7d912ebf8cba2ae1867e6eda2352f611))
14
+
3
15
  ## 1.0.0 (2026-10-28)
4
16
 
5
17
  ### ⚠️ Breaking changes
@@ -0,0 +1,123 @@
1
+ /** A value of a dimension and how many records have it. */
2
+ export type QueryFacetValue = {
3
+ readonly value: string;
4
+ readonly count: number;
5
+ };
6
+ /** The dimensions a query narrows by, with their address parameters. */
7
+ export declare const QUERY_DIMENSIONS: readonly ["artifactTag", "type", "label", "environment", "producer", "branch", "sha", "outcome", "domain", "spec"];
8
+ /** One of {@link QUERY_DIMENSIONS}. */
9
+ export type QueryDimension = (typeof QUERY_DIMENSIONS)[number];
10
+ /** What a query asks for. Every field is optional; nothing asked is everything. See the module comment for the semantics. */
11
+ export type ArtifactFilter = {
12
+ /** Words (all of them) found in the title, test id, spec, domain, branch, labels, tags, environment or producer; or, for a run, its title. */
13
+ readonly q?: string;
14
+ /** Artifact tags (`demo` and `#demo` are the same). */
15
+ readonly artifactTag?: readonly string[];
16
+ /** Also match the tags an artifact inherits from its check and run. Default false: the exact artifact query. */
17
+ readonly inherited?: boolean;
18
+ /** Media types as roles: `video`, `image`, `audio`, `snapshot`, `text`, `file`, or a producer's own role. `video` means a playable recording. */
19
+ readonly type?: readonly string[];
20
+ /** `key=value`, or `key` for any value. */
21
+ readonly label?: readonly string[];
22
+ /** Environments or devices (`ios`, `desktop-web`). */
23
+ readonly environment?: readonly string[];
24
+ readonly producer?: readonly string[];
25
+ readonly branch?: readonly string[];
26
+ /** Revisions: a full commit id, or a prefix of at least seven characters. */
27
+ readonly sha?: readonly string[];
28
+ /** Statuses (`passed`, `failed`, `running`, `not-run`). A run's outcome is `failed` when any result failed, else `running`, else `passed`, else `not-run`. */
29
+ readonly outcome?: readonly string[];
30
+ /** The feature domain of a result's spec. Not applicable to runs. */
31
+ readonly domain?: readonly string[];
32
+ /** Spec paths. Not applicable to runs. */
33
+ readonly spec?: readonly string[];
34
+ /** Only records at or after this time (epoch ms): a result's recording time, a run's end. */
35
+ readonly since?: number;
36
+ /** Only records at or before this time (epoch ms). */
37
+ readonly until?: number;
38
+ /** Only records with a playable video recording (the same as `type: ['video']`). */
39
+ readonly video?: boolean;
40
+ };
41
+ /** The filters a query used, as they are named in the address and in `filters` of a response. */
42
+ export type FilterName = QueryDimension | 'q' | 'inherited' | 'since' | 'until' | 'video';
43
+ /** The values of a record a query looks at: made by {@link fieldsOfRun} or by the artifact search. */
44
+ export type FilterFields = {
45
+ /** Lower-cased text a search matches. */
46
+ readonly text: string;
47
+ /** The normalized tags of its artifacts. */
48
+ readonly artifactTags: readonly string[];
49
+ /** The tags its artifacts inherit from checks and runs, and its own. */
50
+ readonly tags: readonly string[];
51
+ /** Distinct media types; `video` only when a recording can be played. */
52
+ readonly types: readonly string[];
53
+ /** Artifacts by type and their own tags. Where the report does not say which artifact carries which tag, every artifact of the record is given the record's artifact tags. */
54
+ readonly media: readonly {
55
+ readonly type: string;
56
+ readonly tags: readonly string[];
57
+ }[];
58
+ /** `key=value`, sorted and distinct. */
59
+ readonly labels: readonly string[];
60
+ readonly environments: readonly string[];
61
+ readonly producers: readonly string[];
62
+ readonly branches: readonly string[];
63
+ readonly shas: readonly string[];
64
+ readonly outcomes: readonly string[];
65
+ /** Absent when the dimension does not apply (runs have no domain). */
66
+ readonly domains?: readonly string[];
67
+ readonly specs?: readonly string[];
68
+ readonly at: number;
69
+ };
70
+ /** The facets of a query: for each dimension, its values with disjunctive counts (see the module comment). */
71
+ export type QueryFacets = {
72
+ readonly artifactTags: readonly QueryFacetValue[];
73
+ readonly types: readonly QueryFacetValue[];
74
+ /** Values are `key=value`. */
75
+ readonly labels: readonly QueryFacetValue[];
76
+ readonly environments: readonly QueryFacetValue[];
77
+ readonly producers: readonly QueryFacetValue[];
78
+ readonly branches: readonly QueryFacetValue[];
79
+ readonly outcomes: readonly QueryFacetValue[];
80
+ readonly domains?: readonly QueryFacetValue[];
81
+ };
82
+ /**
83
+ * Splits a comma-separated list from an address: `a,b` is two values; `\,` is a comma and `\\` a backslash inside a
84
+ * value. Values are trimmed and empty ones dropped.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * splitList('demo,release\\,2'); // ['demo', 'release,2']
89
+ * ```
90
+ */
91
+ export declare function splitList(raw: string): string[];
92
+ /** Joins values into one address parameter that {@link splitList} reads back; `undefined` when there are none. */
93
+ export declare function joinList(list: readonly string[]): string | undefined;
94
+ /** A time from an address: epoch milliseconds, or a date or date-time Date can read (a date alone is UTC). `undefined` when unreadable. */
95
+ export declare function parseTime(raw: string | null | undefined): number | undefined;
96
+ /**
97
+ * Reads a query from URL search parameters: `q`, `video`, `inherited`, `since`, `until` and the repeatable or
98
+ * comma-separated {@link QUERY_DIMENSIONS}. Unreadable values are left out, never an error.
99
+ */
100
+ export declare function parseArtifactFilter(params: URLSearchParams): ArtifactFilter;
101
+ /** A query as URL search parameters, the form {@link parseArtifactFilter} reads back. */
102
+ export declare function filterParams(filter: ArtifactFilter): URLSearchParams;
103
+ /** Which filters a query uses, by the names an address gives them. */
104
+ export declare function usedFilters(filter: ArtifactFilter): FilterName[];
105
+ /** Whether a query narrows anything. */
106
+ export declare function isFiltered(filter: ArtifactFilter): boolean;
107
+ /** Every filter in `filter` that may be applied to a record with these fields, as the runs query applies them (no domain or spec). */
108
+ export declare function forRuns(filter: ArtifactFilter): ArtifactFilter;
109
+ /**
110
+ * Whether a record satisfies a query. `skip` leaves one dimension out: what {@link facetsOf} uses to count the values of
111
+ * that dimension, and nothing else should need.
112
+ */
113
+ export declare function matchesFilter(fields: FilterFields, filter: ArtifactFilter, skip?: QueryDimension): boolean;
114
+ /**
115
+ * The facets of a query over every record: for each dimension, the values the records have and how many records
116
+ * would match if that value were chosen in its dimension (see the module comment). A value chosen in the query is
117
+ * listed even when nothing has it, with a count of 0, so it can be removed.
118
+ */
119
+ export declare function facetsOf(records: readonly FilterFields[], filter: ArtifactFilter): QueryFacets;
120
+ /** The outcome of a run from its results by status: `failed`, else `running`, else `passed`, else `not-run`. */
121
+ export declare function outcomeOfCounts(counts: Readonly<Record<string, number>>): string;
122
+ /** The `key=value` labels of a record, sorted and distinct. */
123
+ export declare function labelList(labels: Readonly<Record<string, string>> | undefined): string[];
@@ -0,0 +1,234 @@
1
+ /**
2
+ * @module Run and artifact queries
3
+ *
4
+ * One shareable query for the runs of a project and for the recorded results that carry evidence (recordings,
5
+ * screenshots, traces): by artifact tag, label, media type, feature (domain, spec), environment or device, producer,
6
+ * branch, revision, outcome and time. The same rules answer `GET /api/reports/runs`, `GET /api/reports/artifacts`
7
+ * and the viewer's Runs and Artifacts views, so a link means the same thing everywhere.
8
+ *
9
+ * The semantics are fixed, not inferred:
10
+ *
11
+ * - Across dimensions the filters combine with AND: `artifactTag=demo&type=video&environment=ios` is a demo
12
+ * recording on ios.
13
+ * - Within a dimension the values combine with OR: `environment=ios,android` is either.
14
+ * - When both `artifactTag` and `type` are asked for, one artifact must be both (a `demo`-tagged video, not a demo-tagged
15
+ * screenshot beside an untagged video) wherever the report says which videos carry which tags; otherwise a result with
16
+ * an artifact of that type and an artifact with that tag matches.
17
+ * - Facet counts are disjunctive: the count beside a value is how many records the list would hold if that value were
18
+ * the only choice in its dimension, with every other dimension's filter applied. With a value already chosen, the
19
+ * other values show what choosing them in addition would add, and the chosen one's count is the list's.
20
+ * - A filter never changes a status. Evidence that is not recorded is unknown, never a match.
21
+ *
22
+ * Lists in the address are comma-separated; a comma or backslash inside a value is written `\,` or `\\`
23
+ * ({@link splitList}, {@link joinList}), so a label such as `note=a,b` survives.
24
+ *
25
+ * @see docs/server.md#reading-the-report-as-an-agent
26
+ */
27
+ import { normalizeTag } from './artifact-tags.js';
28
+ /** The dimensions a query narrows by, with their address parameters. */
29
+ export const QUERY_DIMENSIONS = ['artifactTag', 'type', 'label', 'environment', 'producer', 'branch', 'sha', 'outcome', 'domain', 'spec'];
30
+ /**
31
+ * Splits a comma-separated list from an address: `a,b` is two values; `\,` is a comma and `\\` a backslash inside a
32
+ * value. Values are trimmed and empty ones dropped.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * splitList('demo,release\\,2'); // ['demo', 'release,2']
37
+ * ```
38
+ */
39
+ export function splitList(raw) {
40
+ const out = [];
41
+ let current = '';
42
+ for (let at = 0; at < raw.length; at += 1) {
43
+ const char = raw[at];
44
+ const next = raw[at + 1];
45
+ if (char === '\\' && (next === ',' || next === '\\')) {
46
+ current += next;
47
+ at += 1;
48
+ }
49
+ else if (char === ',') {
50
+ out.push(current);
51
+ current = '';
52
+ }
53
+ else
54
+ current += char;
55
+ }
56
+ out.push(current);
57
+ return out.map(value => value.trim()).filter(Boolean);
58
+ }
59
+ /** Joins values into one address parameter that {@link splitList} reads back; `undefined` when there are none. */
60
+ export function joinList(list) {
61
+ return list.length ? list.map(value => value.replace(/\\/g, '\\\\').replace(/,/g, '\\,')).join(',') : undefined;
62
+ }
63
+ /** A time from an address: epoch milliseconds, or a date or date-time Date can read (a date alone is UTC). `undefined` when unreadable. */
64
+ export function parseTime(raw) {
65
+ if (!raw)
66
+ return undefined;
67
+ const value = /^\d+$/.test(raw.trim()) ? Number(raw) : Date.parse(raw);
68
+ return Number.isFinite(value) ? value : undefined;
69
+ }
70
+ /**
71
+ * Reads a query from URL search parameters: `q`, `video`, `inherited`, `since`, `until` and the repeatable or
72
+ * comma-separated {@link QUERY_DIMENSIONS}. Unreadable values are left out, never an error.
73
+ */
74
+ export function parseArtifactFilter(params) {
75
+ const many = (name) => params.getAll(name).flatMap(splitList);
76
+ const filter = {};
77
+ const q = params.get('q')?.trim();
78
+ if (q)
79
+ filter.q = q;
80
+ for (const name of QUERY_DIMENSIONS) {
81
+ const values = many(name);
82
+ if (values.length)
83
+ filter[name] = values;
84
+ }
85
+ const truthy = (name) => ['1', 'true'].includes(params.get(name) ?? '');
86
+ if (truthy('inherited'))
87
+ filter.inherited = true;
88
+ if (truthy('video'))
89
+ filter.video = true;
90
+ const since = parseTime(params.get('since'));
91
+ if (since !== undefined)
92
+ filter.since = since;
93
+ const until = parseTime(params.get('until'));
94
+ if (until !== undefined)
95
+ filter.until = until;
96
+ return filter;
97
+ }
98
+ /** A query as URL search parameters, the form {@link parseArtifactFilter} reads back. */
99
+ export function filterParams(filter) {
100
+ const params = new URLSearchParams();
101
+ if (filter.q)
102
+ params.set('q', filter.q);
103
+ for (const name of QUERY_DIMENSIONS) {
104
+ const joined = joinList(filter[name] ?? []);
105
+ if (joined)
106
+ params.set(name, joined);
107
+ }
108
+ if (filter.inherited)
109
+ params.set('inherited', '1');
110
+ if (filter.since !== undefined)
111
+ params.set('since', String(filter.since));
112
+ if (filter.until !== undefined)
113
+ params.set('until', String(filter.until));
114
+ if (filter.video)
115
+ params.set('video', 'true');
116
+ return params;
117
+ }
118
+ /** Which filters a query uses, by the names an address gives them. */
119
+ export function usedFilters(filter) {
120
+ const used = [];
121
+ if (filter.q)
122
+ used.push('q');
123
+ for (const name of QUERY_DIMENSIONS)
124
+ if (filter[name]?.length)
125
+ used.push(name);
126
+ if (filter.inherited)
127
+ used.push('inherited');
128
+ if (filter.since !== undefined)
129
+ used.push('since');
130
+ if (filter.until !== undefined)
131
+ used.push('until');
132
+ if (filter.video)
133
+ used.push('video');
134
+ return used;
135
+ }
136
+ /** Whether a query narrows anything. */
137
+ export function isFiltered(filter) {
138
+ return usedFilters(filter).some(name => name !== 'inherited');
139
+ }
140
+ /** Every filter in `filter` that may be applied to a record with these fields, as the runs query applies them (no domain or spec). */
141
+ export function forRuns(filter) {
142
+ const { domain: _domain, spec: _spec, ...rest } = filter;
143
+ return rest;
144
+ }
145
+ function hasTag(tags, wanted) {
146
+ return wanted.some(tag => { const normal = normalizeTag(tag); return normal !== null && tags.includes(normal); });
147
+ }
148
+ function labelMatches(labels, wanted) {
149
+ return wanted.includes('=') ? labels.includes(wanted) : labels.some(label => label.startsWith(`${wanted}=`));
150
+ }
151
+ /**
152
+ * Whether a record satisfies a query. `skip` leaves one dimension out: what {@link facetsOf} uses to count the values of
153
+ * that dimension, and nothing else should need.
154
+ */
155
+ export function matchesFilter(fields, filter, skip) {
156
+ if (filter.q) {
157
+ for (const term of filter.q.toLowerCase().split(/\s+/).filter(Boolean))
158
+ if (!fields.text.includes(term))
159
+ return false;
160
+ }
161
+ const tags = skip === 'artifactTag' ? [] : filter.artifactTag ?? [];
162
+ const types = skip === 'type' ? [] : [...(filter.type ?? []), ...(filter.video ? ['video'] : [])];
163
+ if (tags.length && types.length) {
164
+ if (!fields.media.some(item => types.includes(item.type) && (hasTag(item.tags, tags) || (filter.inherited === true && hasTag(fields.tags, tags)))))
165
+ return false;
166
+ }
167
+ else {
168
+ if (tags.length && !hasTag(fields.artifactTags, tags) && !(filter.inherited === true && hasTag(fields.tags, tags)))
169
+ return false;
170
+ if (types.length && !types.some(type => fields.types.includes(type)))
171
+ return false;
172
+ }
173
+ if (skip !== 'label' && filter.label?.length && !filter.label.some(wanted => labelMatches(fields.labels, wanted)))
174
+ return false;
175
+ if (skip !== 'environment' && filter.environment?.length && !filter.environment.some(value => fields.environments.includes(value)))
176
+ return false;
177
+ if (skip !== 'producer' && filter.producer?.length && !filter.producer.some(value => fields.producers.includes(value)))
178
+ return false;
179
+ if (skip !== 'branch' && filter.branch?.length && !filter.branch.some(value => fields.branches.includes(value)))
180
+ return false;
181
+ if (skip !== 'sha' && filter.sha?.length && !filter.sha.some(value => value.length >= 7 && fields.shas.some(sha => sha.startsWith(value.toLowerCase()))))
182
+ return false;
183
+ if (skip !== 'outcome' && filter.outcome?.length && !filter.outcome.some(value => fields.outcomes.includes(value)))
184
+ return false;
185
+ if (skip !== 'domain' && filter.domain?.length && fields.domains && !filter.domain.some(value => fields.domains.includes(value)))
186
+ return false;
187
+ if (skip !== 'spec' && filter.spec?.length && fields.specs && !filter.spec.some(value => fields.specs.includes(value)))
188
+ return false;
189
+ if (filter.since !== undefined && fields.at < filter.since)
190
+ return false;
191
+ if (filter.until !== undefined && fields.at > filter.until)
192
+ return false;
193
+ return true;
194
+ }
195
+ /** Distinct values counted, most first and then alphabetically. */
196
+ function tally(values) {
197
+ const counts = new Map();
198
+ for (const list of values)
199
+ for (const value of new Set(list))
200
+ counts.set(value, (counts.get(value) ?? 0) + 1);
201
+ return [...counts].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count || (a.value < b.value ? -1 : a.value > b.value ? 1 : 0));
202
+ }
203
+ /**
204
+ * The facets of a query over every record: for each dimension, the values the records have and how many records
205
+ * would match if that value were chosen in its dimension (see the module comment). A value chosen in the query is
206
+ * listed even when nothing has it, with a count of 0, so it can be removed.
207
+ */
208
+ export function facetsOf(records, filter) {
209
+ const scope = (skip) => records.filter(record => matchesFilter(record, filter, skip));
210
+ const withChosen = (found, chosen) => [
211
+ ...found, ...(chosen ?? []).filter(value => !found.some(entry => entry.value === value)).map(value => ({ value, count: 0 }))
212
+ ];
213
+ const dimension = (skip, pick, chosen) => withChosen(tally(scope(skip).map(pick)), chosen);
214
+ const normalTags = (filter.artifactTag ?? []).map(tag => normalizeTag(tag)).filter((tag) => tag !== null);
215
+ const domains = records.some(record => record.domains);
216
+ return {
217
+ artifactTags: dimension('artifactTag', fields => fields.artifactTags, normalTags),
218
+ types: dimension('type', fields => fields.types, filter.type),
219
+ labels: dimension('label', fields => fields.labels, (filter.label ?? []).filter(value => value.includes('='))),
220
+ environments: dimension('environment', fields => fields.environments, filter.environment),
221
+ producers: dimension('producer', fields => fields.producers, filter.producer),
222
+ branches: dimension('branch', fields => fields.branches, filter.branch),
223
+ outcomes: dimension('outcome', fields => fields.outcomes, filter.outcome),
224
+ ...(domains ? { domains: dimension('domain', fields => fields.domains ?? [], filter.domain) } : {})
225
+ };
226
+ }
227
+ /** The outcome of a run from its results by status: `failed`, else `running`, else `passed`, else `not-run`. */
228
+ export function outcomeOfCounts(counts) {
229
+ return (counts.failed ?? 0) > 0 ? 'failed' : (counts.running ?? 0) > 0 ? 'running' : (counts.passed ?? 0) > 0 ? 'passed' : 'not-run';
230
+ }
231
+ /** The `key=value` labels of a record, sorted and distinct. */
232
+ export function labelList(labels) {
233
+ return Object.entries(labels ?? {}).map(([key, value]) => `${key}=${value}`).sort();
234
+ }
@@ -77,7 +77,8 @@ function videoOf(item, owner, where) {
77
77
  if (!isVideoAttachment(item))
78
78
  return undefined;
79
79
  const recording = item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
80
- return { name: item.name, owner, ...where, ...(recording === undefined ? {} : { recording }), available: recording !== undefined };
80
+ const tags = normalizeTags(item.tags);
81
+ return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined };
81
82
  }
82
83
  /**
83
84
  * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
@@ -51,6 +51,8 @@ export type VideoRef = {
51
51
  readonly attempt?: number;
52
52
  /** The name of the step that holds it, when `owner` is `step`. */
53
53
  readonly step?: string;
54
+ /** The normalized tags of this attachment itself (`demo`), so a query can ask for a tagged video rather than a result that has a video and some tagged file. Absent when it has none (and in documents of older servers). */
55
+ readonly tags?: readonly string[];
54
56
  /** What identifies the bytes (digest, bundle key or address); references with the same one are the same recording. Absent when `available` is false. */
55
57
  readonly recording?: string;
56
58
  /** False when the reference names no bytes at all (no bundle key, address or inline text), so it can never be played. */
@@ -1,3 +1,4 @@
1
+ import { type ArtifactFilter, type FilterFields, type FilterName, type QueryFacets } from './artifact-query.js';
1
2
  import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
2
3
  import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts, VideoCounts, VideoRef } from './evidence-types.js';
3
4
  import { RecordedSpecs } from './recorded-specs.js';
@@ -263,12 +264,20 @@ export type RunSummary = {
263
264
  * and references that cannot be played. Absent when it has none, and in a report that carries no video information.
264
265
  */
265
266
  readonly videos?: VideoCounts;
267
+ /** The normalized tags of the attachments of its results and layers, sorted and distinct. Absent when none is tagged. */
268
+ readonly artifactTags?: readonly string[];
269
+ /** The tags of its checks and layers, which its attachments inherit (what `inherited=1` also matches), sorted and distinct. Absent when none. */
270
+ readonly tags?: readonly string[];
271
+ /** The media types (roles) of its attachments, sorted and distinct; `video` only for a playable recording. Absent when it has no attachment. */
272
+ readonly types?: readonly string[];
273
+ /** The `key=value` labels of its results and layers, sorted and distinct. Absent when none. */
274
+ readonly labels?: readonly string[];
266
275
  };
267
- /** What `runList` can be asked. */
268
- export type RunQuery = {
269
- /** Only runs that have at least one playable video recording. `total` then counts only those. */
270
- readonly video?: boolean;
271
- };
276
+ /**
277
+ * What `runList` can be asked: an {@link ArtifactFilter}, of which a run answers every dimension but `domain` and
278
+ * `spec`. `video` keeps only runs that have at least one playable video recording; `total` then counts only those.
279
+ */
280
+ export type RunQuery = ArtifactFilter;
272
281
  /** `GET /api/reports/runs`: runs, newest first. */
273
282
  export type RunList = {
274
283
  readonly schemaVersion: typeof TEST_CATALOG_VERSION;
@@ -278,6 +287,10 @@ export type RunList = {
278
287
  readonly runs: readonly RunSummary[];
279
288
  /** How many of all runs (whatever was asked, before `limit`) have a playable video recording. */
280
289
  readonly withVideo?: number;
290
+ /** The filters of the query that were applied. A viewer that asked for another knows the server ignored it. */
291
+ readonly filters?: readonly FilterName[];
292
+ /** The values each filter dimension offers with counts over every run (see `facetsOf`). */
293
+ readonly facets?: QueryFacets;
281
294
  };
282
295
  /** One test's last result within one run. */
283
296
  export type RunResult = {
@@ -302,6 +315,60 @@ export type RunDetail = {
302
315
  readonly run: RunSummary;
303
316
  readonly results: readonly RunResult[];
304
317
  };
318
+ /**
319
+ * One recorded result that carries evidence (an attachment: a recording, a screenshot, a trace), with where and when it
320
+ * was recorded: the unit `artifactList` finds. It is a result in the history, not only the report's current winner, so
321
+ * a recording tagged `demo` is found with its original run, revision and status even when a later attempt is untagged.
322
+ */
323
+ export type ArtifactResult = {
324
+ /** The test's key, as in `TestSummary.key`. */
325
+ readonly key: string;
326
+ readonly id: string;
327
+ readonly title: string;
328
+ readonly specPath: string | null;
329
+ /** The domain of its spec, or null when it has none. */
330
+ readonly domain: string | null;
331
+ /** When it was recorded, epoch ms. */
332
+ readonly at: number;
333
+ readonly status: EvidenceStatus;
334
+ readonly environment: string | null;
335
+ readonly producer: string;
336
+ /** The kind of the part that held it. */
337
+ readonly kind: string;
338
+ readonly parameters: Readonly<Record<string, string>>;
339
+ readonly branch: string | null;
340
+ readonly sha: string | null;
341
+ /** The key of its run (see `runKey`). */
342
+ readonly run: string;
343
+ readonly runUrl: string | null;
344
+ /** The id of the layer that recorded it: with `key`, the exact result to open. */
345
+ readonly layer: string;
346
+ readonly backend: string;
347
+ /** Attachments on the result, including those on its steps and attempts. */
348
+ readonly attachments: number;
349
+ /** The normalized tags of its attachments. */
350
+ readonly artifactTags: readonly string[];
351
+ /** The tags of its check and run, which its attachments inherit. */
352
+ readonly tags: readonly string[];
353
+ /** The media types (roles) of its attachments; `video` only for a playable recording. */
354
+ readonly types: readonly string[];
355
+ readonly labels: Readonly<Record<string, string>>;
356
+ readonly videos?: readonly VideoRef[];
357
+ };
358
+ /** `GET /api/reports/artifacts`: the recorded results that carry evidence and match a query, newest first. */
359
+ export type ArtifactList = {
360
+ readonly schemaVersion: typeof TEST_CATALOG_VERSION;
361
+ readonly version: string;
362
+ /** Results that matched, before `offset` and `limit`. */
363
+ readonly total: number;
364
+ readonly offset: number;
365
+ readonly limit: number;
366
+ readonly results: readonly ArtifactResult[];
367
+ /** The filters of the query that were applied. */
368
+ readonly filters: readonly FilterName[];
369
+ /** The values each dimension offers with disjunctive counts over every result that carries evidence. */
370
+ readonly facets: QueryFacets;
371
+ };
305
372
  /**
306
373
  * The run a layer belongs to: the execution its producer named (`run:<id>`), else its run URL, else `branch@sha`, else
307
374
  * `local`. Layers with the same key are one run. Only grouping: it is never part of the slot a result fills.
@@ -351,6 +418,14 @@ export declare class TestHistory {
351
418
  readonly run: RunSummary;
352
419
  readonly results: RunResult[];
353
420
  } | null;
421
+ /**
422
+ * Every recorded result that carries an attachment, newest first, with the values a query looks at. Built once per
423
+ * layer added. The domain is left to the caller (`artifactList`), which may know more specs than the layers recorded.
424
+ */
425
+ evidenceResults(): readonly {
426
+ readonly result: ArtifactResult;
427
+ readonly fields: FilterFields;
428
+ }[];
354
429
  /** The keys of the tests one run recorded. */
355
430
  testsIn(key: string): ReadonlySet<string>;
356
431
  }
@@ -403,6 +478,25 @@ export declare function hasVideo(run: Pick<RunSummary, 'videos'>): boolean;
403
478
  * any recording's bytes. `withVideo` always counts every run that has one.
404
479
  */
405
480
  export declare function runList(history: TestHistory, version: string, limit?: number, query?: RunQuery): RunList;
481
+ /** What a query looks at in a run: see {@link ArtifactFilter}. A run's outcome is `failed` when any result failed. */
482
+ export declare function fieldsOfRun(run: RunSummary): FilterFields;
483
+ /**
484
+ * The recorded results that carry evidence and match a query, newest first, across the whole history (`limit` default
485
+ * 100). `total` and the facet counts apply to every match before `offset` and `limit`. `domainOf` tells the domain of a
486
+ * spec path when the caller knows more than the layers recorded (a server with a spec set); otherwise it is the
487
+ * recorded spec's domain, else the folder the path implies.
488
+ *
489
+ * @example
490
+ * ```ts
491
+ * const list = artifactList(history, 'v1', { artifactTag: ['demo'], type: ['video'], environment: ['ios'] });
492
+ * list.results.map(r => [r.title, r.run, r.sha, r.status]);
493
+ * ```
494
+ */
495
+ export declare function artifactList(history: TestHistory, version: string, query?: ArtifactFilter, page?: {
496
+ readonly limit?: number;
497
+ readonly offset?: number;
498
+ readonly domainOf?: (specPath: string) => string | undefined;
499
+ }): ArtifactList;
406
500
  /** One run with every result it recorded, or null when no run has that key. */
407
501
  export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
408
502
  /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
@@ -415,5 +509,7 @@ export declare function catalogMarkdown(catalog: TestCatalog): string;
415
509
  export declare function historyMarkdown(document: TestHistoryDocument): string;
416
510
  /** The run list as Markdown. */
417
511
  export declare function runsMarkdown(list: RunList): string;
512
+ /** The artifact results as Markdown, for an agent or a terminal. */
513
+ export declare function artifactsMarkdown(list: ArtifactList): string;
418
514
  /** One run as Markdown. */
419
515
  export declare function runMarkdown(detail: RunDetail): string;