@crvouga/atlas 0.14.0 → 0.15.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 (39) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/reports/src/artifact-tag-store.d.ts +20 -0
  3. package/dist/reports/src/artifact-tag-store.js +70 -0
  4. package/dist/reports/src/artifact-tags.d.ts +96 -0
  5. package/dist/reports/src/artifact-tags.js +96 -0
  6. package/dist/reports/src/evidence-detail.d.ts +1 -1
  7. package/dist/reports/src/evidence-detail.js +6 -2
  8. package/dist/reports/src/evidence-types.d.ts +2 -0
  9. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  10. package/dist/reports/src/reports-route.d.ts +8 -0
  11. package/dist/reports/src/test-catalog.d.ts +11 -23
  12. package/dist/reports/src/test-catalog.js +42 -4
  13. package/dist/schema/src/api.d.ts +79 -0
  14. package/dist/schema/src/api.js +31 -1
  15. package/dist/schema/src/layer.d.ts +33 -0
  16. package/dist/schema/src/layer.js +11 -1
  17. package/dist/server/src/app.js +32 -1
  18. package/dist/server/src/openapi.d.ts +1 -1
  19. package/dist/server/src/openapi.js +18 -2
  20. package/docs/extending.md +3 -0
  21. package/docs/reference/http-api.md +48 -0
  22. package/docs/reference/reports.md +217 -2
  23. package/docs/reference/schema.md +32 -0
  24. package/docs/rich-evidence.md +28 -0
  25. package/docs/server.md +24 -0
  26. package/llms-full.txt +103 -0
  27. package/package.json +1 -1
  28. package/packages/schema/schemas/check-detail.schema.json +28 -0
  29. package/packages/schema/schemas/effective-delta.schema.json +14 -0
  30. package/packages/schema/schemas/effective-evidence.schema.json +14 -0
  31. package/packages/schema/schemas/evidence-page.schema.json +7 -0
  32. package/packages/schema/schemas/layer-rows.schema.json +7 -0
  33. package/packages/schema/schemas/layer.schema.json +70 -0
  34. package/packages/schema/schemas/publish-checks.schema.json +35 -0
  35. package/packages/schema/schemas/test-catalog.schema.json +42 -0
  36. package/packages/schema/schemas/test-history.schema.json +28 -0
  37. package/packages/server/openapi.json +732 -1
  38. package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
  39. package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
