@crvouga/atlas 1.1.0 → 1.3.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 (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/artifact-query.d.ts +123 -0
  3. package/dist/reports/src/artifact-query.js +234 -0
  4. package/dist/reports/src/evidence-detail.d.ts +14 -3
  5. package/dist/reports/src/evidence-detail.js +34 -19
  6. package/dist/reports/src/evidence-scope.d.ts +83 -0
  7. package/dist/reports/src/evidence-scope.js +129 -0
  8. package/dist/reports/src/evidence-types.d.ts +5 -1
  9. package/dist/reports/src/explorer-types.d.ts +1 -1
  10. package/dist/reports/src/layer-merge.js +3 -2
  11. package/dist/reports/src/layer-types.d.ts +1 -1
  12. package/dist/reports/src/publish-checks.js +1 -0
  13. package/dist/reports/src/reporter.d.ts +1 -1
  14. package/dist/reports/src/reports-explorer-route.d.ts +276 -0
  15. package/dist/reports/src/reports-route.d.ts +276 -0
  16. package/dist/reports/src/test-catalog.d.ts +108 -6
  17. package/dist/reports/src/test-catalog.js +155 -8
  18. package/dist/schema/src/api.d.ts +745 -0
  19. package/dist/schema/src/api.js +48 -6
  20. package/dist/schema/src/layer.d.ts +981 -0
  21. package/dist/schema/src/layer.js +43 -4
  22. package/dist/server/src/app.js +16 -5
  23. package/dist/server/src/openapi.js +42 -3
  24. package/docs/extending.md +1 -0
  25. package/docs/reference/http-api.md +86 -2
  26. package/docs/reference/reports.md +550 -14
  27. package/docs/reference/schema.md +55 -0
  28. package/docs/rich-evidence.md +78 -0
  29. package/docs/server.md +4 -1
  30. package/docs/ui.md +1 -1
  31. package/llms-full.txt +170 -4
  32. package/package.json +1 -1
  33. package/packages/schema/schemas/check-detail.schema.json +673 -1
  34. package/packages/schema/schemas/effective-delta.schema.json +206 -0
  35. package/packages/schema/schemas/effective-evidence.schema.json +206 -0
  36. package/packages/schema/schemas/evidence-page.schema.json +103 -0
  37. package/packages/schema/schemas/layer-rows.schema.json +103 -0
  38. package/packages/schema/schemas/layer.schema.json +1598 -62
  39. package/packages/schema/schemas/publish-checks.schema.json +768 -0
  40. package/packages/schema/schemas/report-run.schema.json +131 -0
  41. package/packages/schema/schemas/report-runs.schema.json +221 -0
  42. package/packages/schema/schemas/test-catalog.schema.json +135 -0
  43. package/packages/schema/schemas/test-history.schema.json +216 -0
  44. package/packages/server/openapi.json +4731 -735
  45. package/packages/visualizer/src/evidence/artifacts.tsx +24 -18
  46. package/packages/visualizer/src/evidence/evidence-scope.tsx +106 -0
  47. package/packages/visualizer/src/evidence/format.ts +11 -0
  48. package/packages/visualizer/src/explorer/ArtifactsView.tsx +162 -0
  49. package/packages/visualizer/src/explorer/Explorer.tsx +47 -3
  50. package/packages/visualizer/src/explorer/RunsView.tsx +43 -19
  51. package/packages/visualizer/src/explorer/catalog.tsx +97 -12
  52. package/packages/visualizer/src/explorer/filters.tsx +279 -0
  53. package/packages/visualizer/src/explorer/search.ts +85 -6
  54. package/packages/visualizer/src/explorer/views.ts +75 -0
@@ -10,7 +10,9 @@
10
10
  * always the report's winner (`combineEvidence`), and history only describes how it got there.
11
11
  * - `testCatalog` turns the report's winners (plus a history, when there is one) into a `TestCatalog`: a summary with
12
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.
13
+ * - `testHistory`, `runList` and `runDetail` answer for one test, every run, and one run. `runList` and `artifactList`
14
+ * narrow by tag, label, media type, environment, branch, outcome and time (see `artifact-query.ts`); `artifactList` finds
15
+ * the recorded results that carry evidence across the whole history, not only the latest winners.
14
16
  * - `catalogMarkdown`, `historyMarkdown`, `runsMarkdown` and `runMarkdown` say the same in Markdown, for an agent or a
15
17
  * terminal.
16
18
  *
@@ -21,7 +23,9 @@
21
23
  * @see docs/server.md#reading-the-report-as-an-agent
22
24
  */
23
25
  import { displayTag, normalizeTag, normalizeTags } from './artifact-tags.js';
26
+ import { facetsOf, forRuns, labelList, matchesFilter, outcomeOfCounts, splitList, usedFilters } from './artifact-query.js';
24
27
  import { videoCounts } from './evidence-detail.js';
28
+ import { matchesScope, scopeTokens, UNKNOWN_SCOPE } from './evidence-scope.js';
25
29
  import { checkSlots } from './evidence-view.js';
26
30
  import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
27
31
  import { RecordedSpecs } from './recorded-specs.js';
@@ -101,6 +105,7 @@ export class TestHistory {
101
105
  #seen = new Set();
102
106
  #rev = 0;
103
107
  #cells = null;
108
+ #evidence = null;
104
109
  /** Changes each time a layer is added. */
105
110
  get rev() {
106
111
  return this.#rev;
@@ -127,7 +132,7 @@ export class TestHistory {
127
132
  const key = runKey(layer);
128
133
  let run = this.#runs.get(key);
129
134
  if (!run) {
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: [] };
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() } };
131
136
  this.#runs.set(key, run);
132
137
  }
133
138
  // A run is told by its first layer (by time, then id), whatever order its layers arrive in.
@@ -156,6 +161,8 @@ export class TestHistory {
156
161
  run.layers += 1;
157
162
  if (layer.evidence?.videos)
158
163
  run.videos.push(...layer.evidence.videos);
164
+ if (layer.evidence)
165
+ takeEvidence(run.evidence, layer.evidence);
159
166
  const origin = originOf(layer, backend);
160
167
  for (const row of rows) {
161
168
  // "Still running" is progress, not history: a test running now shows it as its current outcome.
@@ -248,13 +255,75 @@ export class TestHistory {
248
255
  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));
249
256
  return { run: runSummary(run, cells), results };
250
257
  }
258
+ /**
259
+ * Every recorded result that carries an attachment, newest first, with the values a query looks at. Built once per
260
+ * layer added. The domain is left to the caller (`artifactList`), which may know more specs than the layers recorded.
261
+ */
262
+ evidenceResults() {
263
+ if (this.#evidence?.rev === this.#rev)
264
+ return this.#evidence.items;
265
+ const items = [];
266
+ for (const [slot, list] of this.#bySlot) {
267
+ const i = this.#idents.get(slot);
268
+ for (const held of list) {
269
+ const e = held.e;
270
+ if (!e || e.attachments < 1)
271
+ continue;
272
+ const playable = (videoCounts(e.videos)?.recordings ?? 0) > 0;
273
+ const types = typesOf(e.roles, playable);
274
+ const artifactTags = [...(e.artifactTags ?? [])];
275
+ const o = held.o;
276
+ const result = {
277
+ key: i.slot, id: i.id, title: i.title, specPath: i.specPath, domain: null, at: o.at, status: held.s, environment: o.environment, producer: o.producer, kind: held.k,
278
+ parameters: o.parameters, branch: o.branch, sha: o.sha, run: o.run, runUrl: o.runUrl, layer: o.layer, backend: o.backend, attachments: e.attachments,
279
+ artifactTags, tags: [...e.tags], types, labels: e.labels, ...(e.videos ? { videos: e.videos } : {}),
280
+ };
281
+ const media = [
282
+ ...types.filter(type => type !== 'video').map(type => ({ type, tags: artifactTags })),
283
+ ...(e.videos ?? []).filter(video => video.available).map(video => ({ type: 'video', tags: video.tags ?? [] })),
284
+ ];
285
+ const labels = labelList(e.labels);
286
+ items.push({
287
+ result,
288
+ fields: {
289
+ text: [i.title, i.id, i.specPath ?? '', o.branch ?? '', o.producer, o.environment ?? '', ...artifactTags, ...e.tags, ...labels].join('\n').toLowerCase(),
290
+ artifactTags, tags: [...new Set([...e.tags, ...artifactTags])], types, media, labels,
291
+ environments: o.environment ? [o.environment] : [], producers: [o.producer], branches: o.branch ? [o.branch] : [], shas: o.sha ? [o.sha.toLowerCase()] : [],
292
+ outcomes: [held.s], at: o.at,
293
+ },
294
+ });
295
+ }
296
+ }
297
+ items.sort((a, b) => b.result.at - a.result.at || compareText(a.result.layer, b.result.layer) || compareText(a.result.key, b.result.key) || compareText(a.result.kind, b.result.kind) || compareText(a.result.producer, b.result.producer));
298
+ this.#evidence = { rev: this.#rev, items };
299
+ return items;
300
+ }
251
301
  /** The keys of the tests one run recorded. */
252
302
  testsIn(key) {
253
303
  return new Set((this.#runCells().get(key) ?? []).map(cell => cell.i.slot));
254
304
  }
255
305
  }
306
+ function takeEvidence(into, evidence) {
307
+ for (const tag of evidence.tags)
308
+ into.tags.add(tag);
309
+ for (const tag of evidence.artifactTags ?? [])
310
+ into.artifactTags.add(tag);
311
+ for (const role of evidence.roles)
312
+ into.roles.add(role);
313
+ for (const label of labelList(evidence.labels))
314
+ into.labels.add(label);
315
+ }
316
+ /** The media types of an evidence summary: its roles, with `video` only when a recording can be played. */
317
+ function typesOf(roles, playable) {
318
+ const types = new Set(roles);
319
+ types.delete('video');
320
+ if (playable)
321
+ types.add('video');
322
+ return [...types].sort();
323
+ }
256
324
  function runSummary(run, cells) {
257
325
  const counts = emptyCounts();
326
+ const carried = { tags: new Set(run.evidence.tags), artifactTags: new Set(run.evidence.artifactTags), roles: new Set(run.evidence.roles), labels: new Set(run.evidence.labels) };
258
327
  const tests = new Set();
259
328
  let total = 0, measured = false;
260
329
  const failed = [];
@@ -262,6 +331,8 @@ function runSummary(run, cells) {
262
331
  for (const cell of cells) {
263
332
  if (cell.r.e?.videos)
264
333
  videos.push(...cell.r.e.videos);
334
+ if (cell.r.e)
335
+ takeEvidence(carried, cell.r.e);
265
336
  counts[cell.r.s] += 1;
266
337
  tests.add(cell.i.slot);
267
338
  if (cell.r.d !== null) {
@@ -281,6 +352,10 @@ function runSummary(run, cells) {
281
352
  producers: [...run.producers].sort(), environments: [...run.environments].sort(),
282
353
  layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
283
354
  ...(video ? { videos: video } : {}),
355
+ ...(carried.artifactTags.size ? { artifactTags: [...carried.artifactTags].sort() } : {}),
356
+ ...(carried.roles.size ? { types: typesOf(carried.roles, (video?.recordings ?? 0) > 0) } : {}),
357
+ ...(carried.labels.size ? { labels: [...carried.labels].sort() } : {}),
358
+ ...(carried.tags.size ? { tags: [...carried.tags].sort() } : {}),
284
359
  };
285
360
  }
286
361
  function compareText(a, b) {
@@ -374,10 +449,16 @@ function summaryOf(slot, history, known) {
374
449
  const labels = {};
375
450
  let attachments = 0, steps = 0, attempts = 0;
376
451
  const roles = new Set();
452
+ const profiles = new Map();
377
453
  for (const [at, outcome] of outcomes.entries()) {
378
454
  const evidence = outcome.evidence;
379
455
  if (!evidence)
380
456
  continue;
457
+ for (const video of evidence.videos ?? [])
458
+ if (video.available) {
459
+ const tokens = scopeTokens(video.scope);
460
+ profiles.set(tokens.join('\n'), tokens);
461
+ }
381
462
  if (evidence.attachments > 0)
382
463
  for (const tag of normalizeTags([...evidence.tags, ...(slot.outcomes[at]?.layer.evidence?.tags ?? [])]))
383
464
  inheritedTags.add(tag);
@@ -407,6 +488,7 @@ function summaryOf(slot, history, known) {
407
488
  tags: tagList,
408
489
  ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}),
409
490
  ...([...inheritedTags].some(tag => !artifactTags.has(tag)) ? { inheritedArtifactTags: [...inheritedTags].filter(tag => !artifactTags.has(tag)).sort() } : {}),
491
+ ...(profiles.size ? { scopeProfiles: [...profiles.values()] } : {}),
410
492
  labels,
411
493
  evidence: { attachments, steps, attempts, roles: [...roles].sort() },
412
494
  lastRecordedAtMs: slot.latestAtMs,
@@ -416,7 +498,7 @@ function summaryOf(slot, history, known) {
416
498
  /** The lowercase text a search matches a test against: title, id, spec, domain, tags, labels, environments and producers. */
417
499
  export function haystackOf(test) {
418
500
  return [
419
- test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags, ...(test.artifactTags ?? []).map(displayTag),
501
+ test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags, ...(test.artifactTags ?? []).map(displayTag), ...(test.scopeProfiles ?? []).flat().filter(token => token !== UNKNOWN_SCOPE),
420
502
  ...Object.entries(test.labels).flatMap(([key, value]) => [key, value]), ...test.environments, ...test.producers,
421
503
  ].join('\n').toLowerCase();
422
504
  }
@@ -523,6 +605,7 @@ export function catalogModel(layers, sources = {}) {
523
605
  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)),
524
606
  tags: facet(tests, test => test.tags),
525
607
  artifactTags: facet(tests, test => test.artifactTags ?? []),
608
+ scopes: facet(tests, test => (test.scopeProfiles ?? []).flat()),
526
609
  branches: facet(tests, test => test.stats.branches),
527
610
  labels: Object.fromEntries([...labelKeys].sort().map(key => [key, facet(tests, test => (test.labels[key] === undefined ? [] : [test.labels[key]]))])),
528
611
  };
@@ -559,6 +642,8 @@ export function matchesQuery(model, test, query, inRun) {
559
642
  return false;
560
643
  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
644
  return false;
645
+ if (query.scope?.length && !(test.scopeProfiles ?? []).some(tokens => matchesScope(tokens, query.scope)))
646
+ return false;
562
647
  if (inRun && !inRun.has(test.key))
563
648
  return false;
564
649
  return true;
@@ -592,24 +677,75 @@ export function hasVideo(run) {
592
677
  export function runList(history, version, limit = 100, query = {}) {
593
678
  const all = history.runs();
594
679
  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 };
680
+ const filter = forRuns(query);
681
+ const fields = all.map(fieldsOfRun);
682
+ const runs = all.filter((_, at) => matchesFilter(fields[at], filter));
683
+ return {
684
+ schemaVersion: TEST_CATALOG_VERSION, version, total: runs.length, runs: runs.slice(0, Math.max(0, limit)), withVideo,
685
+ filters: usedFilters(filter), facets: facetsOf(fields, filter),
686
+ };
687
+ }
688
+ /** What a query looks at in a run: see {@link ArtifactFilter}. A run's outcome is `failed` when any result failed. */
689
+ export function fieldsOfRun(run) {
690
+ const artifactTags = run.artifactTags ?? [];
691
+ // A run summary of an older server has no `types`, but it says whether it has a playable video.
692
+ const types = run.types ?? (hasVideo(run) ? ['video'] : []);
693
+ const labels = run.labels ?? [];
694
+ return {
695
+ text: [run.title ?? '', run.key, run.branch ?? '', run.sha ?? '', ...run.producers, ...run.environments, ...artifactTags, ...(run.tags ?? []), ...labels].join('\n').toLowerCase(),
696
+ artifactTags, tags: [...new Set([...(run.tags ?? []), ...artifactTags])], types, media: types.map(type => ({ type, tags: artifactTags })), labels,
697
+ environments: run.environments, producers: run.producers, branches: run.branch ? [run.branch] : [], shas: run.sha ? [run.sha.toLowerCase()] : [],
698
+ outcomes: [outcomeOfCounts(run.counts)], at: run.endedAtMs,
699
+ };
700
+ }
701
+ /**
702
+ * The recorded results that carry evidence and match a query, newest first, across the whole history (`limit` default
703
+ * 100). `total` and the facet counts apply to every match before `offset` and `limit`. `domainOf` tells the domain of a
704
+ * spec path when the caller knows more than the layers recorded (a server with a spec set); otherwise it is the
705
+ * recorded spec's domain, else the folder the path implies.
706
+ *
707
+ * @example
708
+ * ```ts
709
+ * const list = artifactList(history, 'v1', { artifactTag: ['demo'], type: ['video'], environment: ['ios'] });
710
+ * list.results.map(r => [r.title, r.run, r.sha, r.status]);
711
+ * ```
712
+ */
713
+ export function artifactList(history, version, query = {}, page = {}) {
714
+ const domains = new Map();
715
+ const domainOf = (path) => {
716
+ if (path === null)
717
+ return null;
718
+ if (!domains.has(path))
719
+ domains.set(path, (page.domainOf?.(path) ?? catalogSpec(path, known => history.specs.get(known))?.domain) || null);
720
+ return domains.get(path);
721
+ };
722
+ const items = history.evidenceResults().map(({ result, fields }) => {
723
+ const domain = domainOf(result.specPath);
724
+ return { result: { ...result, domain }, fields: { ...fields, domains: domain ? [domain] : [], specs: result.specPath ? [normalSpecPath(result.specPath)] : [] } };
725
+ });
726
+ const matched = items.filter(item => matchesFilter(item.fields, query));
727
+ const limit = Math.max(0, page.limit ?? 100);
728
+ const offset = Math.max(0, page.offset ?? 0);
729
+ return {
730
+ schemaVersion: TEST_CATALOG_VERSION, version, total: matched.length, offset, limit,
731
+ results: matched.slice(offset, offset + limit).map(item => item.result), filters: usedFilters(query), facets: facetsOf(items.map(item => item.fields), query),
732
+ };
597
733
  }
598
734
  /** One run with every result it recorded, or null when no run has that key. */
599
735
  export function runDetail(history, version, key) {
600
736
  const found = history.run(key);
601
737
  return found ? { schemaVersion: TEST_CATALOG_VERSION, version, ...found } : null;
602
738
  }
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`. */
739
+ /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, `scope` (every token must hold for one recording), and `inherited=1`. */
604
740
  export function parseTestQuery(params) {
605
- const many = (name) => params.getAll(name).flatMap(value => value.split(',')).map(value => value.trim()).filter(Boolean);
741
+ const many = (name) => params.getAll(name).flatMap(splitList);
606
742
  const number = (name) => { const value = params.get(name); return value !== null && /^\d+$/.test(value) ? Number(value) : undefined; };
607
743
  const sort = params.get('sort');
608
744
  const query = {};
609
745
  const q = params.get('q');
610
746
  if (q)
611
747
  query.q = q;
612
- for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag', 'artifactTag']) {
748
+ for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag', 'artifactTag', 'scope']) {
613
749
  const values = many(name);
614
750
  if (values.length)
615
751
  query[name] = values;
@@ -690,6 +826,7 @@ export function historyMarkdown(document) {
690
826
  `- 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'}`,
691
827
  ...(test.tags.length ? [`- Tags: ${test.tags.join(', ')}`] : []),
692
828
  ...(test.artifactTags?.length ? [`- Artifact tags: ${test.artifactTags.map(displayTag).join(', ')}`] : []),
829
+ ...(test.scopeProfiles?.length ? [`- Recording scope: ${test.scopeProfiles.map(tokens => tokens.join(' ')).join('; ')}`] : []),
693
830
  ...(Object.keys(test.labels).length ? [`- Labels: ${Object.entries(test.labels).map(([k, v]) => `${k}=${v}`).join(', ')}`] : []),
694
831
  `- Evidence: ${test.evidence.attachments} attachments${test.evidence.roles.length ? ` (${test.evidence.roles.join(', ')})` : ''}, ${test.evidence.steps} steps`,
695
832
  '',
@@ -726,6 +863,16 @@ export function runsMarkdown(list) {
726
863
  ...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)} |`),
727
864
  ].join('\n')}\n`;
728
865
  }
866
+ /** The artifact results as Markdown, for an agent or a terminal. */
867
+ export function artifactsMarkdown(list) {
868
+ return `${[
869
+ `# Artifacts (${list.results.length} of ${list.total})`,
870
+ '',
871
+ '| Recorded | Test | Status | Environment | Branch | Commit | Types | Tags | Videos | Run |',
872
+ '|---|---|---|---|---|---|---|---|---|---|',
873
+ ...list.results.map(r => `| ${iso(r.at)} | ${cell(r.title)} | ${r.status} | ${r.environment ?? '—'} | ${cell(r.branch ?? '—')} | ${r.sha?.slice(0, 10) ?? '—'} | ${r.types.join(', ') || '—'} | ${r.artifactTags.map(displayTag).join(' ') || '—'} | ${videosText(videoCounts(r.videos))} | ${cell(r.runUrl ?? r.run)} |`),
874
+ ].join('\n')}\n`;
875
+ }
729
876
  /** One run as Markdown. */
730
877
  export function runMarkdown(detail) {
731
878
  const { run } = detail;