@crvouga/atlas 0.14.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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/artifact-tag-store.d.ts +20 -0
  3. package/dist/reports/src/artifact-tag-store.js +70 -0
  4. package/dist/reports/src/artifact-tags.d.ts +96 -0
  5. package/dist/reports/src/artifact-tags.js +96 -0
  6. package/dist/reports/src/evidence-detail.d.ts +20 -3
  7. package/dist/reports/src/evidence-detail.js +66 -13
  8. package/dist/reports/src/evidence-types.d.ts +33 -0
  9. package/dist/reports/src/layer-merge.js +2 -2
  10. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  11. package/dist/reports/src/reports-route.d.ts +8 -0
  12. package/dist/reports/src/test-catalog.d.ts +35 -26
  13. package/dist/reports/src/test-catalog.js +79 -15
  14. package/dist/schema/src/api.d.ts +246 -0
  15. package/dist/schema/src/api.js +59 -5
  16. package/dist/schema/src/layer.d.ts +33 -0
  17. package/dist/schema/src/layer.js +11 -1
  18. package/dist/server/src/app.js +35 -3
  19. package/dist/server/src/llms.js +2 -1
  20. package/dist/server/src/openapi.d.ts +1 -1
  21. package/dist/server/src/openapi.js +20 -3
  22. package/docs/extending.md +3 -0
  23. package/docs/reference/http-api.md +52 -2
  24. package/docs/reference/reports.md +327 -5
  25. package/docs/reference/schema.md +48 -0
  26. package/docs/rich-evidence.md +28 -0
  27. package/docs/server.md +25 -1
  28. package/llms-full.txt +108 -3
  29. package/package.json +1 -1
  30. package/packages/schema/schemas/check-detail.schema.json +28 -0
  31. package/packages/schema/schemas/effective-delta.schema.json +96 -0
  32. package/packages/schema/schemas/effective-evidence.schema.json +96 -0
  33. package/packages/schema/schemas/evidence-page.schema.json +48 -0
  34. package/packages/schema/schemas/layer-rows.schema.json +48 -0
  35. package/packages/schema/schemas/layer.schema.json +70 -0
  36. package/packages/schema/schemas/publish-checks.schema.json +35 -0
  37. package/packages/schema/schemas/report-run.schema.json +71 -0
  38. package/packages/schema/schemas/report-runs.schema.json +38 -1
  39. package/packages/schema/schemas/test-catalog.schema.json +83 -0
  40. package/packages/schema/schemas/test-history.schema.json +110 -0
  41. package/packages/server/openapi.json +1259 -33
  42. package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
  43. package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
  44. package/packages/visualizer/src/explorer/RunsView.tsx +128 -10
  45. package/packages/visualizer/src/explorer/catalog.tsx +16 -6
  46. package/packages/visualizer/src/explorer/search.ts +4 -1
@@ -1,27 +1,5 @@
1
- /**
2
- * @module Test catalog, history and runs
3
- *
4
- * What a person or an agent asks of a report once it holds more than a handful of checks: every test with where it ran,
5
- * how it went over time and how long it takes; the specs, domains, environments and producers they fall under; the CI
6
- * runs that produced them and which tests each one ran.
7
- *
8
- * - `TestHistory` keeps every recorded result of every test (bounded per test) and every run's results, folded in one
9
- * layer at a time, in any order, with the same outcome. It never decides a status: the current status of a test is
10
- * always the report's winner (`combineEvidence`), and history only describes how it got there.
11
- * - `testCatalog` turns the report's winners (plus a history, when there is one) into a `TestCatalog`: a summary with
12
- * insights (slowest, flaky, failing), facets to filter by, and the tests that match a `TestQuery`.
13
- * - `testHistory`, `runList` and `runDetail` answer for one test, every run, and one run.
14
- * - `catalogMarkdown`, `historyMarkdown`, `runsMarkdown` and `runMarkdown` say the same in Markdown, for an agent or a
15
- * terminal.
16
- *
17
- * A test is a check identity (its spec path and id) regardless of producer, environment and parameters; each of those
18
- * combinations is one of its results. A result that is not `passed` is never counted as a pass, and a test with no
19
- * completed result has no pass rate.
20
- *
21
- * @see docs/server.md#reading-the-report-as-an-agent
22
- */
23
1
  import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
