@crvouga/atlas 1.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 1.4.0 (2026-10-28)
4
+
5
+ ### Features
6
+
7
+ - surface run attachments and link directly to a recording ([879f049](https://github.com/crvouga/atlas/commit/879f049e9f7bdae1ff1d869125640aa063a9f653))
8
+
3
9
  ## 1.3.0 (2026-10-28)
4
10
 
5
11
  ### 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
@@ -125,7 +125,7 @@ The narrowly scoped exceptions are:
125
125
  | Area | Screens/interactions | Mantine composition |
126
126
  | --- | --- | --- |
127
127
  | 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 |
128
+ | 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
129
  | 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
130
  | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
131
131
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
package/llms-full.txt CHANGED
@@ -1290,6 +1290,29 @@ or the command line, an attachment may name a `path` instead; `atlas report publ
1290
1290
  `atlas report publish --ctrf` keeps it ([native runner reporters](runners.md) cover CodeceptJS, Mocha, Jest and Node's
1291
1291
  test runner).
1292
1292
 
1293
+ ## Run artifacts and direct links
1294
+
1295
+ Files attached to a run as a whole (top-level `attachments`: a review movie, a ZIP, an HTML gallery, a summary) are
1296
+ shown under **Artifacts** when you open a run in the viewer's Runs tab, without opening any test. The movie plays
1297
+ inline, an archive is a download, and each file keeps its name, role, type, size, duration and tags. They are grouped
1298
+ by the layer that carries them, with who recorded it and when. A layer may hold no checks at all, only files: it is
1299
+ listed with `No checks in this layer`, and a run of only such layers shows `No checks recorded`, never `0 passed`.
1300
+
1301
+ - **Direct link.** *Link to this file* gives an address that names the project, the run, one immutable layer and one
1302
+ attachment: `#/?tab=runs&openRun=<run key>&layer=<layer id>&artifact=<id:… | key:… | name:…>`. Opened in a fresh tab
1303
+ it selects that same file of that same layer, whatever runs arrived since. A link whose file is not in the layer says
1304
+ so instead of showing another file.
1305
+ - **Shared recordings.** Tests that reference the same bytes (same SHA-256 or bundle key) are one recording. The run's
1306
+ *Recordings in tests* lists each once, with every test that references it, and a run-level file that a test also
1307
+ references says so.
1308
+ - **Unplayable files.** A file with no stored bytes, with an address that is not a web address, in a type the viewer
1309
+ has no preview for, or that the browser cannot play, shows what is wrong and, when it can, a download.
1310
+ - **Safety is unchanged.** Files are served only through the attachment route (never sniffed, sandboxed), and an HTML
1311
+ archive renders in an iframe with an empty `sandbox`: it cannot run script.
1312
+ - **API.** `GET /api/reports/runs/detail` lists the run's `layers` (backend, check count, file count) and
1313
+ `GET /api/reports/evidence/detail/{backend}/{id}` returns what a layer carries as a whole (and, as `parts`, what its
1314
+ parts carry). Both only gained optional fields.
1315
+
1293
1316
  ## Recording completeness
1294
1317
 
1295
1318
  A green run does not say whether the videos you expected were kept and published. Atlas answers that separately from
@@ -3934,7 +3957,7 @@ The narrowly scoped exceptions are:
3934
3957
  | Area | Screens/interactions | Mantine composition |
3935
3958
  | --- | --- | --- |
3936
3959
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
3937
- | 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 |
3960
+ | 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 |
3938
3961
  | 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 |
3939
3962
  | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
3940
3963
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
@@ -5230,7 +5253,7 @@ Responses: `200` The matching results and the facets.; `304` Unchanged since the
5230
5253
 
5231
5254
  `GET /api/reports/runs/detail` · role: read
5232
5255
 
5233
- One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
5256
+ 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.
5234
5257
 
5235
5258
  - `key` (query, required): The run's `key` from `/api/reports/runs`, URL-encoded.
5236
5259
  - `format` (query): `markdown` for Markdown instead of JSON.
@@ -5242,7 +5265,7 @@ Responses: `200` The run and its results.; `304` Unchanged since the `If-None-Ma
5242
5265
 
5243
5266
  `GET /api/projects/{project}/reports/runs/detail` · role: read
5244
5267
 
5245
- One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
5268
+ 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.
5246
5269
 
5247
5270
  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.
5248
5271
 
@@ -5352,7 +5375,7 @@ Responses: `200` The rows.; `401` No token, or an unknown one. Send `Authorizati
5352
5375
 
5353
5376
  `GET /api/reports/evidence/detail/{backend}/{id}` · role: read
5354
5377
 
5355
- 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}`.
5378
+ 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}`.
5356
5379
 
5357
5380
  - `backend` (path, required): A backend id from `/api/server`.
5358
5381
  - `id` (path, required): A layer id: 64 lowercase hex digits.
@@ -5366,7 +5389,7 @@ Responses: `200` The detail.; `401` No token, or an unknown one. Send `Authoriza
5366
5389
 
5367
5390
  `GET /api/projects/{project}/reports/evidence/detail/{backend}/{id}` · role: read
5368
5391
 
5369
- 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}`.
5392
+ 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}`.
5370
5393
 
5371
5394
  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.
5372
5395