@crvouga/atlas 1.2.0 → 1.4.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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/evidence-detail.d.ts +37 -3
  3. package/dist/reports/src/evidence-detail.js +52 -20
  4. package/dist/reports/src/evidence-scope.d.ts +83 -0
  5. package/dist/reports/src/evidence-scope.js +129 -0
  6. package/dist/reports/src/evidence-types.d.ts +3 -1
  7. package/dist/reports/src/explorer-types.d.ts +1 -1
  8. package/dist/reports/src/export-evidence.js +1 -1
  9. package/dist/reports/src/layer-merge.js +3 -2
  10. package/dist/reports/src/layer-types.d.ts +1 -1
  11. package/dist/reports/src/layers-route.d.ts +2 -1
  12. package/dist/reports/src/layers-route.js +8 -4
  13. package/dist/reports/src/publish-checks.js +1 -0
  14. package/dist/reports/src/reporter.d.ts +1 -1
  15. package/dist/reports/src/reports-explorer-route.d.ts +276 -0
  16. package/dist/reports/src/reports-route.d.ts +276 -0
  17. package/dist/reports/src/test-catalog.d.ts +34 -1
  18. package/dist/reports/src/test-catalog.js +23 -5
  19. package/dist/schema/src/api.d.ts +542 -0
  20. package/dist/schema/src/api.js +17 -5
  21. package/dist/schema/src/layer.d.ts +981 -0
  22. package/dist/schema/src/layer.js +43 -4
  23. package/dist/server/src/openapi.js +3 -2
  24. package/docs/extending.md +1 -0
  25. package/docs/reference/http-api.md +6 -4
  26. package/docs/reference/reports.md +243 -14
  27. package/docs/reference/schema.md +23 -0
  28. package/docs/rich-evidence.md +70 -0
  29. package/docs/ui.md +1 -1
  30. package/llms-full.txt +78 -5
  31. package/package.json +1 -1
  32. package/packages/schema/schemas/check-detail.schema.json +697 -1
  33. package/packages/schema/schemas/effective-delta.schema.json +192 -0
  34. package/packages/schema/schemas/effective-evidence.schema.json +192 -0
  35. package/packages/schema/schemas/evidence-page.schema.json +96 -0
  36. package/packages/schema/schemas/layer-rows.schema.json +96 -0
  37. package/packages/schema/schemas/layer.schema.json +1598 -62
  38. package/packages/schema/schemas/publish-checks.schema.json +768 -0
  39. package/packages/schema/schemas/report-run.schema.json +181 -0
  40. package/packages/schema/schemas/report-runs.schema.json +6 -0
  41. package/packages/schema/schemas/test-catalog.schema.json +128 -0
  42. package/packages/schema/schemas/test-history.schema.json +202 -0
  43. package/packages/server/openapi.json +2721 -48
  44. package/packages/visualizer/src/evidence/artifacts.tsx +77 -29
  45. package/packages/visualizer/src/evidence/evidence-scope.tsx +106 -0
  46. package/packages/visualizer/src/evidence/format.ts +11 -0
  47. package/packages/visualizer/src/explorer/Explorer.tsx +28 -5
  48. package/packages/visualizer/src/explorer/RunArtifacts.tsx +246 -0
  49. package/packages/visualizer/src/explorer/RunsView.tsx +40 -9
  50. package/packages/visualizer/src/explorer/search.ts +10 -3
@@ -88,6 +88,31 @@ export const MomentSchema = z.looseObject({
88
88
  step: z.string().optional().describe('The `id` of a step'),
89
89
  offsetMs: z.number().nonnegative().optional()
90
90
  });