@@ -4,6 +4,209 @@ Layered evidence for any kind of spec: publishing layers, stores (folder, S3, an
4
4
 
5
5
  > Generated from the doc comments of `packages/reports/src` by `bun run docs`. Do not edit: change the comment next to the code.
6
6
 
7
+ ### Reviewer tags in a store
8
+
9
+ Source: `packages/reports/src/artifact-tag-store.ts`. Import: `@crvouga/atlas/reports/artifact-tag-store`
10
+
11
+ Where a reviewer's tags for attachments are kept: one small immutable object per edit under
12
+ `curation/artifact-tags/`, named by time and a random id so editors never overwrite one another, and never changed
13
+ or deleted afterwards. The layers they are about are not touched. The list of edits is the audit trail: who changed
14
+ which tags of which attachment, and when. `foldArtifactTags` turns it into the tags that stand now.
15
+
16
+ See docs/server.md#tagging-artifacts
17
+
18
+ #### `ARTIFACT_TAGS_PREFIX`
19
+
20
+ ```ts
21
+ export const ARTIFACT_TAGS_PREFIX = …
22
+ ```
23
+
24
+ The prefix of every recorded edit in a store.
25
+
26
+ #### `ArtifactTagError`
27
+
28
+ ```ts
29
+ export class ArtifactTagError extends Error
30
+ ```
31
+
32
+ The edit was not acceptable (`status` is the HTTP status to answer).
33
+
34
+ #### `recordArtifactTags`
35
+
36
+ ```ts
37
+ export async function recordArtifactTags(store: ObjectStore, input: unknown, by: string, atMs: number): Promise<ArtifactTagEntry>
38
+ ```
39
+
40
+ Validates an edit (`ArtifactTagEditSchema`), normalizes its tags and records it as a new object in `store`, by
41
+ `by` at `atMs`. Throws {@link ArtifactTagError} for a body that is not an edit, a tag that is not a tag (empty, or
42
+ over 64 characters) or an edit that changes nothing. Recording the same edit twice records two entries.
43
+
44
+ #### `readArtifactTagEntries`
45
+
46
+ ```ts
47
+ export async function readArtifactTagEntries(store: ObjectStore): Promise<ArtifactTagEntry[]>
48
+ ```
49
+
50
+ Every recorded edit in `store`, oldest first. An object that cannot be read as an edit is skipped (unknown, never
51
+ guessed at). A store with none gives an empty list. Transport errors throw.
52
+
53
+ ### Artifact tags
54
+
55
+ Source: `packages/reports/src/artifact-tags.ts`. Import: `@crvouga/atlas/reports/artifact-tags`
56
+
57
+ The tags of one attachment (a recording, a screenshot, a trace): how a tag is normalized so `demo` and `#demo`
58
+ are the same tag, which tags an attachment shows (its producer's, its reviewers', and those it inherits from its
59
+ check and run), and how a reviewer's edits fold into the tags that stand now. Pure functions, safe in a browser.
60
+
61
+ Tags describe evidence; they never decide a status, a slot or a winner. A producer writes `tags` on the attachment
62
+ inside an immutable layer. A reviewer's tags live apart, as an append-only list of edits that the server records
63
+ with who made each and when (`artifact-tag-store.ts`); the layer is never rewritten.
64
+
65
+ See docs/rich-evidence.md#tagging-artifacts-and-demos
66
+
67
+ #### `MAX_TAG_LENGTH`
68
+
69
+ ```ts
70
+ export const MAX_TAG_LENGTH = …
71
+ ```
72
+
73
+ The longest a normalized tag may be, in characters.
74
+
75
+ #### `normalizeTag`
76
+
77
+ ```ts
78
+ export function normalizeTag(raw: string): string | null
79
+ ```
80
+
81
+ Normalizes a tag so equal tags compare equal: Unicode NFC, any leading `#` removed, surrounding whitespace
82
+ trimmed, letters lower-cased and each run of whitespace inside turned into one `-`. Every other character is kept,
83
+ so `Smoke`, `#smoke` and ` smoke ` are `smoke`, and `team/payments` or `release=2` stay as they are. Returns
84
+ `null` for a tag that is empty after that (`''`, `'#'`, `' '`) or longer than {@link MAX_TAG_LENGTH}.
85
+
86
+ **Example**
87
+
88
+ ```ts
89
+ normalizeTag('#Demo'); // 'demo'
90
+ normalizeTag('release candidate'); // 'release-candidate'
91
+ normalizeTag('#'); // null
92
+ ```
93
+
94
+ #### `normalizeTags`
95
+
96
+ ```ts
97
+ export function normalizeTags(raw: Iterable<string> | undefined): string[]
98
+ ```
99
+
100
+ Normalizes every tag with {@link normalizeTag}, dropping the ones that are not tags and the repeats; the first of each keeps its place.
101
+
102
+ #### `displayTag`
103
+
104
+ ```ts
105
+ export const displayTag = …
106
+ ```
107
+
108
+ The tag as a person sees it: `#demo`.
109
+
110
+ #### `artifactRef`
111
+
112
+ ```ts
113
+ export function artifactRef(attachment: Pick<Attachment, 'id' | 'bundleKey' | 'name'>): string
114
+ ```
115
+
116
+ How an edit names an attachment: `id:<id>` when it has an `id`, else `key:<bundleKey>` (its bytes), else
117
+ `name:<name>`. It is stable for a layer, which never changes. Two attachments in one check with the same bytes and
118
+ no `id` share a reference, and so share reviewer tags; give them an `id` to tag them apart.
119
+
120
+ #### `ArtifactTarget`
121
+
122
+ ```ts
123
+ export type ArtifactTarget = {
124
+ readonly layer: string;
125
+ readonly check: string;
126
+ readonly kind?: string | undefined;
127
+ readonly artifact: string;
128
+ };
129
+ ```
130
+
131
+ Where a reviewer's tags are about: one attachment of one check of one archived layer.
132
+
133
+ #### `ArtifactTagEntry`
134
+
135
+ ```ts
136
+ export type ArtifactTagEntry = {
137
+ readonly id: string;
138
+ readonly target: ArtifactTarget;
139
+ readonly add: readonly string[];
140
+ readonly remove: readonly string[];
141
+ readonly by: string;
142
+ readonly atMs: number;
143
+ };
144
+ ```
145
+
146
+ One recorded edit. Entries are never changed or removed.
147
+
148
+ #### `targetKey`
149
+
150
+ ```ts
151
+ export const targetKey = …
152
+ ```
153
+
154
+ A string that is equal for equal targets (the part `kind` is optional and then does not matter).
155
+
156
+ #### `ReviewerTag`
157
+
158
+ ```ts
159
+ export type ReviewerTag = { readonly tag: string; readonly by: string; readonly atMs: number };
160
+ ```
161
+
162
+ A tag a reviewer added: who added it last, and when.
163
+
164
+ #### `foldArtifactTags`
165
+
166
+ ```ts
167
+ export function foldArtifactTags(entries: readonly ArtifactTagEntry[]): Map<string, ReviewerTag[]>
168
+ ```
169
+
170
+ Folds the recorded edits (in any order) into the reviewer tags that stand now, by target (see {@link targetKey}).
171
+ Edits apply oldest first, ties by `id`; a tag added, removed and added again stands, with the time and author of
172
+ the last add. A target whose tags were all removed is absent. Entries that are not understood are ignored.
173
+
174
+ #### `TagSource`
175
+
176
+ ```ts
177
+ export type TagSource = 'artifact' | 'reviewer' | 'inherited';
178
+ ```
179
+
180
+ Where a shown tag comes from: the attachment's own producer (`artifact`), a reviewer after the upload (`reviewer`), or the check or run around it (`inherited`).
181
+
182
+ #### `ArtifactTag`
183
+
184
+ ```ts
185
+ export type ArtifactTag = { readonly tag: string; readonly source: TagSource; readonly by?: string; readonly atMs?: number };
186
+ ```
187
+
188
+ One tag an attachment shows, with its source and, for a reviewer's tag, who added it and when.
189
+
190
+ #### `artifactTagsOf`
191
+
192
+ ```ts
193
+ export function artifactTagsOf(attachment: Pick<Attachment, 'tags'>, options: { readonly reviewer?: readonly ReviewerTag[]; readonly inherited?: Iterable<string> } = {}): ArtifactTag[]
194
+ ```
195
+
196
+ The tags one attachment shows, normalized and without repeats: its producer's own (`artifact`), then the
197
+ reviewers' (`reviewer`), then those of its check and run that it does not have itself (`inherited`). A tag it has
198
+ more than once is shown once, by the first source above.
199
+
200
+ #### `hasArtifactTag`
201
+
202
+ ```ts
203
+ export function hasArtifactTag(tags: readonly ArtifactTag[], wanted: string, options: { readonly inherited?: boolean } = {}): boolean
204
+ ```
205
+
206
+ Does an attachment match a tag (either spelling)? An exact query (`inherited: false`, the default) matches only the
207
+ tags the attachment has itself, from its producer or a reviewer; an inherited query also matches the tags it
208
+ inherits from its check and run. The two differ for a recording that is untagged under a tagged run.
209
+
7
210
  ### Reading the files attachments name
8
211
 
9
212
  Source: `packages/reports/src/attachment-files.ts`. Import: `@crvouga/atlas/reports/attachment-files`
@@ -1222,7 +1425,7 @@ read when someone asks for it. Undefined when there is none.
1222
1425
 
1223
1426
  Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
1224
1427
  attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
1225
- label the later holder wins. `roles` are the attachments' roles (or, without one, what their media type or file
1428
+ label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
1226
1429
  name implies), sorted and distinct.