24
- 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';
25
3
  import { RecordedSpecs } from './recorded-specs.js';
26
4
  /** The version of the documents this module writes (`TestCatalog`, `TestHistoryDocument`, `RunList`, `RunDetail`). Only optional fields are added within it. */
27
5
  export declare const TEST_CATALOG_VERSION = 1;
@@ -116,6 +94,10 @@ export type TestSummary = {
116
94
  readonly outcomes: readonly TestOutcome[];
117
95
  readonly stats: TestStats;
118
96
  readonly tags: readonly string[];
97
+ /** The normalized tags of the attachments of its current results (`demo`, shown as `#demo`). Never inherited from a check or run; the `tags` above are. Absent in documents of older servers. */
98
+ readonly artifactTags?: readonly string[];
99
+ /** The normalized tags its attachments inherit from their check and run (those of the results that have attachments), apart from `artifactTags`. What an inherited artifact query also matches. */
100
+ readonly inheritedArtifactTags?: readonly string[];
119
101
  readonly labels: Readonly<Record<string, string>>;
120
102
  /** What its current results carry: attachments, steps, retries, and the kinds of attachment. */
121
103
  readonly evidence: {
@@ -187,6 +169,8 @@ export type CatalogFacets = {
187
169
  readonly counts: StatusCounts;
188
170
  })[];
189
171
  readonly tags: readonly FacetValue[];
172
+ /** The tags of attachments, normalized. Absent in documents of older servers. */
173
+ readonly artifactTags?: readonly FacetValue[];
190
174
  readonly branches: readonly FacetValue[];
191
175
  readonly labels: Readonly<Record<string, readonly FacetValue[]>>;
192
176
  };