91
+ /**
92
+ * What a piece of evidence does and does not show, declared by the producer that made it: where it ran, whether its
93
+ * data and dependencies were real, whether audio was captured, how it was paced and which assertions the result
94
+ * stands on. Every field is optional and a field that is absent is unknown, never assumed live, physical or end to end.
95
+ *
96
+ * It is a declaration, not a finding: Atlas shows it as the producer's word, apart from the dimensions the producer
97
+ * names in `verified` (facts something independent checked). It describes evidence and never decides a status, a slot
98
+ * or a winner. The nearest declaration wins as a whole, never field by field: an attachment's `scope`, else its
99
+ * attempt's, else its check's, else its part's, else its layer's.
100
+ */
101
+ export const EvidenceScopeSchema = z.looseObject({
102
+ surface: z.string().min(1).optional().describe('Where the product ran, in an open vocabulary: `browser`, `native-app`, `api`, `cli`…'),
103
+ deviceKind: z.string().min(1).optional().describe('What it ran on, in an open vocabulary: `simulator`, `emulator`, `physical`, `virtual-machine`, `desktop`… A simulator and a physical phone stay distinct values; they are filtered separately'),
104
+ syntheticData: z.boolean().optional().describe('True: the data was made up for the run. False: real data. Absent: unknown'),
105
+ dependencies: z.string().min(1).optional().describe('Whether what the product talks to was `scripted` (replies fixed in advance), `live` or `mixed`. An open vocabulary; absent is unknown'),
106
+ audioCaptured: z.boolean().optional().describe('True: the recording has real audio. False: it is silent. Absent: unknown'),
107
+ pacing: z.string().min(1).optional().describe('How time ran, in an open vocabulary: `paced` (slowed or scripted for a viewer), `real-time`. Absent: unknown'),
108
+ asserted: z.array(z.string()).optional().describe('The boundaries the result actually asserts. Activity visible in a recording beyond them is not verified behavior'),
109
+ limitations: z.array(z.string()).optional().describe('Known limits of what this evidence shows, in the producer\'s words'),
110
+ declaredBy: z.string().optional().describe('Who or what made this declaration: a tool, a team, a person'),
111
+ verified: z.array(z.string()).optional().describe('The names of the fields above (`surface`, `deviceKind`, `syntheticData`, `dependencies`, `audioCaptured`, `pacing`) that something independent of the producer checked. Every other field is only declared'),
112
+ verifiedBy: z.string().optional().describe('What checked the `verified` fields'),
113
+ provenance: z.array(LinkSchema).optional().describe('Where the declaration comes from and how to check it: a document, a CI step, a device log'),
114
+ extensions: ExtensionsSchema.optional()
115
+ });
91
116
  /**
92
117
  * A file or a value that goes with a result: a screenshot, a video, a trace, a log, a DOM or accessibility
93
118
  * snapshot, a HAR, a coverage report, a diff, anything. `contentType` says how to read it; `role` says what
@@ -110,6 +135,7 @@ export const AttachmentSchema = z.looseObject({
110
135
  durationMs: z.number().nonnegative().optional().describe('For video and audio'),
111
136
  description: z.string().optional(),
112
137
  omitted: z.string().optional().describe('Why the producer did not include its bytes, in an open vocabulary: too-large, not-found, not-allowed, over-budget, policy… Present on a metadata-only attachment, so a reader can tell "left out" from "never recorded"'),
138
+ scope: EvidenceScopeSchema.optional().describe('What this one attachment (usually a recording) does and does not show. Overrides the scope of its attempt, check, part and layer as a whole'),
113
139
  tags: z.array(z.string()).optional().describe('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'),
114
140
  at: MomentSchema.optional(),
115
141
  poster: z.string().optional().describe('The `id` of an attachment that stands for this one before it is opened'),
@@ -190,6 +216,7 @@ export const AttemptSchema = z.looseObject({
190
216
  durationMs: z.number().nonnegative().optional(),
191
217
  startedAtMs: z.number().nonnegative().optional(),
192
218
  detail: z.string().optional(),
219
+ scope: EvidenceScopeSchema.optional().describe('What this try\'s evidence shows, when it differs from the other tries (a retry on another device). Overrides the check\'s scope for this try\'s attachments'),
193
220
  steps: z.array(StepSchema).optional(),
194
221
  attachments: z.array(AttachmentSchema).optional(),
195
222
  annotations: z.array(AnnotationSchema).optional(),
@@ -202,6 +229,7 @@ const evidenceShape = {
202
229
  links: z.array(LinkSchema).optional(),
203
230
  attachments: z.array(AttachmentSchema).optional(),
204
231
  annotations: z.array(AnnotationSchema).optional(),
232
+ scope: EvidenceScopeSchema.optional().describe('What the evidence below this level does and does not show: where it ran, how real its data and dependencies were, whether audio was captured, and what is asserted. Absent: unknown, never assumed live, physical or end to end'),
205
233
  extensions: ExtensionsSchema.optional()
206
234
  };
207
235
  /**
@@ -329,17 +357,27 @@ function lenientList(value, schema, prepare = (item) => item) {
329
357
  });
330
358
  }
331
359
  const object = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null);
332
- /** An attachment whose `tags` cannot be read keeps its file: only the tags that are strings are kept. */
360
+ /** A scope that cannot be read is no scope: the evidence is kept and its scope is unknown. */
361
+ function lenientScope(holder) {
362
+ if (!('scope' in holder))
363
+ return holder;
364
+ const { scope, ...rest } = holder;
365
+ const parsed = EvidenceScopeSchema.safeParse(scope);
366
+ return parsed.success ? { ...rest, scope: parsed.data } : rest;
367
+ }
368
+ /** An attachment whose `tags` or `scope` cannot be read keeps its file: only the tags that are strings are kept, and an unreadable scope is dropped. */
333
369
  function lenientAttachment(item) {
334
370
  const attachment = object(item);
335
- if (!attachment || !('tags' in attachment))
371
+ if (!attachment)
336
372
  return item;
337
- const { tags, ...rest } = attachment;
373
+ const { tags, ...rest } = lenientScope(attachment);
374
+ if (!('tags' in attachment))
375
+ return rest;
338
376
  const kept = lenientList(tags, z.string());
339
377
  return kept ? { ...rest, tags: kept } : rest;
340
378
  }
