@crvouga/atlas 1.3.0 → 1.5.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 (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/evidence-detail.d.ts +23 -0
  3. package/dist/reports/src/evidence-detail.js +19 -1
  4. package/dist/reports/src/export-evidence.js +1 -1
  5. package/dist/reports/src/layers-route.d.ts +2 -1
  6. package/dist/reports/src/layers-route.js +8 -4
  7. package/dist/reports/src/test-catalog.d.ts +27 -0
  8. package/dist/reports/src/test-catalog.js +8 -2
  9. package/dist/schema/src/api.d.ts +22 -0
  10. package/dist/schema/src/api.js +10 -1
  11. package/dist/server/src/openapi.js +2 -2
  12. package/docs/reference/http-api.md +4 -4
  13. package/docs/reference/reports.md +64 -7
  14. package/docs/rich-evidence.md +23 -0
  15. package/docs/ui.md +22 -2
  16. package/llms-full.txt +49 -6
  17. package/package.json +1 -1
  18. package/packages/schema/schemas/check-detail.schema.json +24 -0
  19. package/packages/schema/schemas/report-run.schema.json +85 -0
  20. package/packages/schema/schemas/report-runs.schema.json +6 -0
  21. package/packages/server/openapi.json +119 -4
  22. package/packages/visualizer/src/evidence/artifacts.tsx +69 -18
  23. package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
  24. package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
  25. package/packages/visualizer/src/evidence/behavior.ts +268 -0
  26. package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
  27. package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
  28. package/packages/visualizer/src/evidence/gherkin.ts +31 -1
  29. package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
  30. package/packages/visualizer/src/explorer/Explorer.tsx +9 -5
  31. package/packages/visualizer/src/explorer/RunArtifacts.tsx +246 -0
  32. package/packages/visualizer/src/explorer/RunsView.tsx +40 -9
  33. package/packages/visualizer/src/explorer/search.ts +5 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 1.5.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - connect readable behavior with recordings and coverage gaps (#55) ([0a22022](https://github.com/crvouga/atlas/commit/0a22022dac9f9956aa77214af5de7af4fd2b2d88))
8
+
9
+ ## 1.4.0 (2026-10-28)
10
+
11
+ ### Features
12
+
13
+ - surface run attachments and link directly to a recording ([879f049](https://github.com/crvouga/atlas/commit/879f049e9f7bdae1ff1d869125640aa063a9f653))
14
+
3
15
  ## 1.3.0 (2026-10-28)
4
16
 
5
17
  ### Features
@@ -57,6 +57,12 @@ export declare function summarizeLayerEvidence(...holders: readonly EvidenceHold
57
57
  * settles a file whose type nothing says (`application/octet-stream`).
58
58
  */
59
59
  export declare function isVideoAttachment(attachment: Pick<Attachment, 'name' | 'role' | 'contentType'>): boolean;
60
+ /**
61
+ * What identifies an attachment's bytes: its digest, else its bundle key, address or inline text. Attachments with the
62
+ * same identity are the same file however many results reference them. Undefined when it names no bytes at all (it can
63
+ * never be opened). This is the `recording` of a {@link VideoRef}.
64
+ */
65
+ export declare function attachmentIdentity(item: Pick<Attachment, 'name' | 'sha256' | 'bundleKey' | 'uri' | 'text'>): string | undefined;
60
66
  /**
61
67
  * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
62
68
  * "no video" and "nothing known about video" look the same on the wire.
@@ -88,5 +94,22 @@ export declare function checkDetailOf(layer: {
88
94
  } & EvidenceHolder, checkId: string, kind?: string): CheckDetail | null;
89
95
  /** What a layer carries as a whole (its scope, tags, labels, links, attachments, annotations and extensions; not its parts' checks). Absent fields stay absent. */
90
96
  export declare function runEvidence(layer: EvidenceHolder): EvidenceHolder;
97
+ /** What one part of a layer (one kind of report) carries as a whole, named by the part. */
98
+ export type PartEvidence = EvidenceHolder & {
99
+ readonly kind: string;
100
+ readonly id: string;
101
+ readonly title: string;
102
+ };
103
+ /**
104
+ * The parts of a layer that carry evidence of their own (attachments, links, labels…), each with its name. Parts
105
+ * that carry none are left out. A part's files belong to the run, not to any one check.
106
+ */
107
+ export declare function partsEvidence(layer: {
108
+ readonly parts: readonly (EvidenceHolder & {
109
+ readonly kind: string;
110
+ readonly id: string;
111
+ readonly title: string;
112
+ })[];
113
+ }): PartEvidence[];
91
114
  /** Every distinct bundle key (`bundleKey`) that the attachments of a holder, its steps and its attempts point to, so a layer's files can be copied. Attachments held by `uri` or inline `text` have none. */
92
115
  export declare function bundleKeysOf(holder: EvidenceHolder): string[];
@@ -85,10 +85,18 @@ export function isVideoAttachment(attachment) {
85
85
  const type = (attachment.contentType ?? contentTypeOf(attachment.name)).toLowerCase();
86
86
  return type.startsWith('video/') || (attachment.role === 'video' && type.startsWith('application/octet-stream'));
87
87
  }
88
+ /**
89
+ * What identifies an attachment's bytes: its digest, else its bundle key, address or inline text. Attachments with the
90
+ * same identity are the same file however many results reference them. Undefined when it names no bytes at all (it can
91
+ * never be opened). This is the `recording` of a {@link VideoRef}.
92
+ */
93
+ export function attachmentIdentity(item) {
94
+ return item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
95
+ }
88
96
  function videoOf(item, owner, where, scope) {
89
97
  if (!isVideoAttachment(item))
90
98
  return undefined;
91
- const recording = item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
99
+ const recording = attachmentIdentity(item);
92
100
  const tags = normalizeTags(item.tags);
93
101
  return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined, ...(scope ? { scope } : {}) };
94
102
  }
@@ -142,6 +150,16 @@ export function runEvidence(layer) {
142
150
  const { tags, labels, links, attachments, annotations, extensions, scope } = layer;
143
151
  return { ...(scope ? { scope } : {}), ...(tags ? { tags } : {}), ...(labels ? { labels } : {}), ...(links ? { links } : {}), ...(attachments ? { attachments } : {}), ...(annotations ? { annotations } : {}), ...(extensions ? { extensions } : {}) };
144
152
  }
153
+ /**
154
+ * The parts of a layer that carry evidence of their own (attachments, links, labels…), each with its name. Parts
155
+ * that carry none are left out. A part's files belong to the run, not to any one check.
156
+ */
157
+ export function partsEvidence(layer) {
158
+ return layer.parts.flatMap(part => {
159
+ const evidence = runEvidence(part);
160
+ return Object.keys(evidence).length ? [{ kind: part.kind, id: part.id, title: part.title, ...evidence }] : [];
161
+ });
162
+ }
145
163
  /** Every distinct bundle key (`bundleKey`) that the attachments of a holder, its steps and its attempts point to, so a layer's files can be copied. Attachments held by `uri` or inline `text` have none. */
146
164
  export function bundleKeysOf(holder) {
147
165
  const keys = new Set();
@@ -109,7 +109,7 @@ export async function exportEvidence(args) {
109
109
  if (!text)
110
110
  return;
111
111
  const read = JSON.parse(text);
112
- const keys = new Set([...bundleKeysOf('check' in read ? read.check : {}), ...bundleKeysOf(read.run)]);
112
+ const keys = new Set([...bundleKeysOf('check' in read ? read.check : {}), ...bundleKeysOf(read.run), ...('parts' in read ? (read.parts ?? []).flatMap(bundleKeysOf) : [])]);
113
113
  await eachLimited([...keys], 4, key => copy(`/layers/${encodeURIComponent(layer.id)}/blobs/${key.split('/').map(encodeURIComponent).join('/')}`).then(() => { }));
114
114
  };
115
115
  const base = `/evidence/detail/${encodeURIComponent(layer.backend)}/${encodeURIComponent(layer.id)}`;
@@ -51,7 +51,8 @@ export declare function reportBackends(deps: ReportsAppDeps): Backend[];
51
51
  * runs of this machine. `cursor` is the previous page's; 409 when it no longer matches the listing (refresh).
52
52
  * Any `retry` value re-lists backends that were unavailable now instead of after the wait.
53
53
  * - `GET /evidence/rows/:backend/:id` the checks and spec fingerprints of one layer (`LayerRows`).
54
- * - `GET /evidence/detail/:backend/:id?check=&kind=` the run-level evidence of an immutable layer, or one check's detail.
54
+ * - `GET /evidence/detail/:backend/:id?check=&kind=` the run-level evidence of an immutable layer (and, as `parts`, what its
55
+ * parts carry as a whole), or one check's detail.
55
56
  * - `GET /layers/:id/blobs/*` an attachment of an immutable layer by its bundle key, served by `attachmentResponse`.
56
57
  * - `GET /layers/:id/*` the recorded `frame.css`, `tests/<test>/report.json` and `tests/<test>/files/<frame>` of a layer.
57
58
  * - `GET /live/:source/frame.css` and `GET /live/:source/tests/:testId/*` the same for a run still writing (uncached).
@@ -19,7 +19,7 @@ import { createLiveEvidence, liveRunRecord } from './evidence-live.js';
19
19
  import { isFramePath, isKeySegment } from './keys.js';
20
20
  import { catalogEntries, catalogLayers } from './layer-catalog.js';
21
21
  import { attachmentResponse } from './attachment-response.js';
22
- import { checkDetailOf, runEvidence } from './evidence-detail.js';
22
+ import { checkDetailOf, partsEvidence, runEvidence } from './evidence-detail.js';
23
23
  import { layerEvidence } from './layer-merge.js';
24
24
  import { layerSummary, readLayer } from './layer-store.js';
25
25
  import { readBytes, readUtf8 } from './object-io.js';
@@ -69,7 +69,8 @@ function archived(id) {
69
69
  * runs of this machine. `cursor` is the previous page's; 409 when it no longer matches the listing (refresh).
70
70
  * Any `retry` value re-lists backends that were unavailable now instead of after the wait.
71
71
  * - `GET /evidence/rows/:backend/:id` the checks and spec fingerprints of one layer (`LayerRows`).
72
- * - `GET /evidence/detail/:backend/:id?check=&kind=` the run-level evidence of an immutable layer, or one check's detail.
72
+ * - `GET /evidence/detail/:backend/:id?check=&kind=` the run-level evidence of an immutable layer (and, as `parts`, what its
73
+ * parts carry as a whole), or one check's detail.
73
74
  * - `GET /layers/:id/blobs/*` an attachment of an immutable layer by its bundle key, served by `attachmentResponse`.
74
75
  * - `GET /layers/:id/*` the recorded `frame.css`, `tests/<test>/report.json` and `tests/<test>/files/<frame>` of a layer.
75
76
  * - `GET /live/:source/frame.css` and `GET /live/:source/tests/:testId/*` the same for a run still writing (uncached).
@@ -286,8 +287,11 @@ export function createLayersApp(deps) {
286
287
  return c.notFound();
287
288
  const checkId = c.req.query('check');
288
289
  const headers = { 'cache-control': 'public, max-age=31536000, immutable' };
289
- if (checkId === undefined)
290
- return c.json({ layer: id, run: runEvidence(layer) }, 200, headers);
290
+ if (checkId === undefined) {
291
+ // The parts' own evidence is optional: a client that does not know `parts` still has the run's.
292
+ const parts = partsEvidence(layer);
293
+ return c.json({ layer: id, run: runEvidence(layer), ...(parts.length ? { parts } : {}) }, 200, headers);
294
+ }
291
295
  const detail = checkDetailOf(layer, checkId, c.req.query('kind'));
292
296
  return detail ? c.json(detail, 200, headers) : c.notFound();
293
297
  });
@@ -270,6 +270,11 @@ export type RunSummary = {
270
270
  * and references that cannot be played. Absent when it has none, and in a report that carries no video information.
271
271
  */
272
272
  readonly videos?: VideoCounts;
273
+ /**
274
+ * Files attached to the run itself, in its layers (and their parts), not to any one check: review movies, archives,
275
+ * summaries. All kinds, videos included. Absent when none, and in a report that carries no such information.
276
+ */
277
+ readonly attachments?: number;
273
278
  /** The normalized tags of the attachments of its results and layers, sorted and distinct. Absent when none is tagged. */
274
279
  readonly artifactTags?: readonly string[];
275
280
  /** The tags of its checks and layers, which its attachments inherit (what `inherited=1` also matches), sorted and distinct. Absent when none. */
@@ -314,12 +319,33 @@ export type RunResult = {
314
319
  /** Every video its evidence references, with who holds it (check, retry attempt or step). Absent when there is none. */
315
320
  readonly videos?: readonly VideoRef[];
316
321
  };
322
+ /**
323
+ * One layer of a run, as the run's artifact hub names it: who recorded it, how many checks it holds (zero is possible
324
+ * and is not a pass) and the files it carries as a whole. Read the files with `GET /evidence/detail/{backend}/{id}`.
325
+ */
326
+ export type RunLayer = {
327
+ /** The layer's id: 64 hex digits for an archived layer. */
328
+ readonly id: string;
329
+ /** The backend it was read from, for `GET /evidence/detail/{backend}/{id}`. */
330
+ readonly backend: string;
331
+ readonly producer: string;
332
+ readonly environment: string | null;
333
+ readonly recordedAtMs: number;
334
+ /** How many checks the layer recorded. A layer of zero checks is evidence only: it is not a passing test. */
335
+ readonly checks: number;
336
+ /** Files the layer carries as a whole (not those of its checks). */
337
+ readonly attachments: number;
338
+ /** Its videos, when it has any. */
339
+ readonly videos?: VideoCounts;
340
+ };
317
341
  /** `GET /api/reports/runs/detail`: one run and every result it recorded. */
318
342
  export type RunDetail = {
319
343
  readonly schemaVersion: typeof TEST_CATALOG_VERSION;
320
344
  readonly version: string;
321
345
  readonly run: RunSummary;
322
346
  readonly results: readonly RunResult[];
347
+ /** 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. */
348
+ readonly layers?: readonly RunLayer[];
323
349
  };
324
350
  /**
325
351
  * One recorded result that carries evidence (an attachment: a recording, a screenshot, a trace), with where and when it
@@ -423,6 +449,7 @@ export declare class TestHistory {
423
449
  run(key: string): {
424
450
  readonly run: RunSummary;
425
451
  readonly results: RunResult[];
452
+ readonly layers: RunLayer[];
426
453
  } | null;
427
454
  /**
428
455
  * Every recorded result that carries an attachment, newest first, with the values a query looks at. Built once per
@@ -132,7 +132,7 @@ export class TestHistory {
132
132
  const key = runKey(layer);
133
133
  let run = this.#runs.get(key);
134
134
  if (!run) {
135
- run = { key, runUrl: layer.runUrl, branch: layer.branch, sha: layer.sha, start: layer.recordedAtMs, end: layer.recordedAtMs, firstAt: layer.recordedAtMs, first: layer.id, producers: new Set(), environments: new Set(), layers: 0, runId: layer.run?.id, title: undefined, titledAt: 0, titledBy: '', freshness: undefined, videos: [], evidence: { tags: new Set(), artifactTags: new Set(), roles: new Set(), labels: new Set() } };
135
+ run = { key, runUrl: layer.runUrl, branch: layer.branch, sha: layer.sha, start: layer.recordedAtMs, end: layer.recordedAtMs, firstAt: layer.recordedAtMs, first: layer.id, producers: new Set(), environments: new Set(), layers: 0, runId: layer.run?.id, title: undefined, titledAt: 0, titledBy: '', freshness: undefined, videos: [], attachments: 0, members: [], evidence: { tags: new Set(), artifactTags: new Set(), roles: new Set(), labels: new Set() } };
136
136
  this.#runs.set(key, run);
137
137
  }
138
138
  // A run is told by its first layer (by time, then id), whatever order its layers arrive in.
@@ -161,6 +161,10 @@ export class TestHistory {
161
161
  run.layers += 1;
162
162
  if (layer.evidence?.videos)
163
163
  run.videos.push(...layer.evidence.videos);
164
+ const held = layer.evidence?.attachments ?? 0;
165
+ run.attachments += held;
166
+ const layerVideos = videoCounts(layer.evidence?.videos);
167
+ run.members.push({ id: layer.id, backend, producer: layer.producer, environment: layer.environment, recordedAtMs: layer.recordedAtMs, checks: rows.length, attachments: held, ...(layerVideos ? { videos: layerVideos } : {}) });
164
168
  if (layer.evidence)
165
169
  takeEvidence(run.evidence, layer.evidence);
166
170
  const origin = originOf(layer, backend);
@@ -253,7 +257,8 @@ export class TestHistory {
253
257
  ...(r.e?.videos ? { videos: r.e.videos } : {}),
254
258
  }));
255
259
  results.sort((a, b) => FAILURE_RANK[b.status] - FAILURE_RANK[a.status] || compareText(a.specPath ?? '', b.specPath ?? '') || compareText(a.title, b.title) || compareText(a.environment ?? '', b.environment ?? '') || compareText(a.producer, b.producer) || compareText(a.key, b.key));
256
- return { run: runSummary(run, cells), results };
260
+ const layers = [...run.members].sort((a, b) => a.recordedAtMs - b.recordedAtMs || compareText(a.id, b.id));
261
+ return { run: runSummary(run, cells), results, layers };
257
262
  }
258
263
  /**
259
264
  * Every recorded result that carries an attachment, newest first, with the values a query looks at. Built once per
@@ -352,6 +357,7 @@ function runSummary(run, cells) {
352
357
  producers: [...run.producers].sort(), environments: [...run.environments].sort(),
353
358
  layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
354
359
  ...(video ? { videos: video } : {}),
360
+ ...(run.attachments ? { attachments: run.attachments } : {}),
355
361
  ...(carried.artifactTags.size ? { artifactTags: [...carried.artifactTags].sort() } : {}),
356
362
  ...(carried.roles.size ? { types: typesOf(carried.roles, (video?.recordings ?? 0) > 0) } : {}),
357
363
  ...(carried.labels.size ? { labels: [...carried.labels].sort() } : {}),
@@ -1317,6 +1317,11 @@ export declare const CheckDetailSchema: z.ZodObject<{
1317
1317
  recordingExpectation: z.ZodOptional<z.ZodString>;
1318
1318
  }, z.core.$loose>;
1319
1319
  run: z.ZodObject<{}, z.core.$loose>;
1320
+ parts: z.ZodOptional<z.ZodArray<z.ZodObject<{
1321
+ kind: z.ZodString;
1322
+ id: z.ZodString;
1323
+ title: z.ZodString;
1324
+ }, z.core.$loose>>>;
1320
1325
  inheritedScope: z.ZodOptional<z.ZodObject<{
1321
1326
  surface: z.ZodOptional<z.ZodString>;
1322
1327
  deviceKind: z.ZodOptional<z.ZodString>;
@@ -2188,6 +2193,7 @@ export declare const ReportRunSchema: z.ZodObject<{
2188
2193
  recordings: z.ZodNumber;
2189
2194
  unavailable: z.ZodNumber;
2190
2195
  }, z.core.$loose>>;
2196
+ attachments: z.ZodOptional<z.ZodNumber>;
2191
2197
  artifactTags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2192
2198
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2193
2199
  types: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -2274,6 +2280,7 @@ export declare const RunListSchema: z.ZodObject<{
2274
2280
  recordings: z.ZodNumber;
2275
2281
  unavailable: z.ZodNumber;
2276
2282
  }, z.core.$loose>>;
2283
+ attachments: z.ZodOptional<z.ZodNumber>;
2277
2284
  artifactTags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2278
2285
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2279
2286
  types: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -2514,6 +2521,7 @@ export declare const RunDetailSchema: z.ZodObject<{
2514
2521
  recordings: z.ZodNumber;
2515
2522
  unavailable: z.ZodNumber;
2516
2523
  }, z.core.$loose>>;
2524
+ attachments: z.ZodOptional<z.ZodNumber>;
2517
2525
  artifactTags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2518
2526
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
2519
2527
  types: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -2565,4 +2573,18 @@ export declare const RunDetailSchema: z.ZodObject<{
2565
2573
  }, z.core.$loose>>;
2566
2574
  }, z.core.$loose>>>;
2567
2575
  }, z.core.$loose>>;
2576
+ layers: z.ZodOptional<z.ZodArray<z.ZodObject<{
2577
+ id: z.ZodString;
2578
+ backend: z.ZodString;
2579
+ producer: z.ZodString;
2580
+ environment: z.ZodNullable<z.ZodString>;
2581
+ recordedAtMs: z.ZodNumber;
2582
+ checks: z.ZodNumber;
2583
+ attachments: z.ZodNumber;
2584
+ videos: z.ZodOptional<z.ZodObject<{
2585
+ references: z.ZodNumber;
2586
+ recordings: z.ZodNumber;
2587
+ unavailable: z.ZodNumber;
2588
+ }, z.core.$loose>>;
2589
+ }, z.core.$loose>>>;
2568
2590
  }, z.core.$loose>;
@@ -191,6 +191,7 @@ export const CheckDetailSchema = z.looseObject({
191
191
  kind: z.string(),
192
192
  check: CheckSchema,
193
193
  run: z.looseObject({}).describe('What the run as a whole carries: its logs, links, labels, annotations, scope and extensions'),
194
+ parts: z.array(z.looseObject({ kind: z.string(), id: z.string(), title: z.string() })).optional().describe('Without `check` only: the parts of the layer that carry files or links of their own, each with what it carries. Absent when none'),
194
195
  inheritedScope: EvidenceScopeSchema.optional().describe('The scope the check inherits: its part\'s declaration, else its layer\'s. Absent when neither declared one: unknown, not live or physical')
195
196
  });
196
197
  /** `GET /api/store?prefix=…`: one page of keys under `layers/`, in code-unit order. */
@@ -401,6 +402,7 @@ export const ReportRunSchema = z.looseObject({
401
402
  totalDurationMs: z.number().nullable(),
402
403
  failing: z.array(z.looseObject({ key: z.string(), title: z.string(), environment: z.string().nullable(), detail: z.string() })),
403
404
  videos: VideoCountsSchema.optional().describe('Its videos, from its layers and the last result of each test. Absent when it has none or the report carries no video information'),
405
+ attachments: z.number().int().positive().optional().describe('Files attached to the run itself, in its layers, not to a check. Absent when there are none'),
404
406
  artifactTags: z.array(z.string()).optional().describe('The normalized tags of the attachments of its results, sorted and distinct. Absent when none is tagged, and in reports of older servers'),
405
407
  tags: z.array(z.string()).optional().describe('The tags of its checks and layers, which its attachments inherit (what `inherited=1` also matches). Absent when none'),
406
408
  types: z.array(z.string()).optional().describe('The media types (roles) of its attachments, sorted and distinct; `video` only for a playable recording. Absent when it has no attachment'),
@@ -457,5 +459,12 @@ export const RunDetailSchema = z.looseObject({
457
459
  key: z.string(), id: z.string(), title: z.string(), specPath: z.string().nullable(), environment: z.string().nullable(), producer: z.string(),
458
460
  status: EvidenceStatusSchema, durationMs: z.number().nullable(), at: z.number(), layer: z.string(), detail: z.string(),
459
461
  videos: z.array(VideoRefSchema).optional().describe('Every video its evidence references, with who holds it')
460
- }))
462
+ })),
463
+ layers: z.array(z.looseObject({
464
+ id: z.string().describe('The layer id; read its files with `/api/reports/evidence/detail/{backend}/{id}`'),
465
+ backend: z.string(), producer: z.string(), environment: z.string().nullable(), recordedAtMs: z.number(),
466
+ checks: z.number().int().nonnegative().describe('How many checks the layer recorded. Zero means evidence only, not a pass'),
467
+ attachments: z.number().int().nonnegative().describe('Files the layer carries as a whole, not those of its checks'),
468
+ videos: VideoCountsSchema.optional()
469
+ })).optional().describe('Every layer of the run, oldest first. Absent from an older server')
461
470
  });
@@ -393,7 +393,7 @@ const BASE_OPERATIONS = [
393
393
  {
394
394
  route: 'GET /api/reports/runs/detail', path: '/api/reports/runs/detail', operationId: 'getReportRun', tag: 'Report', role: 'read',
395
395
  summary: 'Every test one run recorded',
396
- 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.',
397
397
  parameters: [
398
398
  { name: 'key', in: 'query', required: true, description: 'The run\'s `key` from `/api/reports/runs`, URL-encoded.', schema: str },
399
399
  { name: 'format', in: 'query', description: '`markdown` for Markdown instead of JSON.', schema: { type: 'string', enum: ['json', 'markdown'] } }
@@ -430,7 +430,7 @@ const BASE_OPERATIONS = [
430
430
  {
431
431
  route: 'GET /api/reports/evidence/detail/:backend/:id', path: '/api/reports/evidence/detail/{backend}/{id}', operationId: 'getCheckDetail', tag: 'Report', role: 'read',
432
432
  summary: 'Everything one result carries',
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. 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}`.',
434
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 }],
435
435
  responses: { '200': json('The detail.', 'CheckDetail'), '401': UNAUTHORIZED, '404': problem('No such layer or check.') }
436
436
  },
@@ -756,7 +756,7 @@ Responses: `200` The matching results and the facets.; `304` Unchanged since the
756
756
 
757
757
  `GET /api/reports/runs/detail` · role: read
758
758
 
759
- 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.
760
760
 
761
761
  - `key` (query, required): The run's `key` from `/api/reports/runs`, URL-encoded.
762
762
  - `format` (query): `markdown` for Markdown instead of JSON.
@@ -768,7 +768,7 @@ Responses: `200` The run and its results.; `304` Unchanged since the `If-None-Ma
768
768
 
769
769
  `GET /api/projects/{project}/reports/runs/detail` · role: read
770
770
 
771
- 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.
772
772
 
773
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.
774
774
 
@@ -878,7 +878,7 @@ Responses: `200` The rows.; `401` No token, or an unknown one. Send `Authorizati
878
878
 
879
879
  `GET /api/reports/evidence/detail/{backend}/{id}` · role: read
880
880
 
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. 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}`.
882
882
 
883
883
  - `backend` (path, required): A backend id from `/api/server`.
884
884
  - `id` (path, required): A layer id: 64 lowercase hex digits.
@@ -892,7 +892,7 @@ Responses: `200` The detail.; `401` No token, or an unknown one. Send `Authoriza
892
892
 
893
893
  `GET /api/projects/{project}/reports/evidence/detail/{backend}/{id}` · role: read
894
894
 
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. 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}`.
896
896
 
897
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.
898
898
 
@@ -1713,6 +1713,16 @@ Whether an attachment is a video, by its effective media type: the producer's `c
1713
1713
  name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
1714
1714
  settles a file whose type nothing says (`application/octet-stream`).
1715
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
+
1716
1726
  #### `videoCounts`
1717
1727
 
1718
1728
  ```ts
@@ -1765,6 +1775,27 @@ export function runEvidence(layer: EvidenceHolder): EvidenceHolder
1765
1775
 
1766
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.
1767
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.
1798
+
1768
1799
  #### `bundleKeysOf`
1769
1800
 
1770
1801
  ```ts
@@ -4639,7 +4670,8 @@ Routes, relative to where it is mounted:
4639
4670
  runs of this machine. `cursor` is the previous page's; 409 when it no longer matches the listing (refresh).
4640
4671
  Any `retry` value re-lists backends that were unavailable now instead of after the wait.
4641
4672
  - `GET /evidence/rows/:backend/:id` the checks and spec fingerprints of one layer (`LayerRows`).
4642
- - `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.
4643
4675
  - `GET /layers/:id/blobs/*` an attachment of an immutable layer by its bundle key, served by `attachmentResponse`.
4644
4676
  - `GET /layers/:id/*` the recorded `frame.css`, `tests/<test>/report.json` and `tests/<test>/files/<frame>` of a layer.
4645
4677
  - `GET /live/:source/frame.css` and `GET /live/:source/tests/:testId/*` the same for a run still writing (uncached).
@@ -8601,14 +8633,14 @@ export type RunSummary = {
8601
8633
  * and references that cannot be played. Absent when it has none, and in a report that carries no video information.
8602
8634
  */
8603
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;
8604
8641
  /** The normalized tags of the attachments of its results and layers, sorted and distinct. Absent when none is tagged. */
8605
8642
  readonly artifactTags?: readonly string[];
8606
8643
  /** The tags of its checks and layers, which its attachments inherit (what `inherited=1` also matches), sorted and distinct. Absent when none. */
8607
- readonly tags?: readonly string[];
8608
- /** The media types (roles) of its attachments, sorted and distinct; `video` only for a playable recording. Absent when it has no attachment. */
8609
- readonly types?: readonly string[];
8610
- /** The `key=value` labels of its results and layers, sorted and distinct. Absent when none. */
8611
- readonly labels?: readonly string[];
8612
8644
  …
8613
8645
  ```
8614
8646
 
@@ -8666,6 +8698,29 @@ export type RunResult = {
8666
8698
 
8667
8699
  One test's last result within one run.
8668
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
+
8669
8724
  #### `RunDetail`
8670
8725
 
8671
8726
  ```ts
@@ -8674,6 +8729,8 @@ export type RunDetail = {
8674
8729
  readonly version: string;
8675
8730
  readonly run: RunSummary;
8676
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[];
8677
8734
  };
8678
8735
  ```
8679
8736
 
@@ -8779,7 +8836,7 @@ const catalog = testCatalog(catalogModel(layers, { history }));
8779
8836
  - `of(key: string): readonly TestResultEntry[]` — The recorded results of one test (by `TestSummary.key`), newest first. Empty when none.
8780
8837
  - `get runCount(): number { return this.#runs.size; }` — How many runs it knows of.
8781
8838
  - `runs(): RunSummary[]` — Every run, newest first.
8782
- - `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.
8783
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.
8784
8841
  - `testsIn(key: string): ReadonlySet<string>` — The keys of the tests one run recorded.
8785
8842
 
@@ -303,6 +303,29 @@ or the command line, an attachment may name a `path` instead; `atlas report publ
303
303
  `atlas report publish --ctrf` keeps it ([native runner reporters](runners.md) cover CodeceptJS, Mocha, Jest and Node's
304
304
  test runner).
305
305
 
306
+ ## Run artifacts and direct links
307
+
308
+ Files attached to a run as a whole (top-level `attachments`: a review movie, a ZIP, an HTML gallery, a summary) are
309
+ shown under **Artifacts** when you open a run in the viewer's Runs tab, without opening any test. The movie plays
310
+ inline, an archive is a download, and each file keeps its name, role, type, size, duration and tags. They are grouped
311
+ by the layer that carries them, with who recorded it and when. A layer may hold no checks at all, only files: it is
312
+ listed with `No checks in this layer`, and a run of only such layers shows `No checks recorded`, never `0 passed`.
313
+
314
+ - **Direct link.** *Link to this file* gives an address that names the project, the run, one immutable layer and one
315
+ attachment: `#/?tab=runs&openRun=<run key>&layer=<layer id>&artifact=<id:… | key:… | name:…>`. Opened in a fresh tab
316
+ it selects that same file of that same layer, whatever runs arrived since. A link whose file is not in the layer says
317
+ so instead of showing another file.
318
+ - **Shared recordings.** Tests that reference the same bytes (same SHA-256 or bundle key) are one recording. The run's
319
+ *Recordings in tests* lists each once, with every test that references it, and a run-level file that a test also
320
+ references says so.
321
+ - **Unplayable files.** A file with no stored bytes, with an address that is not a web address, in a type the viewer
322
+ has no preview for, or that the browser cannot play, shows what is wrong and, when it can, a download.
323
+ - **Safety is unchanged.** Files are served only through the attachment route (never sniffed, sandboxed), and an HTML
324
+ archive renders in an iframe with an empty `sandbox`: it cannot run script.
325
+ - **API.** `GET /api/reports/runs/detail` lists the run's `layers` (backend, check count, file count) and
326
+ `GET /api/reports/evidence/detail/{backend}/{id}` returns what a layer carries as a whole (and, as `parts`, what its
327
+ parts carry). Both only gained optional fields.
328
+
306
329
  ## Recording completeness
307
330
 
308
331
  A green run does not say whether the videos you expected were kept and published. Atlas answers that separately from
package/docs/ui.md CHANGED
@@ -67,6 +67,26 @@ and component defaults still live only in `ui/theme.tsx`. Quint/Alloy grammar so
67
67
  [`ui/grammars/README.md`](../packages/visualizer/src/ui/grammars/README.md). Unknown formats remain plain
68
68
  text, and parse failures must leave the source visible. Syntax highlighting never means a spec passed.
69
69
 
70
+ ## Behaviors: Read, Watch, Inspect
71
+
72
+ A Gherkin page reads as behaviors: each scenario, or each example row of an outline (never the outline template as an
73
+ extra), is one behavior in one coverage state (`evidence/behavior.ts`). Opening a behavior shows Mantine `Tabs`:
74
+ **Read** (the steps with the example's values filled in), **Watch** (what is expected next to the matched recording,
75
+ its status and provenance) and **Inspect** (the existing check detail: runs, environments, attempts, files). A behavior
76
+ with a playable recording opens on Watch, one in a run in progress on Inspect, otherwise on Read. The video is read only
77
+ when Watch is on screen.
78
+
79
+ - States stay distinct and none but `demonstrated` is a pass with proof: recording missing, recording of an older spec
80
+ revision, no recording, API only, failed, running, skipped, parked (tagged `@parked`, `@wip`, `@pending`) and not executed.
81
+ Counts are in the Coverage section above the source; an empty state is left out except `demonstrated`.
82
+ - The promise's revision (the spec's fingerprint now) and the evidence's revision (the fingerprint the layer recorded)
83
+ are shown separately. A known difference demotes a recording to "Recording is older" with an alert; a missing
84
+ fingerprint is "not recorded", never a match.
85
+ - A recording is always labelled illustrative: it shows one run and does not approve a behavior for production.
86
+ `demo`/`representative` tags only add a "Representative demo" label.
87
+ - The spec index searches titles, paths and declared domains, and can group by the domain from spec metadata (specs with
88
+ none are listed under "No domain declared"; the Tests view reports domain problems).
89
+
70
90
  ## Icons and notation marks
71
91
 
72
92
  Use named imports from `@tabler/icons-react` for all UI glyphs. The icon package supplies SVG paths;
@@ -125,9 +145,9 @@ The narrowly scoped exceptions are:
125
145
  | Area | Screens/interactions | Mantine composition |
126
146
  | --- | --- | --- |
127
147
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
128
- | Project home | Tests/runs/artifacts tabs, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
148
+ | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
129
149
  | Test detail | Current outcomes, statistics, duration graph, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
130
- | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
150
+ | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
131
151
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
132
152
  | Evidence | Steps, retries, annotations, facts, unknown extensions, safe downloads and previews | List, Badge, Accordion, Code, Image, native media |
133
153
  | Recordings | Pictures/video/timeline, actor selection, moments, failure jumps, timing, model-state differences | Tabs, Slider, Button, ScrollArea, Table, Progress |