@@ -206,6 +190,10 @@ export type TestQuery = {
206
190
  /** Spec paths (`specs/...`). */
207
191
  readonly spec?: readonly string[];
208
192
  readonly tag?: readonly string[];
193
+ /** Only tests with an attachment carrying this tag (`demo` and `#demo` are the same), not counting tags inherited from a check or run: the exact artifact query. `tag` is the inherited one. */
194
+ readonly artifactTag?: readonly string[];
195
+ /** With `artifactTag`: also match the tags an attachment inherits from its check and run (a recording without a tag of its own under a run tagged `demo`). Default false: the exact query. */
196
+ readonly inherited?: boolean;
209
197
  /** Only tests recorded in this run (a key from `runList`). */
210
198
  readonly run?: string;
211
199
  readonly sort?: TestSort;
@@ -270,13 +258,26 @@ export type RunSummary = {
270
258
  readonly environment: string | null;
271
259
  readonly detail: string;
272
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;
273
271
  };
274
272
  /** `GET /api/reports/runs`: runs, newest first. */
275
273
  export type RunList = {
276
274
  readonly schemaVersion: typeof TEST_CATALOG_VERSION;
277
275
  readonly version: string;
276
+ /** Runs that matched, before `limit`. */
278
277
  readonly total: number;
279
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;
280
281
  };
281
282
  /** One test's last result within one run. */
282
283
  export type RunResult = {
@@ -291,6 +292,8 @@ export type RunResult = {
291
292
  readonly at: number;
292
293
  readonly layer: string;
293
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[];
294
297
  };
295
298
  /** `GET /api/reports/runs/detail`: one run and every result it recorded. */
296
299
  export type RunDetail = {
@@ -392,11 +395,17 @@ export declare function matchesQuery(model: CatalogModel, test: TestSummary, que
392
395
  export declare function testCatalog(model: CatalogModel, query?: TestQuery): TestCatalog;
393
396
  /** One test and its recorded results, or null when the report has no test with that key. */
394
397
  export declare function testHistory(model: CatalogModel, key: string): TestHistoryDocument | null;
395
- /** Every run the history knows of, newest first, `limit` at most (default 100). */
396
- 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;
397
406
  /** One run with every result it recorded, or null when no run has that key. */
398
407
  export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
399
- /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`. */
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`. */
400
409
  export declare function parseTestQuery(params: URLSearchParams): TestQuery;
401
410
  /** A duration for a person: `850 ms`, `12.3 s`, `4m 05s`. */
402
411
  export declare function formatMs(ms: number | null | undefined): string;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * @module Test catalog, history and runs
3
+ *
4
+ * What a person or an agent asks of a report once it holds more than a handful of checks: every test with where it ran,
5
+ * how it went over time and how long it takes; the specs, domains, environments and producers they fall under; the CI
6
+ * runs that produced them and which tests each one ran.
7
+ *
8
+ * - `TestHistory` keeps every recorded result of every test (bounded per test) and every run's results, folded in one
9
+ * layer at a time, in any order, with the same outcome. It never decides a status: the current status of a test is
10
+ * always the report's winner (`combineEvidence`), and history only describes how it got there.
11
+ * - `testCatalog` turns the report's winners (plus a history, when there is one) into a `TestCatalog`: a summary with
12
+ * insights (slowest, flaky, failing), facets to filter by, and the tests that match a `TestQuery`.
13
+ * - `testHistory`, `runList` and `runDetail` answer for one test, every run, and one run.
14
+ * - `catalogMarkdown`, `historyMarkdown`, `runsMarkdown` and `runMarkdown` say the same in Markdown, for an agent or a
15
+ * terminal.
16
+ *
17
+ * A test is a check identity (its spec path and id) regardless of producer, environment and parameters; each of those
18
+ * combinations is one of its results. A result that is not `passed` is never counted as a pass, and a test with no
19
+ * completed result has no pass rate.
20
+ *
21
+ * @see docs/server.md#reading-the-report-as-an-agent
22
+ */
23
+ import { displayTag, normalizeTag, normalizeTags } from './artifact-tags.js';
24
+ import { videoCounts } from './evidence-detail.js';
1
25
  import { checkSlots } from './evidence-view.js';
2
26
  import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
3
27
  import { RecordedSpecs } from './recorded-specs.js';
@@ -103,7 +127,7 @@ export class TestHistory {
103
127
  const key = runKey(layer);
104
128
  let run = this.#runs.get(key);
105
129
  if (!run) {
106
- 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: [] };
107
131
  this.#runs.set(key, run);
108
132
  }
109
133
  // A run is told by its first layer (by time, then id), whatever order its layers arrive in.
@@ -130,6 +154,8 @@ export class TestHistory {
130
154
  if (layer.environment)
131
155
  run.environments.add(layer.environment);
132
156
  run.layers += 1;
157
+ if (layer.evidence?.videos)
158
+ run.videos.push(...layer.evidence.videos);
133
159
  const origin = originOf(layer, backend);
134
160
  for (const row of rows) {
135
161
  // "Still running" is progress, not history: a test running now shows it as its current outcome.
@@ -217,6 +243,7 @@ export class TestHistory {
217
243
  const results = cells.map(({ i, r }) => ({
218
244
  key: i.slot, id: i.id, title: i.title, specPath: i.specPath, environment: r.o.environment, producer: r.o.producer,
219
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 } : {}),
220
247
  }));
221
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));
222
249
  return { run: runSummary(run, cells), results };
@@ -231,7 +258,10 @@ function runSummary(run, cells) {
231
258
  const tests = new Set();
232
259
  let total = 0, measured = false;
233
260
  const failed = [];
261
+ const videos = [...run.videos];
234
262
  for (const cell of cells) {
263
+ if (cell.r.e?.videos)
264
+ videos.push(...cell.r.e.videos);
235
265
  counts[cell.r.s] += 1;
236
266
  tests.add(cell.i.slot);
237
267
  if (cell.r.d !== null) {
@@ -242,6 +272,7 @@ function runSummary(run, cells) {
242
272
  failed.push(cell);
243
273
  }
244
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);
245
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) }));
246
277
  return {
247
278
  key: run.key, ...(run.runId ? { runId: run.runId } : {}), ...(run.title ? { title: run.title } : {}), ...(run.freshness ? { freshness: run.freshness } : {}),
@@ -249,6 +280,7 @@ function runSummary(run, cells) {
249
280
  startedAtMs: run.start, endedAtMs: run.end, spanMs: run.end - run.start,
250
281
  producers: [...run.producers].sort(), environments: [...run.environments].sort(),
251
282
  layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
283
+ ...(video ? { videos: video } : {}),
252
284
  };
253
285
  }
254
286
  function compareText(a, b) {
@@ -337,15 +369,22 @@ function outcomeOf(result) {
337
369
  function summaryOf(slot, history, known) {
338
370
  const outcomes = slot.outcomes.map(outcomeOf);
339
371
  const tags = new Set();
372
+ const artifactTags = new Set();
373
+ const inheritedTags = new Set();
340
374
  const labels = {};
341
375
  let attachments = 0, steps = 0, attempts = 0;
342
376
  const roles = new Set();
343
- for (const outcome of outcomes) {
377
+ for (const [at, outcome] of outcomes.entries()) {
344
378
  const evidence = outcome.evidence;
345
379
  if (!evidence)
346
380
  continue;
381
+ if (evidence.attachments > 0)
382
+ for (const tag of normalizeTags([...evidence.tags, ...(slot.outcomes[at]?.layer.evidence?.tags ?? [])]))
383
+ inheritedTags.add(tag);
347
384
  for (const tag of evidence.tags)
348
385
  tags.add(tag);
386
+ for (const tag of evidence.artifactTags ?? [])
387
+ artifactTags.add(tag);
349
388
  Object.assign(labels, evidence.labels);
350
389
  attachments += evidence.attachments;
351
390
  steps += evidence.steps;
@@ -366,6 +405,8 @@ function summaryOf(slot, history, known) {
366
405
  outcomes,
367
406
  stats: statsOf(history?.of(slot.key) ?? [], outcomes, tagList, labels),
368
407
  tags: tagList,
408
+ ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}),
409
+ ...([...inheritedTags].some(tag => !artifactTags.has(tag)) ? { inheritedArtifactTags: [...inheritedTags].filter(tag => !artifactTags.has(tag)).sort() } : {}),
369
410
  labels,
370
411
  evidence: { attachments, steps, attempts, roles: [...roles].sort() },
371
412
  lastRecordedAtMs: slot.latestAtMs,
@@ -375,7 +416,7 @@ function summaryOf(slot, history, known) {
375
416
  /** The lowercase text a search matches a test against: title, id, spec, domain, tags, labels, environments and producers. */
376
417
  export function haystackOf(test) {
377
418
  return [
378
- test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags,
419
+ test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags, ...(test.artifactTags ?? []).map(displayTag),
379
420
  ...Object.entries(test.labels).flatMap(([key, value]) => [key, value]), ...test.environments, ...test.producers,
380
421
  ].join('\n').toLowerCase();
381
422
  }
@@ -481,6 +522,7 @@ export function catalogModel(layers, sources = {}) {
481
522
  domains: facet(tests, test => [test.spec?.domain || '(no spec)']),
482
523
  specs: [...specGroups].map(([, list]) => { const g = group(list[0].spec.path, list); return { ...list[0].spec, tests: g.tests, counts: g.counts }; }).sort((a, b) => compareText(a.domain, b.domain) || compareText(a.title, b.title)),
483
524
  tags: facet(tests, test => test.tags),
525
+ artifactTags: facet(tests, test => test.artifactTags ?? []),
484
526
  branches: facet(tests, test => test.stats.branches),
485
527
  labels: Object.fromEntries([...labelKeys].sort().map(key => [key, facet(tests, test => (test.labels[key] === undefined ? [] : [test.labels[key]]))])),
486
528
  };
@@ -515,6 +557,8 @@ export function matchesQuery(model, test, query, inRun) {
515
557
  return false;
516
558
  if (query.tag?.length && !query.tag.some(tag => test.tags.includes(tag)))
517
559
  return false;
560
+ if (query.artifactTag?.length && !query.artifactTag.some(tag => { const wanted = normalizeTag(tag); return wanted !== null && ((test.artifactTags?.includes(wanted) ?? false) || (query.inherited === true && (test.inheritedArtifactTags?.includes(wanted) ?? false))); }))
561
+ return false;
518
562
  if (inRun && !inRun.has(test.key))
519
563
  return false;
520
564
  return true;
@@ -536,17 +580,27 @@ export function testHistory(model, key) {
536
580
  return null;
537
581
  return { schemaVersion: TEST_CATALOG_VERSION, version: model.version, test, history: model.history?.of(key) ?? test.outcomes };
538
582
  }
539
- /** Every run the history knows of, newest first, `limit` at most (default 100). */
540
- export function runList(history, version, limit = 100) {
541
- const runs = history.runs();
542
- 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 };
543
597
  }
544
598
  /** One run with every result it recorded, or null when no run has that key. */
545
599
  export function runDetail(history, version, key) {
546
600
  const found = history.run(key);
547
601
  return found ? { schemaVersion: TEST_CATALOG_VERSION, version, ...found } : null;
548
602
  }
549
- /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`. */
603
+ /** 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`. */
550
604
  export function parseTestQuery(params) {
551
605
  const many = (name) => params.getAll(name).flatMap(value => value.split(',')).map(value => value.trim()).filter(Boolean);
552
606
  const number = (name) => { const value = params.get(name); return value !== null && /^\d+$/.test(value) ? Number(value) : undefined; };
@@ -555,11 +609,13 @@ export function parseTestQuery(params) {
555
609
  const q = params.get('q');
556
610
  if (q)
557
611
  query.q = q;
558
- for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag']) {
612
+ for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag', 'artifactTag']) {
559
613
  const values = many(name);
560
614
  if (values.length)
561
615
  query[name] = values;
562
616
  }
617
+ if (['1', 'true'].includes(params.get('inherited') ?? ''))
618
+ query.inherited = true;
563
619
  const run = params.get('run');
564
620
  if (run)
565
621
  query.run = run;
@@ -633,6 +689,7 @@ export function historyMarkdown(document) {
633
689
  `- Results: ${test.stats.results} recorded, ${test.stats.passed} passed, ${test.stats.failed} failed${test.stats.passRate === null ? '' : `, pass rate ${Math.round(test.stats.passRate * 100)}%`}, in ${test.stats.runs} runs`,
634
690
  `- Duration: ${d ? `avg ${formatMs(d.avgMs)}, p50 ${formatMs(d.p50Ms)}, p95 ${formatMs(d.p95Ms)}, min ${formatMs(d.minMs)}, max ${formatMs(d.maxMs)}, last ${formatMs(d.lastMs)}` : 'not measured'}`,
635
691
  ...(test.tags.length ? [`- Tags: ${test.tags.join(', ')}`] : []),
692
+ ...(test.artifactTags?.length ? [`- Artifact tags: ${test.artifactTags.map(displayTag).join(', ')}`] : []),
636
693
  ...(Object.keys(test.labels).length ? [`- Labels: ${Object.entries(test.labels).map(([k, v]) => `${k}=${v}`).join(', ')}`] : []),
637
694
  `- Evidence: ${test.evidence.attachments} attachments${test.evidence.roles.length ? ` (${test.evidence.roles.join(', ')})` : ''}, ${test.evidence.steps} steps`,
638
695
  '',
@@ -649,6 +706,12 @@ export function historyMarkdown(document) {
649
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] ?? '')} |`),
650
707
  ].join('\n')}\n`;
651
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
+ }
652
715
  /** What to say beside a commit that was not recorded from a clean checkout: nothing, ` (modified)` or ` (origin unknown)`. */
653
716
  function freshnessNote(freshness) {
654
717
  return freshness === 'modified' ? ' (modified: local changes)' : freshness === 'unknown' ? ' (origin unknown)' : '';
@@ -658,9 +721,9 @@ export function runsMarkdown(list) {
658
721
  return `${[
659
722
  `# Runs (${list.runs.length} of ${list.total})`,
660
723
  '',
661
- '| Ended | Branch | Commit | Environments | Tests | Passed | Failed | Not run | Span | Run |',
662
- '|---|---|---|---|---:|---:|---:|---:|---:|---|',
663
- ...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)} |`),
664
727
  ].join('\n')}\n`;
665
728
  }
666
729
  /** One run as Markdown. */
@@ -674,9 +737,10 @@ export function runMarkdown(detail) {
674
737
  `- ${iso(run.startedAtMs)} to ${iso(run.endedAtMs)} (${formatMs(run.spanMs)}); ${run.layers} layers from ${run.producers.join(', ')}`,
675
738
  `- Environments: ${run.environments.join(', ') || '—'}`,
676
739
  `- ${run.tests} tests: ${counted(run.counts)}${run.totalDurationMs === null ? '' : `; ${formatMs(run.totalDurationMs)} of test time`}`,
740
+ ...(run.videos ? [`- Videos: ${videosText(run.videos)}`] : []),
677
741
  '',
678
- '| Status | Test | Spec | Environment | Duration | Detail |',
679
- '|---|---|---|---|---:|---|',
680
- ...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] ?? '')} |`),
681
745
  ].join('\n')}\n`;
682
746
  }