1227
1430
 
1228
1431
  #### `roleOfType`
@@ -1418,6 +1621,8 @@ export type EvidenceSummary = {
1418
1621
  readonly attempts: number;
1419
1622
  readonly links: number;
1420
1623
  readonly tags: readonly string[];
1624
+ /** The normalized tags of the attachments themselves (`#demo` is `demo`), sorted and distinct. Absent when no attachment is tagged. Unlike `tags` they are never inherited from a check or run. */
1625
+ readonly artifactTags?: readonly string[];
1421
1626
  readonly labels: Readonly<Record<string, string>>;
1422
1627
  /** The distinct roles of its attachments (or what their media types say), to show what kind of evidence there is. */
1423
1628
  readonly roles: readonly string[];
@@ -7514,6 +7719,10 @@ export type TestSummary = {
7514
7719
  readonly outcomes: readonly TestOutcome[];
7515
7720
  readonly stats: TestStats;
7516
7721
  readonly tags: readonly string[];
7722
+ /** The normalized tags of the attachments of its current results (`demo`, shown as `#demo`). Never inherited from a check or run; the `tags` above are. Absent in documents of older servers. */
7723
+ readonly artifactTags?: readonly string[];
7724
+ /** The normalized tags its attachments inherit from their check and run (those of the results that have attachments), apart from `artifactTags`. What an inherited artifact query also matches. */
7725
+ readonly inheritedArtifactTags?: readonly string[];
7517
7726
  readonly labels: Readonly<Record<string, string>>;
7518
7727
  /** What its current results carry: attachments, steps, retries, and the kinds of attachment. */
7519
7728
  readonly evidence: { readonly attachments: number; readonly steps: number; readonly attempts: number; readonly roles: readonly string[] };
@@ -7604,6 +7813,8 @@ export type CatalogFacets = {
7604
7813
  readonly domains: readonly FacetValue[];
7605
7814
  readonly specs: readonly (CatalogSpec & { readonly tests: number; readonly counts: StatusCounts })[];
7606
7815
  readonly tags: readonly FacetValue[];
7816
+ /** The tags of attachments, normalized. Absent in documents of older servers. */
7817
+ readonly artifactTags?: readonly FacetValue[];
7607
7818
  readonly branches: readonly FacetValue[];
7608
7819
  readonly labels: Readonly<Record<string, readonly FacetValue[]>>;
7609
7820
  };
@@ -7641,6 +7852,10 @@ export type TestQuery = {
7641
7852
  /** Spec paths (`specs/...`). */
7642
7853
  readonly spec?: readonly string[];
7643
7854
  readonly tag?: readonly string[];
7855
+ /** Only tests with an attachment carrying this tag (`demo` and `#demo` are the same), not counting tags inherited from a check or run: the exact artifact query. `tag` is the inherited one. */
7856
+ readonly artifactTag?: readonly string[];
7857
+ /** With `artifactTag`: also match the tags an attachment inherits from its check and run (a recording without a tag of its own under a run tagged `demo`). Default false: the exact query. */
7858
+ readonly inherited?: boolean;
7644
7859
  /** Only tests recorded in this run (a key from `runList`). */
7645
7860
  readonly run?: string;
7646
7861
  readonly sort?: TestSort;
@@ -7921,7 +8136,7 @@ One run with every result it recorded, or null when no run has that key.
7921
8136
  export function parseTestQuery(params: URLSearchParams): TestQuery
7922
8137
  ```
7923
8138
 
7924
- Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`.
8139
+ 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`.
7925
8140
 
7926
8141
  #### `formatMs`
7927
8142
 
@@ -185,6 +185,38 @@ export const ChecksResultSchema = …
185
185
 
186
186
  `POST /api/checks` answered: the layer that was written.
187
187
 
188
+ #### `ArtifactTargetSchema`
189
+
190
+ ```ts
191
+ export const ArtifactTargetSchema = …
192
+ ```
193
+
194
+ Which attachment a reviewer's tags belong to: one attachment of one check of one archived layer.
195
+
196
+ #### `ArtifactTagEditSchema`
197
+
198
+ ```ts
199
+ export const ArtifactTagEditSchema = …
200
+ ```
201
+
202
+ The body of `POST /api/reports/artifact-tags`: tags to add to and to remove from one attachment. At least one tag overall.
203
+
204
+ #### `ArtifactTagEntrySchema`
205
+
206
+ ```ts
207
+ export const ArtifactTagEntrySchema = …
208
+ ```
209
+
210
+ One recorded edit of an attachment's reviewer tags. Entries are never changed or removed: they are the audit trail.
211
+
212
+ #### `ArtifactTagsSchema`
213
+
214
+ ```ts
215
+ export const ArtifactTagsSchema = …
216
+ ```
217
+
218
+ `GET /api/reports/artifact-tags`: every recorded edit, oldest first. Fold them with `foldArtifactTags`.
219
+
188
220
  #### `HelloEventSchema`
189
221
 
190
222
  ```ts
@@ -26,6 +26,7 @@ decided exactly as before ([the report contract](report-contract.md)). Evidence
26
26
  | `steps` (`parentId`, `offsetMs`, `durationMs`, `status`, `kind`) | check, attempt | Draws the check's timeline: nested steps, when each started, how long it took, which one failed. |
27
27
  | `attempts` | check | Every try of a retried check, with its own status, detail, steps and attachments. A check that passed after failing is flaky. |
28
28
  | `tags`, `labels` | check, layer | Filters and groups (`smoke`, `flaky`, an owner, a browser). |
29
+ | `tags` | attachment | Marks one recording or screenshot, for example `demo`: shown as a `#demo` chip, and searchable. See [Tagging artifacts and demos](#tagging-artifacts-and-demos). |
29
30
  | `annotations`, `links` | check, step, attempt, layer | Notes, warnings and issue links next to the result. |
30
31
  | `specPath` | check | The spec a check belongs to (a `.feature`, a test file), relative to the repository. Checks are grouped by it. |
31
32
  | `specs[]` (`path`, `domain`, `kind`, `title`) | layer | Names each spec and its group (`domain`), so a server can group checks by spec with a title even when no [spec set](integrating.md#tell-atlas-which-spec-a-check-belongs-to) has been published. |
@@ -34,6 +35,33 @@ decided exactly as before ([the report contract](report-contract.md)). Evidence
34
35
  Send `sha`, `branch` and `runUrl` on every layer too. They never change a result, but they tie results to a commit
35
36
  and to the CI run that produced them.
36
37
 
38
+ ## Tagging artifacts and demos
39
+
40
+ To mark one recording as a demo, tag the attachment itself. A tag on a check says something about the result; a tag on
41
+ an attachment says something about that file, and no Gherkin tag, label, environment or producer name is needed:
42
+
43
+ ```ts
44
+ { name: 'checkout.webm', role: 'video', tags: ['demo'], labels: { audience: 'business' } }
45
+ ```
46
+
47
+ The viewer shows it as a `#demo` chip on the artifact card. Selecting the chip filters the result's evidence to the
48
+ attachments with that tag, and says how many match. Tags describe evidence; they never change a status or a slot.
49
+
50
+ - **One spelling.** A tag is normalized before it is compared or stored: a leading `#` is dropped, letters are
51
+ lower-cased, each run of whitespace becomes `-`, and surrounding space is trimmed, so `demo`, `#demo` and `#Demo ` are
52
+ `demo`. Empty tags and tags over 64 characters are not tags and are ignored (or refused, when a reviewer enters them).
53
+ Everything else is a valid tag (`team/payments`, `release=2`), and repeats are shown once.
54
+ - **Inherited.** An attachment is also searchable by the tags of its check and its run. The viewer says `inherited` on
55
+ such a chip, and a switch decides whether the filter counts them: a recording without a tag of its own under a run
56
+ tagged `demo` is found by the inherited query and not by the exact one. In the API,
57
+ `GET /api/reports/tests?artifactTag=demo` is exact and adding `&inherited=1` includes inherited tags.
58
+ - **Reviewers.** After upload, a person with a publish token can add tags to an attachment (the viewer's *Add tag*
59
+ button) and remove the ones a reviewer added. Those tags are not written into the layer, which is immutable: the
60
+ server keeps them as an audit trail (who, when, which tags) and the viewer shows them as `added by <name>`, apart
61
+ from the producer's tags. See [Tagging artifacts](server.md#tagging-artifacts). A producer's own tags stay as uploaded.
62
+ - **Older layers.** A layer without attachment tags reads exactly as before. Tags on an attachment that cannot be read
63
+ (not a list of strings) are dropped, and the attachment, check and layer stay.
64
+
37
65
  ## Labels describe, parameters split
38
66
 
39
67
  A result's **slot** is its producer, check id, part kind, spec, environment and **parameters**. The newest completed
package/docs/server.md CHANGED
@@ -216,6 +216,26 @@ against a server unchanged. A client uploads **in dependency order**: blobs, the
216
216
  entry that makes it visible. The server checks each step and says no to a catalog entry whose manifest is not
217
217
  there (`409`), so nothing half-uploaded is ever shown.
218
218
 
219
+ ## Tagging artifacts
220
+
221
+ A reviewer can mark a recording `#demo` after it was uploaded without uploading it again. The layer is immutable, so the
222
+ tag is kept beside it: each edit is one small object under `curation/artifact-tags/` in the write backend (or the live
223
+ store of a relay), named by time, never changed or removed. It records the attachment (layer, check, and the attachment's
224
+ `id`, else its bytes' key, else its name), the normalized tags added and removed, the name of the token that made the
225
+ edit, and the server's time. Nothing about the layer, its results or the report's winners changes.
226
+
227
+ - `GET /api/reports/artifact-tags` (read) returns every edit, oldest first. Fold them to get the tags that stand now: the
228
+ last edit of a tag wins.
229
+ - `POST /api/reports/artifact-tags` (publish, `Authorization: Bearer`) records an edit. A read token, an anonymous reader
230
+ and the browser's sign-in box are refused (`403`/`401`) like any upload, so a read-only visitor can see the tags and
231
+ not change them. The viewer asks for a token when a reviewer adds a tag and keeps it only in that tab.
232
+ - Producer tags (`tags` on the attachment) and reviewer tags are kept apart everywhere: the viewer says who added a
233
+ reviewer tag and when, and a reviewer can remove only reviewer tags. `capabilities.artifactTags` on `/api/server` says
234
+ a server has this.
235
+
236
+ See [Tagging artifacts and demos](rich-evidence.md#tagging-artifacts-and-demos) for the tag rules. Reviewer tags are
237
+ shown in the viewer's evidence; the tests API (`artifactTag`) searches the producers' tags.
238
+
219
239
  ## Streaming
220
240
 
221
241
  `GET /api/events` is a server-sent-events stream:
@@ -330,6 +350,10 @@ curl "$ATLAS_SERVER_URL/api/projects/$ATLAS_PROJECT/reports/tests?format=markdow
330
350
  | `GET /api/projects/{project}/reports/tests/health` | read | Why specs, domains and counts disagree. |
331
351
  | `GET /api/reports/tests/history` | read | One test and every result recorded for it. |
332
352
  | `GET /api/projects/{project}/reports/tests/history` | read | One test and every result recorded for it. |
353
+ | `GET /api/reports/artifact-tags` | read | Reviewer tags of attachments: every recorded edit. |
354
+ | `GET /api/projects/{project}/reports/artifact-tags` | read | Reviewer tags of attachments: every recorded edit. |
355
+ | `POST /api/reports/artifact-tags` | publish | Tag or untag one attachment after upload. |
356
+ | `POST /api/projects/{project}/reports/artifact-tags` | publish | Tag or untag one attachment after upload. |
333
357
  | `GET /api/reports/runs` | read | The runs that recorded the report. |
334
358
  | `GET /api/projects/{project}/reports/runs` | read | The runs that recorded the report. |
335
359
  | `GET /api/reports/runs/detail` | read | Every test one run recorded. |
package/llms-full.txt CHANGED
@@ -1013,6 +1013,7 @@ decided exactly as before ([the report contract](report-contract.md)). Evidence
1013
1013
  | `steps` (`parentId`, `offsetMs`, `durationMs`, `status`, `kind`) | check, attempt | Draws the check's timeline: nested steps, when each started, how long it took, which one failed. |
1014
1014
  | `attempts` | check | Every try of a retried check, with its own status, detail, steps and attachments. A check that passed after failing is flaky. |
1015
1015
  | `tags`, `labels` | check, layer | Filters and groups (`smoke`, `flaky`, an owner, a browser). |
1016
+ | `tags` | attachment | Marks one recording or screenshot, for example `demo`: shown as a `#demo` chip, and searchable. See [Tagging artifacts and demos](#tagging-artifacts-and-demos). |
1016
1017
  | `annotations`, `links` | check, step, attempt, layer | Notes, warnings and issue links next to the result. |
1017
1018
  | `specPath` | check | The spec a check belongs to (a `.feature`, a test file), relative to the repository. Checks are grouped by it. |
1018
1019
  | `specs[]` (`path`, `domain`, `kind`, `title`) | layer | Names each spec and its group (`domain`), so a server can group checks by spec with a title even when no [spec set](integrating.md#tell-atlas-which-spec-a-check-belongs-to) has been published. |
@@ -1021,6 +1022,33 @@ decided exactly as before ([the report contract](report-contract.md)). Evidence
1021
1022
  Send `sha`, `branch` and `runUrl` on every layer too. They never change a result, but they tie results to a commit
1022
1023
  and to the CI run that produced them.
1023
1024
 
1025
+ ## Tagging artifacts and demos
1026
+
1027
+ To mark one recording as a demo, tag the attachment itself. A tag on a check says something about the result; a tag on
1028
+ an attachment says something about that file, and no Gherkin tag, label, environment or producer name is needed:
1029
+
1030
+ ```ts
1031
+ { name: 'checkout.webm', role: 'video', tags: ['demo'], labels: { audience: 'business' } }
1032
+ ```
1033
+
1034
+ The viewer shows it as a `#demo` chip on the artifact card. Selecting the chip filters the result's evidence to the
1035
+ attachments with that tag, and says how many match. Tags describe evidence; they never change a status or a slot.
1036
+
1037
+ - **One spelling.** A tag is normalized before it is compared or stored: a leading `#` is dropped, letters are
1038
+ lower-cased, each run of whitespace becomes `-`, and surrounding space is trimmed, so `demo`, `#demo` and `#Demo ` are
1039
+ `demo`. Empty tags and tags over 64 characters are not tags and are ignored (or refused, when a reviewer enters them).
1040
+ Everything else is a valid tag (`team/payments`, `release=2`), and repeats are shown once.
1041
+ - **Inherited.** An attachment is also searchable by the tags of its check and its run. The viewer says `inherited` on
1042
+ such a chip, and a switch decides whether the filter counts them: a recording without a tag of its own under a run
1043
+ tagged `demo` is found by the inherited query and not by the exact one. In the API,
1044
+ `GET /api/reports/tests?artifactTag=demo` is exact and adding `&inherited=1` includes inherited tags.
1045
+ - **Reviewers.** After upload, a person with a publish token can add tags to an attachment (the viewer's *Add tag*
1046
+ button) and remove the ones a reviewer added. Those tags are not written into the layer, which is immutable: the
1047
+ server keeps them as an audit trail (who, when, which tags) and the viewer shows them as `added by <name>`, apart
1048
+ from the producer's tags. See [Tagging artifacts](server.md#tagging-artifacts). A producer's own tags stay as uploaded.
1049
+ - **Older layers.** A layer without attachment tags reads exactly as before. Tags on an attachment that cannot be read
1050
+ (not a list of strings) are dropped, and the attachment, check and layer stay.
1051
+
1024
1052
  ## Labels describe, parameters split
1025
1053
 
1026
1054
  A result's **slot** is its producer, check id, part kind, spec, environment and **parameters**. The newest completed
@@ -1404,6 +1432,26 @@ against a server unchanged. A client uploads **in dependency order**: blobs, the
1404
1432
  entry that makes it visible. The server checks each step and says no to a catalog entry whose manifest is not
1405
1433
  there (`409`), so nothing half-uploaded is ever shown.
1406
1434
 
1435
+ ## Tagging artifacts
1436
+
1437
+ A reviewer can mark a recording `#demo` after it was uploaded without uploading it again. The layer is immutable, so the
1438
+ tag is kept beside it: each edit is one small object under `curation/artifact-tags/` in the write backend (or the live
1439
+ store of a relay), named by time, never changed or removed. It records the attachment (layer, check, and the attachment's
1440
+ `id`, else its bytes' key, else its name), the normalized tags added and removed, the name of the token that made the
1441
+ edit, and the server's time. Nothing about the layer, its results or the report's winners changes.
1442
+
1443
+ - `GET /api/reports/artifact-tags` (read) returns every edit, oldest first. Fold them to get the tags that stand now: the
1444
+ last edit of a tag wins.
1445
+ - `POST /api/reports/artifact-tags` (publish, `Authorization: Bearer`) records an edit. A read token, an anonymous reader
1446
+ and the browser's sign-in box are refused (`403`/`401`) like any upload, so a read-only visitor can see the tags and
1447
+ not change them. The viewer asks for a token when a reviewer adds a tag and keeps it only in that tab.
1448
+ - Producer tags (`tags` on the attachment) and reviewer tags are kept apart everywhere: the viewer says who added a
1449
+ reviewer tag and when, and a reviewer can remove only reviewer tags. `capabilities.artifactTags` on `/api/server` says
1450
+ a server has this.
1451
+
1452
+ See [Tagging artifacts and demos](rich-evidence.md#tagging-artifacts-and-demos) for the tag rules. Reviewer tags are
1453
+ shown in the viewer's evidence; the tests API (`artifactTag`) searches the producers' tags.
1454
+
1407
1455
  ## Streaming
1408
1456
 
1409
1457
  `GET /api/events` is a server-sent-events stream:
@@ -1518,6 +1566,10 @@ curl "$ATLAS_SERVER_URL/api/projects/$ATLAS_PROJECT/reports/tests?format=markdow
1518
1566
  | `GET /api/projects/{project}/reports/tests/health` | read | Why specs, domains and counts disagree. |
1519
1567
  | `GET /api/reports/tests/history` | read | One test and every result recorded for it. |
1520
1568
  | `GET /api/projects/{project}/reports/tests/history` | read | One test and every result recorded for it. |
1569
+ | `GET /api/reports/artifact-tags` | read | Reviewer tags of attachments: every recorded edit. |
1570
+ | `GET /api/projects/{project}/reports/artifact-tags` | read | Reviewer tags of attachments: every recorded edit. |
1571
+ | `POST /api/reports/artifact-tags` | publish | Tag or untag one attachment after upload. |
1572
+ | `POST /api/projects/{project}/reports/artifact-tags` | publish | Tag or untag one attachment after upload. |
1521
1573
  | `GET /api/reports/runs` | read | The runs that recorded the report. |
1522
1574
  | `GET /api/projects/{project}/reports/runs` | read | The runs that recorded the report. |
1523
1575
  | `GET /api/reports/runs/detail` | read | Every test one run recorded. |
@@ -2542,6 +2594,9 @@ carry any of these. Every one is optional.
2542
2594
  | `extensions{}` | Structured data of your own or a tool's, under a name you own. |
2543
2595
  | `measurements[]` | Numbers, with units and an allowance (already part of the contract). |
2544
2596
 
2597
+ An attachment may carry its own `tags[]` (`demo`): shown as `#demo` and searchable; see
2598
+ [Tagging artifacts and demos](rich-evidence.md#tagging-artifacts-and-demos).
2599
+
2545
2600
  On a **layer** the same fields describe the whole run: its CI log, a coverage report, an archive of traces, the
2546
2601
  build number, a link to the pipeline.
2547
2602
 
@@ -4794,6 +4849,8 @@ Send `format=markdown` (or `Accept: text/markdown`) for the same as a Markdown r
4794
4849
  - `domain` (query): Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.
4795
4850
  - `spec` (query): Only tests of this spec path. Repeatable or comma-separated.
4796
4851
  - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
4852
+ - `artifactTag` (query): Only tests with an attachment tagged this (`demo` and `#demo` are the same tag), not counting tags inherited from a check or run: the exact artifact query. `inherited=1` widens it to the tags inherited from the check and run. Repeatable or comma-separated.
4853
+ - `inherited` (query): With `artifactTag`: `1` also matches the tags an attachment inherits from its check and run, so a recording without a tag of its own under a run tagged `demo` is found. Without it the query is exact.
4797
4854
  - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
4798
4855
  - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
4799
4856
  - `offset` (query): Skip this many matching tests.
@@ -4823,6 +4880,8 @@ Scoped to one project. The same project may be selected on the unprefixed API us
4823
4880
  - `domain` (query): Only tests whose spec is in this domain (`(no spec)` for none). Repeatable or comma-separated.
4824
4881
  - `spec` (query): Only tests of this spec path. Repeatable or comma-separated.
4825
4882
  - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
4883
+ - `artifactTag` (query): Only tests with an attachment tagged this (`demo` and `#demo` are the same tag), not counting tags inherited from a check or run: the exact artifact query. `inherited=1` widens it to the tags inherited from the check and run. Repeatable or comma-separated.
4884
+ - `inherited` (query): With `artifactTag`: `1` also matches the tags an attachment inherits from its check and run, so a recording without a tag of its own under a run tagged `demo` is found. Without it the query is exact.
4826
4885
  - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
4827
4886
  - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
4828
4887
  - `offset` (query): Skip this many matching tests.
@@ -4891,6 +4950,50 @@ Scoped to one project. The same project may be selected on the unprefixed API us
4891
4950
 
4892
4951
  Responses: `200` The test and its history.; `304` Unchanged since the `If-None-Match` ETag.; `400` No `key`.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `404` No test in the report has that key.; `503` The server is still reading its backends. Retry after the number of seconds in `Retry-After`.
4893
4952
 
4953
+ ### Reviewer tags of attachments: every recorded edit
4954
+
4955
+ `GET /api/reports/artifact-tags` · role: read
4956
+
4957
+ The audit trail of tags that reviewers added to and removed from attachments after upload, oldest first. Each entry says which attachment (`target`), which normalized tags were added and removed, the name of the token that made the edit (`by`, never the token) and when (`atMs`). The layers themselves are never rewritten, so a producer's own `tags` on an attachment stay as uploaded; fold the entries (oldest first, the last edit of a tag wins) to get the reviewer tags that stand now. Tags are normalized: `#Demo` and `demo` are `demo`.
4958
+
4959
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
4960
+
4961
+ Responses: `200` The edits.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
4962
+
4963
+ ### Reviewer tags of attachments: every recorded edit
4964
+
4965
+ `GET /api/projects/{project}/reports/artifact-tags` · role: read
4966
+
4967
+ The audit trail of tags that reviewers added to and removed from attachments after upload, oldest first. Each entry says which attachment (`target`), which normalized tags were added and removed, the name of the token that made the edit (`by`, never the token) and when (`atMs`). The layers themselves are never rewritten, so a producer's own `tags` on an attachment stay as uploaded; fold the entries (oldest first, the last edit of a tag wins) to get the reviewer tags that stand now. Tags are normalized: `#Demo` and `demo` are `demo`.
4968
+
4969
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
4970
+
4971
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
4972
+
4973
+ Responses: `200` The edits.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.
4974
+
4975
+ ### Tag or untag one attachment after upload
4976
+
4977
+ `POST /api/reports/artifact-tags` · role: publish
4978
+
4979
+ Records one edit of the reviewer tags of one attachment, by the name of the publish (or admin) token that sends it. It adds an immutable entry to the audit trail and changes nothing else: not the layer, not the movie, not any result. The tags it removes are reviewer tags only; a producer's tags are part of the layer and stay. A reader-only token, a browser sign-in box and an anonymous reader are refused. Name the attachment as `artifactRef` does (`id:<id>`, else `key:<bundleKey>`, else `name:<name>`).
4980
+
4981
+ - `project` (query): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
4982
+
4983
+ Responses: `201` The recorded entry.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `413` Request too large.; `422` Not a tag edit: a bad target, an empty or over-long tag, or nothing to change.
4984
+
4985
+ ### Tag or untag one attachment after upload
4986
+
4987
+ `POST /api/projects/{project}/reports/artifact-tags` · role: publish
4988
+
4989
+ Records one edit of the reviewer tags of one attachment, by the name of the publish (or admin) token that sends it. It adds an immutable entry to the audit trail and changes nothing else: not the layer, not the movie, not any result. The tags it removes are reviewer tags only; a producer's tags are part of the layer and stay. A reader-only token, a browser sign-in box and an anonymous reader are refused. Name the attachment as `artifactRef` does (`id:<id>`, else `key:<bundleKey>`, else `name:<name>`).
4990
+
4991
+ Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
4992
+
4993
+ - `project` (path, required): Project slug. Alternatively send X-Atlas-Project or use the project-prefixed route. Omit for the default project. Conflicting selectors are refused.
4994
+
4995
+ Responses: `201` The recorded entry.; `401` No token, or an unknown one. Send `Authorization: Bearer <token>`.; `403` The token is known but its role may not do this.; `413` Request too large.; `422` Not a tag edit: a bad target, an empty or over-long tag, or nothing to change.
4996
+
4894
4997
  ### The runs that recorded the report
4895
4998
 
4896
4999
  `GET /api/reports/runs` · role: read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Atlas: specs of any kind, reported as layered evidence. Publish checks from any runner (JUnit, CTRF, a script), combine them across nodes, branches, environments and backends, gate CI on the result, and run statecharts against the real app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -169,6 +169,13 @@
169
169
  "description": {
170
170
  "type": "string"
171
171
  },
172
+ "tags": {
173
+ "description": "Free labels of this one attachment, such as `demo`: shown as `#demo`, searchable, and normalized by `normalizeTag` (a leading `#` and letter case never matter). Unlike the tags of a check they say nothing about its result",
174
+ "type": "array",
175
+ "items": {
176
+ "type": "string"
177
+ }
178
+ },
172
179
  "at": {
173
180
  "type": "object",
174
181
  "properties": {
@@ -397,6 +404,13 @@
397
404
  "description": {
398
405
  "type": "string"
399
406
  },
407
+ "tags": {
408
+ "description": "Free labels of this one attachment, such as `demo`: shown as `#demo`, searchable, and normalized by `normalizeTag` (a leading `#` and letter case never matter). Unlike the tags of a check they say nothing about its result",
409
+ "type": "array",
410
+ "items": {
411
+ "type": "string"
412
+ }
413
+ },
400
414
  "at": {
401
415
  "type": "object",
402
416
  "properties": {
@@ -662,6 +676,13 @@
662
676
  "description": {
663
677
  "type": "string"
664
678
  },
679
+ "tags": {
680
+ "description": "Free labels of this one attachment, such as `demo`: shown as `#demo`, searchable, and normalized by `normalizeTag` (a leading `#` and letter case never matter). Unlike the tags of a check they say nothing about its result",
681
+ "type": "array",
682
+ "items": {
683
+ "type": "string"
684
+ }
685
+ },
665
686
  "at": {
666
687
  "type": "object",
667
688
  "properties": {
@@ -862,6 +883,13 @@
862
883
  "description": {
863
884
  "type": "string"
864
885
  },
886
+ "tags": {
887
+ "description": "Free labels of this one attachment, such as `demo`: shown as `#demo`, searchable, and normalized by `normalizeTag` (a leading `#` and letter case never matter). Unlike the tags of a check they say nothing about its result",
888
+ "type": "array",
889
+ "items": {
890
+ "type": "string"
891
+ }
892
+ },
865
893
  "at": {
866
894
  "type": "object",
867
895
  "properties": {
@@ -132,6 +132,13 @@
132
132
  "type": "string"
133
133
  }
134
134
  },
135
+ "artifactTags": {
136
+ "description": "Normalized tags of the attachments themselves, sorted and distinct. Absent when no attachment is tagged; never inherited from a check or run",
137
+ "type": "array",
138
+ "items": {
139
+ "type": "string"
140
+ }
141
+ },
135
142
  "labels": {
136
143
  "type": "object",
137
144
  "propertyNames": {
@@ -358,6 +365,13 @@
358
365
  "type": "string"
359
366
  }
360
367
  },
368
+ "artifactTags": {
369
+ "description": "Normalized tags of the attachments themselves, sorted and distinct. Absent when no attachment is tagged; never inherited from a check or run",
370
+ "type": "array",
371
+ "items": {
372
+ "type": "string"
373
+ }
374
+ },
361
375
  "labels": {
362
376
  "type": "object",
363
377
  "propertyNames": {