@crvouga/atlas 0.15.0 → 0.16.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 (30) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/reports/src/evidence-detail.d.ts +19 -2
  3. package/dist/reports/src/evidence-detail.js +63 -14
  4. package/dist/reports/src/evidence-types.d.ts +31 -0
  5. package/dist/reports/src/layer-merge.js +2 -2
  6. package/dist/reports/src/test-catalog.d.ts +24 -3
  7. package/dist/reports/src/test-catalog.js +37 -11
  8. package/dist/schema/src/api.d.ts +167 -0
  9. package/dist/schema/src/api.js +28 -4
  10. package/dist/server/src/app.js +3 -2
  11. package/dist/server/src/llms.js +2 -1
  12. package/dist/server/src/openapi.js +2 -1
  13. package/docs/reference/http-api.md +4 -2
  14. package/docs/reference/reports.md +110 -3
  15. package/docs/reference/schema.md +16 -0
  16. package/docs/server.md +1 -1
  17. package/llms-full.txt +5 -3
  18. package/package.json +1 -1
  19. package/packages/schema/schemas/effective-delta.schema.json +82 -0
  20. package/packages/schema/schemas/effective-evidence.schema.json +82 -0
  21. package/packages/schema/schemas/evidence-page.schema.json +41 -0
  22. package/packages/schema/schemas/layer-rows.schema.json +41 -0
  23. package/packages/schema/schemas/report-run.schema.json +71 -0
  24. package/packages/schema/schemas/report-runs.schema.json +38 -1
  25. package/packages/schema/schemas/test-catalog.schema.json +41 -0
  26. package/packages/schema/schemas/test-history.schema.json +82 -0
  27. package/packages/server/openapi.json +498 -3
  28. package/packages/visualizer/src/explorer/RunsView.tsx +128 -10
  29. package/packages/visualizer/src/explorer/catalog.tsx +16 -6
  30. package/packages/visualizer/src/explorer/search.ts +4 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