341
379
  function lenientEvidence(holder, nested = []) {
342
- const out = { ...holder };
380
+ const out = lenientScope(holder);
343
381
  const set = (key, value) => { if (value === undefined)
344
382
  delete out[key];
345
383
  else
@@ -468,6 +506,7 @@ export const PublishChecksSchema = z.looseObject({
468
506
  links: evidenceShape.links,
469
507
  attachments: z.array(AttachmentInputSchema).optional().describe('Attached to the whole layer: a CI log, a coverage report, a trace archive'),
470
508
  annotations: evidenceShape.annotations,
509
+ scope: evidenceShape.scope,
471
510
  extensions: evidenceShape.extensions
472
511
  });
473
512
  const CountsSchema = z.object({
@@ -293,6 +293,7 @@ const BASE_OPERATIONS = [
293
293
  { name: 'tag', in: 'query', description: 'Only tests with this tag. Repeatable or comma-separated.', schema: str },
294
294
  { name: 'artifactTag', in: 'query', description: '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.', schema: str, example: 'demo' },
295
295
  { name: 'inherited', in: 'query', description: '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.', schema: { type: 'string', enum: ['1', 'true'] } },
296
+ { name: 'scope', in: 'query', description: 'Only tests with one playable recording that states every one of these producer-declared qualifications: `surface:<value>`, `device:<value>` (`device:physical` and `device:simulator` stay apart), `data:synthetic` or `data:real`, `dependencies:<value>`, `audio:recorded` or `audio:silent`, `pacing:<value>`. All must hold for the same recording. A recording that did not declare a value never matches it, and `unknown` matches only a recording that declared no scope. Repeatable or comma-separated.', schema: str, example: 'device:physical,audio:recorded' },
296
297
  { name: 'run', in: 'query', description: 'Only tests recorded in this run (a `key` from `/api/reports/runs`).', schema: str },
297
298
  { name: 'sort', in: 'query', description: 'The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.', schema: { type: 'string', enum: ['spec', 'title', 'recent', 'slowest', 'failures', 'flaky', 'runs'] } },
298
299
  { name: 'offset', in: 'query', description: 'Skip this many matching tests.', schema: { type: 'integer', minimum: 0 } },
@@ -392,7 +393,7 @@ const BASE_OPERATIONS = [
392
393
  {
393
394
  route: 'GET /api/reports/runs/detail', path: '/api/reports/runs/detail', operationId: 'getReportRun', tag: 'Report', role: 'read',
394
395
  summary: 'Every test one run recorded',
395
- description: 'One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.',
396
+ description: 'One run and its last result for every test, environment and producer it recorded, failures first, and `layers`: each layer of the run with its backend, how many checks it recorded (zero means evidence only, never a pass) and how many files it carries as a whole. Open a layer\'s files with `/api/reports/evidence/detail/{backend}/{id}`. Send `format=markdown` for Markdown.',
396
397
  parameters: [
397
398
  { name: 'key', in: 'query', required: true, description: 'The run\'s `key` from `/api/reports/runs`, URL-encoded.', schema: str },
398
399
  { name: 'format', in: 'query', description: '`markdown` for Markdown instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
@@ -429,7 +430,7 @@ const BASE_OPERATIONS = [
429
430
  {
430
431
  route: 'GET /api/reports/evidence/detail/:backend/:id', path: '/api/reports/evidence/detail/{backend}/{id}', operationId: 'getCheckDetail', tag: 'Report', role: 'read',
431
432
  summary: 'Everything one result carries',
432
- description: 'The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run\'s. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.',
433
+ description: 'The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run\'s, and `parts`: what each part of the layer that carries files or links of its own holds. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.',
433
434
  parameters: [BACKEND, LAYER_ID, { name: 'check', in: 'query', description: 'The check id.', schema: str }, { name: 'kind', in: 'query', description: 'The kind of the part, when the same id is in several.', schema: str }],
434
435
  responses: { '200': json('The detail.', 'CheckDetail'), '401': UNAUTHORIZED, '404': problem('No such layer or check.') }
435
436
  },
package/docs/extending.md CHANGED
@@ -31,6 +31,7 @@ carry any of these. Every one is optional.
31
31
  | `attempts[]` | Every try, when the check was retried. |
32
32
  | `tags[]` | Free labels to filter by: `smoke`, `payments`. |
33
33
  | `labels{}` | Facts about the result that are not its identity: `owner`, `severity`, `browser`, `build`. |
34
+ | `scope{}` | What the evidence does and does not show: surface, device kind, synthetic data, scripted dependencies, audio, pacing, what was asserted and its limits. Unknown when absent. See [Evidence scope](rich-evidence.md#evidence-scope). |
34
35
  | `links[]` | Where to read more: the CI run, the issue, the trace client. |
35
36
  | `extensions{}` | Structured data of your own or a tool's, under a name you own. |
36
37
  | `measurements[]` | Numbers, with units and an allowance (already part of the contract). |
@@ -497,6 +497,7 @@ Send `format=markdown` (or `Accept: text/markdown`) for the same as a Markdown r
497
497
  - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
498
498
  - `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.
499
499
  - `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.
500
+ - `scope` (query): Only tests with one playable recording that states every one of these producer-declared qualifications: `surface:<value>`, `device:<value>` (`device:physical` and `device:simulator` stay apart), `data:synthetic` or `data:real`, `dependencies:<value>`, `audio:recorded` or `audio:silent`, `pacing:<value>`. All must hold for the same recording. A recording that did not declare a value never matches it, and `unknown` matches only a recording that declared no scope. Repeatable or comma-separated.
500
501
  - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
501
502
  - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
502
503
  - `offset` (query): Skip this many matching tests.
@@ -528,6 +529,7 @@ Scoped to one project. The same project may be selected on the unprefixed API us
528
529
  - `tag` (query): Only tests with this tag. Repeatable or comma-separated.
529
530
  - `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.
530
531
  - `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.
532
+ - `scope` (query): Only tests with one playable recording that states every one of these producer-declared qualifications: `surface:<value>`, `device:<value>` (`device:physical` and `device:simulator` stay apart), `data:synthetic` or `data:real`, `dependencies:<value>`, `audio:recorded` or `audio:silent`, `pacing:<value>`. All must hold for the same recording. A recording that did not declare a value never matches it, and `unknown` matches only a recording that declared no scope. Repeatable or comma-separated.
531
533
  - `run` (query): Only tests recorded in this run (a `key` from `/api/reports/runs`).
532
534
  - `sort` (query): The order: `spec` (default: domain, spec, title), `title`, `recent`, `slowest`, `failures`, `flaky` or `runs`.
533
535
  - `offset` (query): Skip this many matching tests.
@@ -754,7 +756,7 @@ Responses: `200` The matching results and the facets.; `304` Unchanged since the
754
756
 
755
757
  `GET /api/reports/runs/detail` · role: read
756
758
 
757
- One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
759
+ One run and its last result for every test, environment and producer it recorded, failures first, and `layers`: each layer of the run with its backend, how many checks it recorded (zero means evidence only, never a pass) and how many files it carries as a whole. Open a layer's files with `/api/reports/evidence/detail/{backend}/{id}`. Send `format=markdown` for Markdown.
758
760
 
759
761
  - `key` (query, required): The run's `key` from `/api/reports/runs`, URL-encoded.
760
762
  - `format` (query): `markdown` for Markdown instead of JSON.
@@ -766,7 +768,7 @@ Responses: `200` The run and its results.; `304` Unchanged since the `If-None-Ma
766
768
 
767
769
  `GET /api/projects/{project}/reports/runs/detail` · role: read
768
770
 
769
- One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
771
+ One run and its last result for every test, environment and producer it recorded, failures first, and `layers`: each layer of the run with its backend, how many checks it recorded (zero means evidence only, never a pass) and how many files it carries as a whole. Open a layer's files with `/api/reports/evidence/detail/{backend}/{id}`. Send `format=markdown` for Markdown.
770
772
 
771
773
  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.
772
774
 
@@ -876,7 +878,7 @@ Responses: `200` The rows.; `401` No token, or an unknown one. Send `Authorizati
876
878
 
877
879
  `GET /api/reports/evidence/detail/{backend}/{id}` · role: read
878
880
 
879
- The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
881
+ The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's, and `parts`: what each part of the layer that carries files or links of its own holds. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
880
882
 
881
883
  - `backend` (path, required): A backend id from `/api/server`.
882
884
  - `id` (path, required): A layer id: 64 lowercase hex digits.
@@ -890,7 +892,7 @@ Responses: `200` The detail.; `401` No token, or an unknown one. Send `Authoriza
890
892
 
891
893
  `GET /api/projects/{project}/reports/evidence/detail/{backend}/{id}` · role: read
892
894
 
893
- The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
895
+ The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's, and `parts`: what each part of the layer that carries files or links of its own holds. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
894
896
 
895
897
  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.
896
898
 
@@ -1661,6 +1661,8 @@ export type EvidenceHolder = {
1661
1661
  readonly steps?: readonly Step[];
1662
1662
  readonly attempts?: readonly Attempt[];
1663
1663
  readonly extensions?: Readonly<Record<string, unknown>>;
1664
+ /** What its evidence shows, as its producer declared it (see `EvidenceScope`). */
1665
+ readonly scope?: EvidenceScope;
1664
1666
  };
1665
1667
  ```
1666
1668
 
@@ -1680,7 +1682,17 @@ Several holders (for example a check and its layer) are summed: `attachments` al
1680
1682
  attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
1681
1683
  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
1682
1684
  name implies), sorted and distinct. `videos` lists every attachment whose effective media type is video, with the
1683
- check, retry attempt or step that holds it (see {@link isVideoAttachment}).
1685
+ check, retry attempt or step that holds it (see {@link isVideoAttachment}), each with the scope that applies to it.
1686
+
1687
+ #### `summarizeEvidenceIn`
1688
+
1689
+ ```ts
1690
+ export function summarizeEvidenceIn(inherited: EvidenceScope | undefined, ...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined
1691
+ ```
1692
+
1693
+ Like {@link summarizeEvidence} for the checks of a part: a video that declares no scope, and sits in a holder that
1694
+ declares none, takes the scope `inherited` from its part or layer (the nearest declaration of the two, see
1695
+ `effectiveScope`). Without `inherited` it is the same as `summarizeEvidence`.
1684
1696
 
1685
1697
  #### `summarizeLayerEvidence`
1686
1698
 
@@ -1701,6 +1713,16 @@ Whether an attachment is a video, by its effective media type: the producer's `c
1701
1713
  name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
1702
1714
  settles a file whose type nothing says (`application/octet-stream`).
1703
1715
 
1716
+ #### `attachmentIdentity`
1717
+
1718
+ ```ts
1719
+ export function attachmentIdentity(item: Pick<Attachment, 'name' | 'sha256' | 'bundleKey' | 'uri' | 'text'>): string | undefined
1720
+ ```
1721
+
1722
+ What identifies an attachment's bytes: its digest, else its bundle key, address or inline text. Attachments with the
1723
+ same identity are the same file however many results reference them. Undefined when it names no bytes at all (it can
1724
+ never be opened). This is the `recording` of a {@link VideoRef}.
1725
+
1704
1726
  #### `videoCounts`
1705
1727
 
1706
1728
  ```ts
@@ -1730,6 +1752,8 @@ export type CheckDetail = {
1730
1752
  readonly check: Check;
1731
1753
  /** What the run as a whole carries: its logs, its links, its labels. */
1732
1754
  readonly run: EvidenceHolder;
1755
+ /** The scope the check inherits: its part's declaration, else its layer's. Absent when neither declared one, which is unknown, not live or physical. */
1756
+ readonly inheritedScope?: EvidenceScope;
1733
1757
  };
1734
1758
  ```
1735
1759
 
@@ -1738,7 +1762,7 @@ All of one result's evidence, read when someone opens it.
1738
1762
  #### `checkDetailOf`
1739
1763
 
1740
1764
  ```ts
1741
- export function checkDetailOf(layer: { readonly id: string; readonly parts: readonly { readonly kind: string; readonly checks: readonly Check[] }[] } & EvidenceHolder, checkId: string, kind?: string): CheckDetail | null
1765
+ export function checkDetailOf(layer: { readonly id: string; readonly parts: readonly { readonly kind: string; readonly checks: readonly Check[]; readonly scope?: EvidenceScope }[] } & EvidenceHolder, checkId: string, kind?: string): CheckDetail | null
1742
1766
  ```
1743
1767
 
1744
1768
  One result's evidence, from a layer: the first check with id `checkId` (in a part of kind `kind`, when given) plus the layer's own evidence. Null when the layer holds no such check.
@@ -1749,7 +1773,28 @@ One result's evidence, from a layer: the first check with id `checkId` (in a par
1749
1773
  export function runEvidence(layer: EvidenceHolder): EvidenceHolder
1750
1774
  ```
1751
1775
 
1752
- What a layer carries as a whole (its tags, labels, links, attachments, annotations and extensions; not its parts' checks). Absent fields stay absent.
1776
+ What a layer carries as a whole (its scope, tags, labels, links, attachments, annotations and extensions; not its parts' checks). Absent fields stay absent.
1777
+
1778
+ #### `PartEvidence`
1779
+
1780
+ ```ts
1781
+ export type PartEvidence = EvidenceHolder & {
1782
+ readonly kind: string;
1783
+ readonly id: string;
1784
+ readonly title: string;
1785
+ };
1786
+ ```
1787
+
1788
+ What one part of a layer (one kind of report) carries as a whole, named by the part.
1789
+
1790
+ #### `partsEvidence`
1791
+
1792
+ ```ts
1793
+ export function partsEvidence(layer: { readonly parts: readonly (EvidenceHolder & { readonly kind: string; readonly id: string; readonly title: string })[] }): PartEvidence[]
1794
+ ```
1795
+
1796
+ The parts of a layer that carry evidence of their own (attachments, links, labels…), each with its name. Parts
1797
+ that carry none are left out. A part's files belong to the run, not to any one check.
1753
1798
 
1754
1799
  #### `bundleKeysOf`
1755
1800
 
@@ -1844,6 +1889,156 @@ the next pass; `refresh` does not throw for those.
1844
1889
 
1845
1890
  - `args.backend`: The backend id stamped on live layers.
1846
1891
 
1892
+ ### Evidence scope
1893
+
1894
+ Source: `packages/reports/src/evidence-scope.ts`. Import: `@crvouga/atlas/reports/evidence-scope`
1895
+
1896
+ Reads what a producer declared a piece of evidence shows (`EvidenceScope`): which declaration applies to an
1897
+ attachment, the short badges a reviewer sees before playing a recording, the facts split into declared and
1898
+ independently verified, and the filter tokens (`device:physical`, `audio:recorded`) a query narrows by. Pure
1899
+ functions, safe in a browser.
1900
+
1901
+ The one rule: what is not declared is unknown. A missing scope, or a missing field, never reads as live, physical,
1902
+ audible or end to end, and a filter for a value never matches evidence that did not say it. A scope describes
1903
+ evidence; it never decides a status, a slot or a winner.
1904
+
1905
+ See docs/rich-evidence.md#evidence-scope
1906
+
1907
+ #### `SCOPE_DIMENSIONS`
1908
+
1909
+ ```ts
1910
+ export const SCOPE_DIMENSIONS = …
1911
+ ```
1912
+
1913
+ The fields of a scope that say something about the evidence and that `verified` may name, in the order they are shown.
1914
+
1915
+ #### `ScopeDimension`
1916
+
1917
+ ```ts
1918
+ export type ScopeDimension = (typeof SCOPE_DIMENSIONS)[number];
1919
+ ```
1920
+
1921
+ One of {@link SCOPE_DIMENSIONS}.
1922
+
1923
+ #### `UNKNOWN_SCOPE`
1924
+
1925
+ ```ts
1926
+ export const UNKNOWN_SCOPE = …
1927
+ ```
1928
+
1929
+ The filter token of a recording that declares no scope at all.
1930
+
1931
+ #### `effectiveScope`
1932
+
1933
+ ```ts
1934
+ export function effectiveScope(...candidates: readonly (EvidenceScope | undefined)[]): EvidenceScope | undefined
1935
+ ```
1936
+
1937
+ The declaration that applies to something: the nearest one wins as a whole, never field by field, so a recording
1938
+ never shows a limitation or a device that was declared for another source. Pass the candidates nearest first
1939
+ (attachment, attempt, check, part, layer); absent ones are skipped. Undefined when none declares a scope.
1940
+
1941
+ **Example**
1942
+
1943
+ ```ts
1944
+ effectiveScope(attachment.scope, attempt.scope, check.scope, layer.scope);
1945
+ ```
1946
+
1947
+ #### `scopeKey`
1948
+
1949
+ ```ts
1950
+ export function scopeKey(scope: EvidenceScope | undefined): string
1951
+ ```
1952
+
1953
+ A stable string that is equal for equal declarations (key order never matters), to tell one scope from another.
1954
+
1955
+ #### `scopeTokens`
1956
+
1957
+ ```ts
1958
+ export function scopeTokens(scope: EvidenceScope | undefined): string[]
1959
+ ```
1960
+
1961
+ The filter tokens one declaration states, normalized (letter case and spaces never matter): `surface:<value>`,
1962
+ `device:<value>`, `data:synthetic` or `data:real`, `dependencies:<value>`, `audio:recorded` or `audio:silent`,
1963
+ `pacing:<value>`. A field that is not declared adds no token. No scope at all is the single token
1964
+ {@link UNKNOWN_SCOPE}, so a filter can ask for the unknown on purpose; a scope that declares nothing readable is
1965
+ also unknown.
1966
+
1967
+ #### `matchesScope`
1968
+
1969
+ ```ts
1970
+ export function matchesScope(tokens: readonly string[], wanted: readonly string[]): boolean
1971
+ ```
1972
+
1973
+ Whether one recording's tokens satisfy a filter: every wanted token must be among them. Tokens compare normalized.
1974
+ An empty filter matches everything. The token {@link UNKNOWN_SCOPE} matches only a recording with no declared
1975
+ fields, so asking for `device:physical` never includes a recording that did not say what it ran on.
1976
+
1977
+ #### `ScopeBasis`
1978
+
1979
+ ```ts
1980
+ export type ScopeBasis = 'verified' | 'declared' | 'undeclared';
1981
+ ```
1982
+
1983
+ How a fact is known: `verified` (independently checked), `declared` (the producer's word) or `undeclared` (unknown).
1984
+
1985
+ #### `ScopeFact`
1986
+
1987
+ ```ts
1988
+ export type ScopeFact = {
1989
+ readonly dimension: ScopeDimension;
1990
+ /** A name for the dimension: `Device`, `Audio`. */
1991
+ readonly label: string;
1992
+ /** The value as words: `Physical device`, `Silent`, `Not declared`. */
1993
+ readonly value: string;
1994
+ readonly basis: ScopeBasis;
1995
+ };
1996
+ ```
1997
+
1998
+ One line of what a scope says: the dimension, a sentence for a person, and how well it is known.
1999
+
2000
+ #### `ScopeBadge`
2001
+
2002
+ ```ts
2003
+ export type ScopeBadge = {
2004
+ readonly dimension: ScopeDimension | 'unknown';
2005
+ readonly text: string;
2006
+ readonly basis: ScopeBasis;
2007
+ };
2008
+ ```
2009
+
2010
+ A short qualification to show beside a recording: `Device: Simulator`, `Scripted dependencies`, `Silent`.
2011
+
2012
+ #### `scopeFacts`
2013
+
2014
+ ```ts
2015
+ export function scopeFacts(scope: EvidenceScope | undefined): ScopeFact[]
2016
+ ```
2017
+
2018
+ Every dimension of a scope with its value and how it is known, in a fixed order, including the ones nobody declared
2019
+ (`Not declared`, basis `undeclared`). A dimension is `verified` only when the scope's `verified` names it and it has
2020
+ a value; everything else a producer wrote is `declared`. With no scope every dimension is undeclared.
2021
+
2022
+ #### `scopeBadges`
2023
+
2024
+ ```ts
2025
+ export function scopeBadges(scope: EvidenceScope | undefined): ScopeBadge[]
2026
+ ```
2027
+
2028
+ The concise badges for a scope: one per declared dimension, in a fixed order (`Surface: Browser`, `Device: Simulator`, `Synthetic
2029
+ data`, `Scripted dependencies`, `Silent`, `Paced`). With no declared dimension the one badge `Scope unknown`, so
2030
+ a reviewer is told nothing was claimed rather than shown nothing.
2031
+
2032
+ #### `scopeCaveats`
2033
+
2034
+ ```ts
2035
+ export function scopeCaveats(scope: EvidenceScope | undefined): string[]
2036
+ ```
2037
+
2038
+ What a reviewer must not read into evidence: a sentence per thing that is not shown. It names the assertions the
2039
+ result stands on when there are some (so activity visible beyond them is not verified), and every kind of reality
2040
+ that was not declared. Empty only for a scope that declares and verifies everything and lists no limitation.
2041
+
1847
2042
  ### Report evidence types
1848
2043
 
1849
2044
  Source: `packages/reports/src/evidence-types.ts`. Import: `@crvouga/atlas/reports/evidence-types`
@@ -1916,6 +2111,8 @@ export type VideoRef = {
1916
2111
  readonly recording?: string;
1917
2112
  /** False when the reference names no bytes at all (no bundle key, address or inline text), so it can never be played. */
1918
2113
  readonly available: boolean;
2114
+ /** What this recording shows, as declared for it: its own `scope`, else its attempt's, check's, part's or layer's. Absent when none declared one: its scope is unknown, not live, physical or audible. */
2115
+ readonly scope?: EvidenceScope;
1919
2116
  };
1920
2117
  ```
1921
2118
 
@@ -2419,7 +2616,7 @@ A number a check recorded. Exceeding `limit` is shown and never fails the check
2419
2616
  #### `EvidenceExtras`
2420
2617
 
2421
2618
  ```ts
2422
- export type EvidenceExtras = Partial<Pick<import('../../schema/src/layer').Check, 'tags' | 'labels' | 'links' | 'steps' | 'attachments' | 'annotations' | 'attempts' | 'extensions'>>;
2619
+ export type EvidenceExtras = Partial<Pick<import('../../schema/src/layer').Check, 'tags' | 'labels' | 'links' | 'steps' | 'attachments' | 'annotations' | 'attempts' | 'extensions' | 'scope'>>;
2423
2620
  ```
2424
2621
 
2425
2622
  What a check may carry beyond its outcome. All optional and open: see docs/extending.md.
@@ -4333,7 +4530,7 @@ String choices (such as `{ device: 'iphone-15' }`) that make a result its own sl
4333
4530
  #### `LayerExtras`
4334
4531
 
4335
4532
  ```ts
4336
- export type LayerExtras = Partial<Pick<import('../../schema/src/layer').LayerDocument, 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions' | 'recordings'>>;
4533
+ export type LayerExtras = Partial<Pick<import('../../schema/src/layer').LayerDocument, 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions' | 'recordings' | 'scope'>>;
4337
4534
  ```
4338
4535
 
4339
4536
  What a layer may carry as a whole. All optional and open: see docs/extending.md.
@@ -4473,7 +4670,8 @@ Routes, relative to where it is mounted:
4473
4670
  runs of this machine. `cursor` is the previous page's; 409 when it no longer matches the listing (refresh).
4474
4671
  Any `retry` value re-lists backends that were unavailable now instead of after the wait.
4475
4672
  - `GET /evidence/rows/:backend/:id` the checks and spec fingerprints of one layer (`LayerRows`).
4476
- - `GET /evidence/detail/:backend/:id?check=&kind=` the run-level evidence of an immutable layer, or one check's detail.
4673
+ - `GET /evidence/detail/:backend/:id?check=&kind=` the run-level evidence of an immutable layer (and, as `parts`, what its
4674
+ parts carry as a whole), or one check's detail.
4477
4675
  - `GET /layers/:id/blobs/*` an attachment of an immutable layer by its bundle key, served by `attachmentResponse`.
4478
4676
  - `GET /layers/:id/*` the recorded `frame.css`, `tests/<test>/report.json` and `tests/<test>/files/<frame>` of a layer.
4479
4677
  - `GET /live/:source/frame.css` and `GET /live/:source/tests/:testId/*` the same for a run still writing (uncached).
@@ -6636,7 +6834,7 @@ See docs/server.md#streaming-a-run-as-it-happens
6636
6834
  #### `ReporterIdentity`
6637
6835
 
6638
6836
  ```ts
6639
- export type ReporterIdentity = Pick<PublishChecksInput, 'producer' | 'kind' | 'title' | 'sha' | 'branch' | 'runUrl' | 'run' | 'environment' | 'parameters' | 'freshness' | 'specs' | 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'extensions'>;
6837
+ export type ReporterIdentity = Pick<PublishChecksInput, 'producer' | 'kind' | 'title' | 'sha' | 'branch' | 'runUrl' | 'run' | 'environment' | 'parameters' | 'freshness' | 'specs' | 'tags' | 'labels' | 'links' | 'attachments' | 'annotations' | 'scope' | 'extensions'>;
6640
6838
  ```
6641
6839
 
6642
6840
  What every layer of one stream shares: who is reporting, from where. Whatever `publishChecks` takes, except the checks and the time.
@@ -8217,6 +8415,8 @@ export type TestSummary = {
8217
8415
  readonly artifactTags?: readonly string[];
8218
8416
  /** 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. */
8219
8417
  readonly inheritedArtifactTags?: readonly string[];
8418
+ /** What each distinct playable recording of its current results declares it shows, as filter tokens (`device:physical`, `audio:silent`; see `scopeTokens`). A recording that declared nothing is `['unknown']`. Absent when it has no playable recording. A query's `scope` must hold for one recording, so evidence from two different devices never combines. */
8419
+ readonly scopeProfiles?: readonly (readonly string[])[];
8220
8420
  readonly labels: Readonly<Record<string, string>>;
8221
8421
  /** What its current results carry: attachments, steps, retries, and the kinds of attachment. */
8222
8422
  readonly evidence: { readonly attachments: number; readonly steps: number; readonly attempts: number; readonly roles: readonly string[] };
@@ -8309,6 +8509,8 @@ export type CatalogFacets = {
8309
8509
  readonly tags: readonly FacetValue[];
8310
8510
  /** The tags of attachments, normalized. Absent in documents of older servers. */
8311
8511
  readonly artifactTags?: readonly FacetValue[];
8512
+ /** The scope tokens of its recordings (`device:simulator`, `audio:recorded`, `unknown`), with how many tests have a recording that states each. Absent in documents of older servers. */
8513
+ readonly scopes?: readonly FacetValue[];
8312
8514
  readonly branches: readonly FacetValue[];
8313
8515
  readonly labels: Readonly<Record<string, readonly FacetValue[]>>;
8314
8516
  };
@@ -8350,6 +8552,8 @@ export type TestQuery = {
8350
8552
  readonly artifactTag?: readonly string[];
8351
8553
  /** 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. */
8352
8554
  readonly inherited?: boolean;
8555
+ /** Only tests with one playable recording that states every one of these (`device:physical`, `audio:recorded`, `data:synthetic`, `dependencies:live`, `surface:browser`, `pacing:paced`; see `scopeTokens`). Evidence that did not declare a value never matches it; `unknown` matches only a recording that declared no scope. */
8556
+ readonly scope?: readonly string[];
8353
8557
  /** Only tests recorded in this run (a key from `runList`). */
8354
8558
  readonly run?: string;
8355
8559
  readonly sort?: TestSort;
@@ -8429,14 +8633,14 @@ export type RunSummary = {
8429
8633
  * and references that cannot be played. Absent when it has none, and in a report that carries no video information.
8430
8634
  */
8431
8635
  readonly videos?: VideoCounts;
8636
+ /**
8637
+ * Files attached to the run itself, in its layers (and their parts), not to any one check: review movies, archives,
8638
+ * summaries. All kinds, videos included. Absent when none, and in a report that carries no such information.
8639
+ */
8640
+ readonly attachments?: number;
8432
8641
  /** The normalized tags of the attachments of its results and layers, sorted and distinct. Absent when none is tagged. */
8433
8642
  readonly artifactTags?: readonly string[];
8434
8643
  /** The tags of its checks and layers, which its attachments inherit (what `inherited=1` also matches), sorted and distinct. Absent when none. */
8435
- readonly tags?: readonly string[];
8436
- /** The media types (roles) of its attachments, sorted and distinct; `video` only for a playable recording. Absent when it has no attachment. */
8437
- readonly types?: readonly string[];
8438
- /** The `key=value` labels of its results and layers, sorted and distinct. Absent when none. */
8439
- readonly labels?: readonly string[];
8440
8644
  …
8441
8645
  ```
8442
8646
 
@@ -8494,6 +8698,29 @@ export type RunResult = {
8494
8698
 
8495
8699
  One test's last result within one run.
8496
8700
 
8701
+ #### `RunLayer`
8702
+
8703
+ ```ts
8704
+ export type RunLayer = {
8705
+ /** The layer's id: 64 hex digits for an archived layer. */
8706
+ readonly id: string;
8707
+ /** The backend it was read from, for `GET /evidence/detail/{backend}/{id}`. */
8708
+ readonly backend: string;
8709
+ readonly producer: string;
8710
+ readonly environment: string | null;
8711
+ readonly recordedAtMs: number;
8712
+ /** How many checks the layer recorded. A layer of zero checks is evidence only: it is not a passing test. */
8713
+ readonly checks: number;
8714
+ /** Files the layer carries as a whole (not those of its checks). */
8715
+ readonly attachments: number;
8716
+ /** Its videos, when it has any. */
8717
+ readonly videos?: VideoCounts;
8718
+ };
8719
+ ```
8720
+
8721
+ One layer of a run, as the run's artifact hub names it: who recorded it, how many checks it holds (zero is possible
8722
+ and is not a pass) and the files it carries as a whole. Read the files with `GET /evidence/detail/{backend}/{id}`.
8723
+
8497
8724
  #### `RunDetail`
8498
8725
 
8499
8726
  ```ts
@@ -8502,6 +8729,8 @@ export type RunDetail = {
8502
8729
  readonly version: string;
8503
8730
  readonly run: RunSummary;
8504
8731
  readonly results: readonly RunResult[];
8732
+ /** Every layer of the run, oldest first, so its run-level files can be found even in a layer with no checks. Absent from an older server. */
8733
+ readonly layers?: readonly RunLayer[];
8505
8734
  };
8506
8735
  ```
8507
8736
 
@@ -8607,7 +8836,7 @@ const catalog = testCatalog(catalogModel(layers, { history }));
8607
8836
  - `of(key: string): readonly TestResultEntry[]` — The recorded results of one test (by `TestSummary.key`), newest first. Empty when none.
8608
8837
  - `get runCount(): number { return this.#runs.size; }` — How many runs it knows of.
8609
8838
  - `runs(): RunSummary[]` — Every run, newest first.
8610
- - `run(key: string): { readonly run: RunSummary; readonly results: RunResult[] } | null` — One run and its results (failed first, then by spec and title), or null.
8839
+ - `run(key: string): { readonly run: RunSummary; readonly results: RunResult[]; readonly layers: RunLayer[] } | null` — One run and its results (failed first, then by spec and title), or null.
8611
8840
  - `evidenceResults(): readonly { readonly result: ArtifactResult; readonly fields: FilterFields }[]` — Every recorded result that carries an attachment, newest first, with the values a query looks at. Built once per layer added. The domain is left to the caller (`artifactList`), which may know more specs than the layers recorded.
8612
8841
  - `testsIn(key: string): ReadonlySet<string>` — The keys of the tests one run recorded.
8613
8842
 
@@ -8762,7 +8991,7 @@ One run with every result it recorded, or null when no run has that key.
8762
8991
  export function parseTestQuery(params: URLSearchParams): TestQuery
8763
8992
  ```
8764
8993
 
8765
- 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`.
8994
+ Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, `scope` (every token must hold for one recording), and `inherited=1`.
8766
8995
 
8767
8996
  #### `formatMs`
8768
8997
 
@@ -776,6 +776,29 @@ export const MomentSchema = …
776
776
 
777
777
  Where in a check something happened: at a step, and/or this long after the check started.
778
778
 
779
+ #### `EvidenceScopeSchema`
780
+
781
+ ```ts
782
+ export const EvidenceScopeSchema = …
783
+ ```
784
+
785
+ What a piece of evidence does and does not show, declared by the producer that made it: where it ran, whether its
786
+ data and dependencies were real, whether audio was captured, how it was paced and which assertions the result
787
+ stands on. Every field is optional and a field that is absent is unknown, never assumed live, physical or end to end.
788
+
789
+ It is a declaration, not a finding: Atlas shows it as the producer's word, apart from the dimensions the producer
790
+ names in `verified` (facts something independent checked). It describes evidence and never decides a status, a slot
791
+ or a winner. The nearest declaration wins as a whole, never field by field: an attachment's `scope`, else its
792
+ attempt's, else its check's, else its part's, else its layer's.
793
+
794
+ #### `EvidenceScope`
795
+
796
+ ```ts
797
+ export type EvidenceScope = z.output<typeof EvidenceScopeSchema>;
798
+ ```
799
+
800
+ A producer-declared evidence scope as stored in a layer, parsed from `EvidenceScopeSchema`.
801
+
779
802
  #### `AttachmentSchema`
780
803
 
781
804
  ```ts