+ ## 0.16.0 (2026-10-28)
4
+
5
+ ### Features
6
+
7
+ - show video availability and filter runs with recordings ([dece195](https://github.com/crvouga/atlas/commit/dece195ef419a3fc6eaedb643544be29ffb341c3))
8
+
3
9
  ## 0.15.0 (2026-10-28)
4
10
 
5
11
  ### Features
@@ -10,7 +10,7 @@
10
10
  * @see docs/report-contract.md (evidence)
11
11
  */
12
12
  import type { Annotation, Attachment, Attempt, Check, Step } from '../../schema/src/layer.js';
13
- import type { EvidenceSummary } from './evidence-types.js';
13
+ import type { EvidenceSummary, VideoCounts, VideoRef } from './evidence-types.js';
14
14
  /** Anything that may carry evidence: a layer, a part or a check. */
15
15
  export type EvidenceHolder = {
16
16
  readonly tags?: readonly string[];
@@ -34,9 +34,26 @@ export type EvidenceHolder = {
34
34
  * Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
35
35
  * attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
36
36
  * label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
37
- * name implies), sorted and distinct.
37
+ * name implies), sorted and distinct. `videos` lists every attachment whose effective media type is video, with the
38
+ * check, retry attempt or step that holds it (see {@link isVideoAttachment}).
38
39
  */
39
40
  export declare function summarizeEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
41
+ /**
42
+ * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
43
+ * part is owned by the `layer`, not by a check.
44
+ */
45
+ export declare function summarizeLayerEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
46
+ /**
47
+ * Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
48
+ * name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
49
+ * settles a file whose type nothing says (`application/octet-stream`).
50
+ */
51
+ export declare function isVideoAttachment(attachment: Pick<Attachment, 'name' | 'role' | 'contentType'>): boolean;
52
+ /**
53
+ * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
54
+ * "no video" and "nothing known about video" look the same on the wire.
55
+ */
56
+ export declare function videoCounts(videos: readonly VideoRef[] | undefined): VideoCounts | undefined;
40
57
  /** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
41
58
  export declare function roleOfType(contentType: string | undefined): string;
42
59
  /** All of one result's evidence, read when someone opens it. */
@@ -11,31 +11,48 @@ function count(holder) {
11
11
  * Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
12
12
  * attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
13
13
  * label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
14
- * name implies), sorted and distinct.
14
+ * name implies), sorted and distinct. `videos` lists every attachment whose effective media type is video, with the
15
+ * check, retry attempt or step that holds it (see {@link isVideoAttachment}).
15
16
  */
16
17
  export function summarizeEvidence(...holders) {
18
+ return summarize(holders, 'check');
19
+ }
20
+ /**
21
+ * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
22
+ * part is owned by the `layer`, not by a check.
23
+ */
24
+ export function summarizeLayerEvidence(...holders) {
25
+ return summarize(holders, 'layer');
26
+ }
27
+ function summarize(holders, owner) {
17
28
  if (!holders.some(holder => count(holder) > 0))
18
29
  return undefined;
19
30
  const roles = new Set();
20
31
  const tags = new Set();
21
32
  const artifactTags = new Set();
22
33
  const labels = {};
34
+ const videos = [];
23
35
  let attachments = 0, steps = 0, annotations = 0, attempts = 0, links = 0;
24
- const attachmentsOf = (list) => { for (const item of list ?? []) {
25
- attachments += 1;
26
- for (const tag of normalizeTags(item.tags))
27
- artifactTags.add(tag);
28
- roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
29
- } };
36
+ const attachmentsOf = (list, by, where = {}) => {
37
+ for (const item of list ?? []) {
38
+ attachments += 1;
39
+ for (const tag of normalizeTags(item.tags))
40
+ artifactTags.add(tag);
41
+ roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
42
+ const video = videoOf(item, by, where);
43
+ if (video)
44
+ videos.push(video);
45
+ }
46
+ };
47
+ const stepsOf = (list, attempt) => { for (const step of list ?? [])
48
+ attachmentsOf(step.attachments, 'step', { ...(attempt === undefined ? {} : { attempt }), step: step.name }); };
30
49
  for (const holder of holders) {
31
- attachmentsOf(holder.attachments);
50
+ attachmentsOf(holder.attachments, owner);
32
51
  // Attachments on steps and attempts are part of the check too.
33
- for (const step of holder.steps ?? [])
34
- attachmentsOf(step.attachments);
52
+ stepsOf(holder.steps);
35
53
  for (const attempt of holder.attempts ?? []) {
36
- attachmentsOf(attempt.attachments);
37
- for (const step of attempt.steps ?? [])
38
- attachmentsOf(step.attachments);
54
+ attachmentsOf(attempt.attachments, 'attempt', { attempt: attempt.index });
55
+ stepsOf(attempt.steps, attempt.index);
39
56
  }
40
57
  steps += holder.steps?.length ?? 0;
41
58
  annotations += holder.annotations?.length ?? 0;
@@ -45,7 +62,39 @@ export function summarizeEvidence(...holders) {
45
62
  tags.add(tag);
46
63
  Object.assign(labels, holder.labels);
47
64
  }
48
- return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort() };
65
+ return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort(), ...(videos.length ? { videos } : {}) };
66
+ }
67
+ /**
68
+ * Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
69
+ * name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
70
+ * settles a file whose type nothing says (`application/octet-stream`).
71
+ */
72
+ export function isVideoAttachment(attachment) {
73
+ const type = (attachment.contentType ?? contentTypeOf(attachment.name)).toLowerCase();
74
+ return type.startsWith('video/') || (attachment.role === 'video' && type.startsWith('application/octet-stream'));
75
+ }
76
+ function videoOf(item, owner, where) {
77
+ if (!isVideoAttachment(item))
78
+ return undefined;
79
+ const recording = item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
80
+ return { name: item.name, owner, ...where, ...(recording === undefined ? {} : { recording }), available: recording !== undefined };
81
+ }
82
+ /**
83
+ * Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
84
+ * "no video" and "nothing known about video" look the same on the wire.
85
+ */
86
+ export function videoCounts(videos) {
87
+ if (!videos?.length)
88
+ return undefined;
89
+ const distinct = new Set();
90
+ let unavailable = 0;
91
+ for (const video of videos) {
92
+ if (video.available && video.recording !== undefined)
93
+ distinct.add(video.recording);
94
+ else
95
+ unavailable += 1;
96
+ }
97
+ return { references: videos.length, recordings: distinct.size, unavailable };
49
98
  }
50
99
  /** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
51
100
  export function roleOfType(contentType) {
@@ -36,6 +36,35 @@ export type BackendInfo = {
36
36
  };
37
37
  /** How many results have each status (`passed`, `failed`, `running`, `not-run`). */
38
38
  export type StatusCounts = Readonly<Record<EvidenceStatus, number>>;
39
+ /** Who holds a recording: the whole layer (or one of its parts), one check, one retry attempt of a check, or one step. */
40
+ export type VideoOwner = 'layer' | 'check' | 'attempt' | 'step';
41
+ /**
42
+ * One reference to a video, found by its effective media type (the producer's `contentType`, else the one the file
43
+ * name implies; a `video` role alone counts only when nothing else says what the file is). A reference is not a
44
+ * recording: the same bytes referenced from three checks are three references and one recording.
45
+ */
46
+ export type VideoRef = {
47
+ /** The attachment's name, for a person. */
48
+ readonly name: string;
49
+ readonly owner: VideoOwner;
50
+ /** The try that holds it, when `owner` is `attempt` or the step is inside one. */
51
+ readonly attempt?: number;
52
+ /** The name of the step that holds it, when `owner` is `step`. */
53
+ readonly step?: string;
54
+ /** What identifies the bytes (digest, bundle key or address); references with the same one are the same recording. Absent when `available` is false. */
55
+ readonly recording?: string;
56
+ /** False when the reference names no bytes at all (no bundle key, address or inline text), so it can never be played. */
57
+ readonly available: boolean;
58
+ };
59
+ /** Video availability as counts: references, distinct playable recordings (deduplicated), and references that cannot be played. */
60
+ export type VideoCounts = {
61
+ /** Every reference to a video, playable or not. */
62
+ readonly references: number;
63
+ /** Distinct playable recordings: identical bytes referenced twice are one. */
64
+ readonly recordings: number;
65
+ /** References that name no bytes. A report that predates availability metadata has none of these and none of the others. */
66
+ readonly unavailable: number;
67
+ };
39
68
  /**
40
69
  * What a result (or a run) carries beyond its outcome, as counts and short facts: attachments, steps,
41
70
  * annotations, retries, tags, labels, links. It is only there when there is something. The evidence itself
@@ -55,6 +84,8 @@ export type EvidenceSummary = {
55
84
  readonly labels: Readonly<Record<string, string>>;
56
85
  /** The distinct roles of its attachments (or what their media types say), to show what kind of evidence there is. */
57
86
  readonly roles: readonly string[];
87
+ /** Its references to videos, wherever they are held (layer, check, attempt, step). Only there when it has any. */
88
+ readonly videos?: readonly VideoRef[];
58
89
  };
59
90
  /** A report layer's origin and totals. Its checks are read separately, once. */
60
91
  export type LayerHeader = {
@@ -1,4 +1,4 @@
1
- import { summarizeEvidence } from './evidence-detail.js';
1
+ import { summarizeEvidence, summarizeLayerEvidence } from './evidence-detail.js';
2
2
  import { layerPlace } from './layer-axes.js';
3
3
  /**
4
4
  * A short, deterministic fingerprint of a spec's text, shaped `<length>:<hash>:<hash>` (the length in UTF-16 code
@@ -94,7 +94,7 @@ export function layerEvidence(summary, backend) {
94
94
  }
95
95
  }
96
96
  const recording = summary.recording;
97
- const layerSummaryEvidence = summarizeEvidence(summary, ...summary.parts);
97
+ const layerSummaryEvidence = summarizeLayerEvidence(summary, ...summary.parts);
98
98
  const layer = {
99
99
  ...(summary.freshness ? { freshness: summary.freshness } : {}),
100
100
  ...(summary.run ? { run: summary.run } : {}),
@@ -1,5 +1,5 @@
1
1
  import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
2
- import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts } from './evidence-types.js';
2
+ import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts, VideoCounts, VideoRef } from './evidence-types.js';
3
3
  import { RecordedSpecs } from './recorded-specs.js';
4
4
  /** The version of the documents this module writes (`TestCatalog`, `TestHistoryDocument`, `RunList`, `RunDetail`). Only optional fields are added within it. */
5
5
  export declare const TEST_CATALOG_VERSION = 1;
@@ -258,13 +258,26 @@ export type RunSummary = {
258
258
  readonly environment: string | null;
259
259
  readonly detail: string;
260
260
  }[];
261
+ /**
262
+ * Its videos, from the run's layers and from the last result of each test: references, distinct playable recordings
263
+ * and references that cannot be played. Absent when it has none, and in a report that carries no video information.
264
+ */
265
+ readonly videos?: VideoCounts;
266
+ };
267
+ /** What `runList` can be asked. */
268
+ export type RunQuery = {
269
+ /** Only runs that have at least one playable video recording. `total` then counts only those. */
270
+ readonly video?: boolean;
261
271
  };
262
272
  /** `GET /api/reports/runs`: runs, newest first. */
263
273
  export type RunList = {
264
274
  readonly schemaVersion: typeof TEST_CATALOG_VERSION;
265
275
  readonly version: string;
276
+ /** Runs that matched, before `limit`. */
266
277
  readonly total: number;
267
278
  readonly runs: readonly RunSummary[];
279
+ /** How many of all runs (whatever was asked, before `limit`) have a playable video recording. */
280
+ readonly withVideo?: number;
268
281
  };
269
282
  /** One test's last result within one run. */
270
283
  export type RunResult = {
@@ -279,6 +292,8 @@ export type RunResult = {
279
292
  readonly at: number;
280
293
  readonly layer: string;
281
294
  readonly detail: string;
295
+ /** Every video its evidence references, with who holds it (check, retry attempt or step). Absent when there is none. */
296
+ readonly videos?: readonly VideoRef[];
282
297
  };
283
298
  /** `GET /api/reports/runs/detail`: one run and every result it recorded. */
284
299
  export type RunDetail = {
@@ -380,8 +395,14 @@ export declare function matchesQuery(model: CatalogModel, test: TestSummary, que
380
395
  export declare function testCatalog(model: CatalogModel, query?: TestQuery): TestCatalog;
381
396
  /** One test and its recorded results, or null when the report has no test with that key. */
382
397
  export declare function testHistory(model: CatalogModel, key: string): TestHistoryDocument | null;
383
- /** Every run the history knows of, newest first, `limit` at most (default 100). */
384
- export declare function runList(history: TestHistory, version: string, limit?: number): RunList;
398
+ /** Whether a run has at least one playable video recording. */
399
+ export declare function hasVideo(run: Pick<RunSummary, 'videos'>): boolean;
400
+ /**
401
+ * Every run the history knows of, newest first, `limit` at most (default 100). With `query.video` only the runs that
402
+ * have a playable video recording: the filter and `total` apply to every run before `limit`, and nothing is read from
403
+ * any recording's bytes. `withVideo` always counts every run that has one.
404
+ */
405
+ export declare function runList(history: TestHistory, version: string, limit?: number, query?: RunQuery): RunList;
385
406
  /** One run with every result it recorded, or null when no run has that key. */
386
407
  export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
387
408
  /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
@@ -21,6 +21,7 @@
21
21
  * @see docs/server.md#reading-the-report-as-an-agent
22
22
  */
23
23
  import { displayTag, normalizeTag, normalizeTags } from './artifact-tags.js';
24
+ import { videoCounts } from './evidence-detail.js';
24
25
  import { checkSlots } from './evidence-view.js';
25
26
  import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
26
27
  import { RecordedSpecs } from './recorded-specs.js';
@@ -126,7 +127,7 @@ export class TestHistory {
126
127
  const key = runKey(layer);
127
128
  let run = this.#runs.get(key);
128
129
  if (!run) {
129
- 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 };
130
+ 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: [] };
130
131
  this.#runs.set(key, run);
131
132
  }
132
133
  // A run is told by its first layer (by time, then id), whatever order its layers arrive in.
@@ -153,6 +154,8 @@ export class TestHistory {
153
154
  if (layer.environment)
154
155
  run.environments.add(layer.environment);
155
156
  run.layers += 1;
157
+ if (layer.evidence?.videos)
158
+ run.videos.push(...layer.evidence.videos);
156
159
  const origin = originOf(layer, backend);
157
160
  for (const row of rows) {
158
161
  // "Still running" is progress, not history: a test running now shows it as its current outcome.
@@ -240,6 +243,7 @@ export class TestHistory {
240
243
  const results = cells.map(({ i, r }) => ({
241
244
  key: i.slot, id: i.id, title: i.title, specPath: i.specPath, environment: r.o.environment, producer: r.o.producer,
242
245
  status: r.s, durationMs: r.d, at: r.o.at, layer: r.o.layer, detail: shortDetail(r.t),
246
+ ...(r.e?.videos ? { videos: r.e.videos } : {}),
243
247
  }));
244
248
  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));
245
249
  return { run: runSummary(run, cells), results };
@@ -254,7 +258,10 @@ function runSummary(run, cells) {
254
258
  const tests = new Set();
255
259
  let total = 0, measured = false;
256
260
  const failed = [];
261
+ const videos = [...run.videos];
257
262
  for (const cell of cells) {
263
+ if (cell.r.e?.videos)
264
+ videos.push(...cell.r.e.videos);
258
265
  counts[cell.r.s] += 1;
259
266
  tests.add(cell.i.slot);
260
267
  if (cell.r.d !== null) {
@@ -265,6 +272,7 @@ function runSummary(run, cells) {
265
272
  failed.push(cell);
266
273
  }
267
274
  failed.sort((a, b) => compareText(a.i.slot, b.i.slot) || compareText(a.r.o.place, b.r.o.place) || compareText(a.r.k, b.r.k));
275
+ const video = videoCounts(videos);
268
276
  const failing = failed.slice(0, 20).map(cell => ({ key: cell.i.slot, title: cell.i.title, environment: cell.r.o.environment, detail: (cell.r.t.split('\n')[0] ?? '').slice(0, 500) }));
269
277
  return {
270
278
  key: run.key, ...(run.runId ? { runId: run.runId } : {}), ...(run.title ? { title: run.title } : {}), ...(run.freshness ? { freshness: run.freshness } : {}),
@@ -272,6 +280,7 @@ function runSummary(run, cells) {
272
280
  startedAtMs: run.start, endedAtMs: run.end, spanMs: run.end - run.start,
273
281
  producers: [...run.producers].sort(), environments: [...run.environments].sort(),
274
282
  layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
283
+ ...(video ? { videos: video } : {}),
275
284
  };
276
285
  }
277
286
  function compareText(a, b) {
@@ -571,10 +580,20 @@ export function testHistory(model, key) {
571
580
  return null;
572
581
  return { schemaVersion: TEST_CATALOG_VERSION, version: model.version, test, history: model.history?.of(key) ?? test.outcomes };
573
582
  }
574
- /** Every run the history knows of, newest first, `limit` at most (default 100). */
575
- export function runList(history, version, limit = 100) {
576
- const runs = history.runs();
577
- return { schemaVersion: TEST_CATALOG_VERSION, version, total: runs.length, runs: runs.slice(0, Math.max(0, limit)) };
583
+ /** Whether a run has at least one playable video recording. */
584
+ export function hasVideo(run) {
585
+ return (run.videos?.recordings ?? 0) > 0;
586
+ }
587
+ /**
588
+ * Every run the history knows of, newest first, `limit` at most (default 100). With `query.video` only the runs that
589
+ * have a playable video recording: the filter and `total` apply to every run before `limit`, and nothing is read from
590
+ * any recording's bytes. `withVideo` always counts every run that has one.
591
+ */
592
+ export function runList(history, version, limit = 100, query = {}) {
593
+ const all = history.runs();
594
+ const withVideo = all.filter(hasVideo).length;
595
+ const runs = query.video ? all.filter(hasVideo) : all;
596
+ return { schemaVersion: TEST_CATALOG_VERSION, version, total: runs.length, runs: runs.slice(0, Math.max(0, limit)), withVideo };
578
597
  }
579
598
  /** One run with every result it recorded, or null when no run has that key. */
580
599
  export function runDetail(history, version, key) {
@@ -687,6 +706,12 @@ export function historyMarkdown(document) {
687
706
  ...document.history.map(e => `| ${iso(e.at)} | ${e.environment ?? '—'} | ${e.status} | ${formatMs(e.durationMs)} | ${cell(e.branch ?? '—')} | ${e.sha?.slice(0, 10) ?? '—'} | ${cell(e.detail.split('\n')[0] ?? '')} |`),
688
707
  ].join('\n')}\n`;
689
708
  }
709
+ /** Video counts in a few words: `2 (3 references)`, with how many cannot be played, or `—` when there are none. */
710
+ function videosText(videos) {
711
+ if (!videos)
712
+ return '—';
713
+ return `${videos.recordings}${videos.references !== videos.recordings ? ` (${videos.references} references)` : ''}${videos.unavailable ? `, ${videos.unavailable} unavailable` : ''}`;
714
+ }
690
715
  /** What to say beside a commit that was not recorded from a clean checkout: nothing, ` (modified)` or ` (origin unknown)`. */
691
716
  function freshnessNote(freshness) {
692
717
  return freshness === 'modified' ? ' (modified: local changes)' : freshness === 'unknown' ? ' (origin unknown)' : '';
@@ -696,9 +721,9 @@ export function runsMarkdown(list) {
696
721
  return `${[
697
722
  `# Runs (${list.runs.length} of ${list.total})`,
698
723
  '',
699
- '| Ended | Branch | Commit | Environments | Tests | Passed | Failed | Not run | Span | Run |',
700
- '|---|---|---|---|---:|---:|---:|---:|---:|---|',
701
- ...list.runs.map(r => `| ${iso(r.endedAtMs)} | ${cell(r.branch ?? '—')} | ${r.sha?.slice(0, 10) ?? '—'}${freshnessNote(r.freshness)} | ${r.environments.join(', ') || '—'} | ${r.tests} | ${r.counts.passed} | ${r.counts.failed} | ${r.counts['not-run']} | ${formatMs(r.spanMs)} | ${cell(r.title ? `${r.title} (${r.key})` : r.key)} |`),
724
+ '| Ended | Branch | Commit | Environments | Tests | Passed | Failed | Not run | Span | Videos | Run |',
725
+ '|---|---|---|---|---:|---:|---:|---:|---:|---|---|',
726
+ ...list.runs.map(r => `| ${iso(r.endedAtMs)} | ${cell(r.branch ?? '—')} | ${r.sha?.slice(0, 10) ?? '—'}${freshnessNote(r.freshness)} | ${r.environments.join(', ') || '—'} | ${r.tests} | ${r.counts.passed} | ${r.counts.failed} | ${r.counts['not-run']} | ${formatMs(r.spanMs)} | ${videosText(r.videos)} | ${cell(r.title ? `${r.title} (${r.key})` : r.key)} |`),
702
727
  ].join('\n')}\n`;
703
728
  }
704
729
  /** One run as Markdown. */
@@ -712,9 +737,10 @@ export function runMarkdown(detail) {
712
737
  `- ${iso(run.startedAtMs)} to ${iso(run.endedAtMs)} (${formatMs(run.spanMs)}); ${run.layers} layers from ${run.producers.join(', ')}`,
713
738
  `- Environments: ${run.environments.join(', ') || '—'}`,
714
739
  `- ${run.tests} tests: ${counted(run.counts)}${run.totalDurationMs === null ? '' : `; ${formatMs(run.totalDurationMs)} of test time`}`,
740
+ ...(run.videos ? [`- Videos: ${videosText(run.videos)}`] : []),
715
741
  '',
716
- '| Status | Test | Spec | Environment | Duration | Detail |',
717
- '|---|---|---|---|---:|---|',
718
- ...detail.results.map(r => `| ${r.status} | ${cell(r.title)} | ${cell(r.specPath ?? '—')} | ${r.environment ?? '—'} | ${formatMs(r.durationMs)} | ${cell(r.detail.split('\n')[0] ?? '')} |`),
742
+ '| Status | Test | Spec | Environment | Duration | Videos | Detail |',
743
+ '|---|---|---|---|---:|---|---|',
744
+ ...detail.results.map(r => `| ${r.status} | ${cell(r.title)} | ${cell(r.specPath ?? '—')} | ${r.environment ?? '—'} | ${formatMs(r.durationMs)} | ${videosText(videoCounts(r.videos))} | ${cell(r.detail.split('\n')[0] ?? '')} |`),
719
745
  ].join('\n')}\n`;
720
746
  }