@crvouga/atlas 0.16.0 → 1.0.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 (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +52 -8
  3. package/dist/core/src/cli.d.ts +1 -1
  4. package/dist/core/src/cli.js +1 -1
  5. package/dist/reports/src/attachment-files.d.ts +3 -1
  6. package/dist/reports/src/attachment-files.js +14 -4
  7. package/dist/reports/src/evidence-types.d.ts +22 -0
  8. package/dist/reports/src/garbage.d.ts +39 -0
  9. package/dist/reports/src/garbage.js +82 -0
  10. package/dist/reports/src/layer-merge.js +5 -0
  11. package/dist/reports/src/layer-types.d.ts +1 -1
  12. package/dist/reports/src/publish-checks.js +11 -3
  13. package/dist/reports/src/recording-receipt.d.ts +48 -0
  14. package/dist/reports/src/recording-receipt.js +103 -0
  15. package/dist/reports/src/report-cli.d.ts +1 -1
  16. package/dist/reports/src/report-cli.js +20 -2
  17. package/dist/reports/src/report-index.d.ts +6 -0
  18. package/dist/reports/src/report-index.js +14 -0
  19. package/dist/reports/src/report-status.d.ts +2 -0
  20. package/dist/reports/src/report-status.js +17 -1
  21. package/dist/reports/src/reports-explorer-route.d.ts +8 -0
  22. package/dist/reports/src/reports-route.d.ts +8 -0
  23. package/dist/schema/src/api.d.ts +65 -0
  24. package/dist/schema/src/api.js +7 -2
  25. package/dist/schema/src/cli-results.d.ts +107 -2
  26. package/dist/schema/src/cli-results.js +11 -3
  27. package/dist/schema/src/index.d.ts +1 -0
  28. package/dist/schema/src/index.js +1 -0
  29. package/dist/schema/src/layer.d.ts +165 -0
  30. package/dist/schema/src/layer.js +72 -1
  31. package/dist/schema/src/server-config.d.ts +7 -49
  32. package/dist/schema/src/server-config.js +8 -21
  33. package/dist/schema/src/user-state.d.ts +115 -0
  34. package/dist/schema/src/user-state.js +54 -0
  35. package/dist/server/src/app.d.ts +16 -2
  36. package/dist/server/src/app.js +62 -0
  37. package/dist/server/src/config.d.ts +16 -21
  38. package/dist/server/src/config.js +48 -57
  39. package/dist/server/src/dev.d.ts +1 -1
  40. package/dist/server/src/dev.js +20 -7
  41. package/dist/server/src/index.d.ts +5 -1
  42. package/dist/server/src/index.js +5 -1
  43. package/dist/server/src/main.d.ts +23 -7
  44. package/dist/server/src/main.js +72 -26
  45. package/dist/server/src/openapi.js +24 -5
  46. package/dist/server/src/projects.js +89 -14
  47. package/dist/server/src/settings.d.ts +23 -7
  48. package/dist/server/src/settings.js +50 -60
  49. package/dist/server/src/storage.d.ts +68 -0
  50. package/dist/server/src/storage.js +125 -0
  51. package/dist/server/src/user-state.d.ts +31 -0
  52. package/dist/server/src/user-state.js +74 -0
  53. package/docs/agents.md +1 -1
  54. package/docs/deploying.md +38 -39
  55. package/docs/reference/cli.md +17 -7
  56. package/docs/reference/http-api.md +30 -2
  57. package/docs/reference/reports.md +165 -2
  58. package/docs/reference/schema.md +161 -16
  59. package/docs/reference/server.md +263 -41
  60. package/docs/report-backends.md +0 -144
  61. package/docs/report-contract.md +1 -1
  62. package/docs/rich-evidence.md +29 -0
  63. package/docs/runners.md +1 -1
  64. package/docs/server.md +141 -36
  65. package/docs/statecharts.md +1 -1
  66. package/docs/ui.md +1 -1
  67. package/docs/workspaces.md +5 -2
  68. package/llms-full.txt +265 -234
  69. package/llms.txt +1 -1
  70. package/package.json +2 -2
  71. package/packages/reports/STORAGE.md +1 -1
  72. package/packages/schema/schemas/check-detail.schema.json +20 -0
  73. package/packages/schema/schemas/effective-delta.schema.json +61 -0
  74. package/packages/schema/schemas/effective-evidence.schema.json +61 -0
  75. package/packages/schema/schemas/evidence-page.schema.json +45 -0
  76. package/packages/schema/schemas/gc-policy.schema.json +36 -0
  77. package/packages/schema/schemas/gc-run-result.schema.json +106 -0
  78. package/packages/schema/schemas/gc-snapshot.schema.json +122 -0
  79. package/packages/schema/schemas/layer-rows.schema.json +16 -0
  80. package/packages/schema/schemas/layer.schema.json +153 -0
  81. package/packages/schema/schemas/publish-checks.schema.json +24 -0
  82. package/packages/schema/schemas/publish-result.schema.json +161 -1
  83. package/packages/schema/schemas/report-status.schema.json +104 -0
  84. package/packages/schema/schemas/server-config.schema.json +0 -41
  85. package/packages/schema/schemas/server-editable-settings.schema.json +0 -41
  86. package/packages/schema/schemas/server-settings-snapshot.schema.json +0 -82
  87. package/packages/schema/schemas/user-state.schema.json +140 -0
  88. package/packages/server/README.md +11 -6
  89. package/packages/server/openapi.json +843 -128
  90. package/packages/visualizer/src/admin/SettingsPage.tsx +183 -88
  91. package/packages/visualizer/src/evidence/marks.tsx +16 -0
@@ -0,0 +1,103 @@
1
+ import { contentTypeOf } from './media-types.js';
2
+ /** Is this attachment a screen recording: a role of `video`, or a `video/*` media type? */
3
+ export function isVideoAttachment(attachment) {
4
+ if (attachment.role === 'video')
5
+ return true;
6
+ const type = typeof attachment.contentType === 'string' ? attachment.contentType : typeof attachment.name === 'string' ? contentTypeOf(attachment.name) : '';
7
+ return type.startsWith('video/');
8
+ }
9
+ /** Every video attachment of a holder, its steps and its attempts, at any depth. */
10
+ function videosOf(holder) {
11
+ const found = [];
12
+ const take = (list) => { if (Array.isArray(list))
13
+ for (const item of list)
14
+ if (isVideoAttachment(item))
15
+ found.push(item); };
16
+ take(holder.attachments);
17
+ for (const step of holder.steps ?? [])
18
+ take(step.attachments);
19
+ for (const attempt of holder.attempts ?? []) {
20
+ take(attempt.attachments);
21
+ for (const step of attempt.steps ?? [])
22
+ take(step.attachments);
23
+ }
24
+ return found;
25
+ }
26
+ /**
27
+ * The receipt for the checks of one layer, or `undefined` when no check declares a `recordingExpectation` (the layer then
28
+ * claims nothing). Call it with checks whose attachment bytes are already stored (`bundleKey`, `bytes` and `sha256` set).
29
+ *
30
+ * A `required` check is `uploaded` when any video of it has stored bytes, `referenced` when one has only a `uri`,
31
+ * `skipped` when its videos were left out (the attachment's `omitted` is the reason) and `unresolved` when it has no
32
+ * video at all. A `not-applicable` check is listed but is neither expected nor missing. Items are sorted by check id so the
33
+ * same input is always the same receipt.
34
+ */
35
+ export function recordingReceipt(checks) {
36
+ const items = [];
37
+ const count = { uploaded: 0, referenced: 0, skipped: 0, unresolved: 0, notApplicable: 0 };
38
+ for (const check of checks) {
39
+ const expectation = check.recordingExpectation;
40
+ if (expectation !== 'required' && expectation !== 'not-applicable')
41
+ continue;
42
+ const check_ = String(check.id);
43
+ if (expectation === 'not-applicable') {
44
+ count.notApplicable += 1;
45
+ items.push({ check: check_, state: 'not-applicable' });
46
+ continue;
47
+ }
48
+ const files = videosOf(check).map(video => {
49
+ const state = typeof video.bundleKey === 'string' ? 'uploaded' : typeof video.uri === 'string' ? 'referenced' : 'skipped';
50
+ return {
51
+ name: String(video.name),
52
+ state,
53
+ ...(typeof video.bytes === 'number' ? { bytes: video.bytes } : {}),
54
+ ...(typeof video.sha256 === 'string' ? { sha256: video.sha256 } : {}),
55
+ ...(state === 'skipped' ? { reason: typeof video.omitted === 'string' ? video.omitted : 'bytes-not-attached' } : {})
56
+ };
57
+ });
58
+ const first = (state) => files.find(file => file.state === state);
59
+ const state = first('uploaded') ? 'uploaded' : first('referenced') ? 'referenced' : first('skipped') ? 'skipped' : 'unresolved';
60
+ const reason = state === 'skipped' ? first('skipped')?.reason : state === 'unresolved' ? 'no-video-attached' : undefined;
61
+ count[state] += 1;
62
+ items.push({ check: check_, state, ...(reason ? { reason } : {}), ...(files.length ? { files } : {}) });
63
+ }
64
+ if (!items.length)
65
+ return undefined;
66
+ items.sort((a, b) => (a.check < b.check ? -1 : a.check > b.check ? 1 : 0));
67
+ return { expected: count.uploaded + count.referenced + count.skipped + count.unresolved, ...count, items };
68
+ }
69
+ /**
70
+ * Reduces the winning results of a report to its recording completeness. `incomplete` when any expected recording is
71
+ * skipped or unresolved; otherwise `unknown` when a contributing layer has no receipt (nothing is claimed for it, and a
72
+ * report with no results at all is unknown too); otherwise `complete` when something was expected and `none-expected` when
73
+ * nothing was. Gaps are sorted so the output never depends on read order.
74
+ */
75
+ export function recordingCompleteness(results) {
76
+ const count = { expected: 0, uploaded: 0, referenced: 0, skipped: 0, unresolved: 0, notApplicable: 0 };
77
+ const gaps = [];
78
+ const without = new Set();
79
+ for (const result of results) {
80
+ if (!result.hasReceipt) {
81
+ without.add(result.layer);
82
+ continue;
83
+ }
84
+ const state = result.recording?.state;
85
+ if (state === undefined)
86
+ continue;
87
+ if (state === 'not-applicable') {
88
+ count.notApplicable += 1;
89
+ continue;
90
+ }
91
+ count.expected += 1;
92
+ if (state === 'uploaded' || state === 'referenced' || state === 'skipped' || state === 'unresolved')
93
+ count[state] += 1;
94
+ // A state this reader does not know is a gap: it is never counted as uploaded.
95
+ if (state !== 'uploaded' && state !== 'referenced') {
96
+ gaps.push({ spec: result.spec, check: result.check, layer: result.layer, environment: result.environment, state, ...(result.recording?.reason ? { reason: result.recording.reason } : {}) });
97
+ }
98
+ }
99
+ const text = (value) => value ?? '';
100
+ gaps.sort((a, b) => (text(a.spec) + a.check + text(a.environment) + a.layer < text(b.spec) + b.check + text(b.environment) + b.layer ? -1 : 1));
101
+ const state = gaps.length ? 'incomplete' : without.size || !results.length ? 'unknown' : count.expected ? 'complete' : 'none-expected';
102
+ return { state, ...count, layersWithoutReceipt: without.size, gaps };
103
+ }
@@ -13,7 +13,7 @@
13
13
  * @see docs/agents.md
14
14
  */
15
15
  /** The text `atlas report --help` prints: every subcommand, its flags, what each does and the exit codes. Also the source of the command reference in `docs/reference/cli.md`. */
16
- export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--run-id <id>] [--run-title <t>] [--run-started-at <iso|ms>] [--run-ended-at <iso|ms>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--run-id <id>] [--run-title <t>]\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.\n When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --run-id <id> name this execution (a local or manual run with no CI run URL; also ATLAS_RUN_ID).\n Layers that share an id are one run, however many shards or batches uploaded them;\n another invocation at the same commit gets another id. Never part of what a check is\n --run-title <t> a human name for the run (also ATLAS_RUN_TITLE); --run-started-at and --run-ended-at\n record when it ran. Without --run-id a run is its CI run URL, else branch@sha\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
16
+ export declare const REPORT_USAGE = "atlas report \u2014 layered evidence for any kind of spec\n\n atlas report publish [--store <dir|s3://bucket/prefix>] [--producer <name>]\n ( --ctrf <file> | --junit <file> | --manifest <file> | --checks <file.json> )\n [--environment <name>] [--branch <b>] [--sha <s>] [--run-url <u>]\n [--run-id <id>] [--run-title <t>] [--run-started-at <iso|ms>] [--run-ended-at <iso|ms>]\n [--spec <path> | --specs <dir>] [--upload-run]\n [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]\n [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...\n [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]\n [--require-recordings]\n atlas report stream [--store <dir|s3://\u2026|https://atlas.example.com>] --producer <name> [--environment <name>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...\n [--run-id <id>] [--run-title <t>]\n [--flush-ms 1000] [--batch 100] (checks as JSON lines on standard input)\n atlas report status [--store <dir|s3://\u2026|id=url>]... [--backends <module>]\n [--specs <dir>] [--format text|json|markdown]\n [--require-environments a,b] [--require-all-specs]\n [--branch <name>]... [--ignore-modified]\n atlas report merge [--store \u2026]... [--backends <module>] --into <dir|s3://\u2026>\n atlas report doctor [--store \u2026]... [--role publisher|reader|both]\n atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]\n\n A store is a directory or s3://bucket/prefix (add ?region=\u2026&endpoint=\u2026 for R2, MinIO and the\n rest). Select a project on an Atlas node with --project <slug> or ATLAS_PROJECT. This scopes specs, reports and recordings together.\n When no --store is given, ATLAS_STORE is used. Keys never go in the URL: set\n AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with `aws sso login`, or use a role.\n\n publish Write one immutable layer: the checks of one producer, on one environment. Safe to repeat.\n --producer is required, unless the --checks file names one\n --spec <path> every check in the report belongs to this spec (a report per feature file)\n --specs <dir> link checks to the spec files in <dir> by path, or by the suite, class or\n file name the report gives; also publishes the specs themselves, so the\n node can show them without a checkout\n --run-id <id> name this execution (a local or manual run with no CI run URL; also ATLAS_RUN_ID).\n Layers that share an id are one run, however many shards or batches uploaded them;\n another invocation at the same commit gets another id. Never part of what a check is\n --run-title <t> a human name for the run (also ATLAS_RUN_TITLE); --run-started-at and --run-ended-at\n record when it ran. Without --run-id a run is its CI run URL, else branch@sha\n --upload-run with --manifest: also upload the run's screenshots and clips for the viewer\n --attach <file> attach a file to the whole layer: a CI log, a coverage report, a trace archive\n (screenshots, videos and traces of a single test go in its CTRF or checks file)\n --label k=v a fact about the run, not part of what it checks: build, os, browser\n --link [n=]<url> a link to the run, a dashboard, an issue\n Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own\n folder and each --attach-root, and never from a hidden file or a link out: a report is data from a\n test run, not instructions. One that is missing or not allowed is skipped with a warning.\n --require-recordings exit 1 when a check that declared recordingExpectation \"required\" has no\n uploaded video (absent, left out as too large or not allowed, or refused). The layer is\n still written and the checks keep their results; only this command fails. The receipt of\n what was uploaded (counts, sizes, checksums) is in the JSON result under layer.recordings.\n From a checkout with uncommitted changes the layer is marked \"modified\".\n stream Send checks as they finish: one JSON object per line on standard input (the fields of one\n check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server\n shows the run while it is still going. A check sent again with the same id (running, then\n passed) is replaced by the later. Ends when the input does; exit 1 if anything could not be sent.\n Point it at an Atlas server with --store https://\u2026 and a token in ATLAS_TOKEN.\n status Combine every layer of every store, and exit 1 unless the report passes.\n --specs <dir> lay the results over these spec files (default: the specs in the store)\n --require-all-specs fail while any spec has no results\n --branch <name> only layers recorded on this branch (repeatable)\n --ignore-modified leave out layers from modified checkouts and unknown origins\n merge Copy the layers of several stores into one.\n doctor Check that a store works: credentials, listing, writing, and what it holds.\n policy Print the least-privilege IAM policy for publishers, readers, or both.\n\n --backends <module> A TypeScript or JavaScript module whose default export is a list of\n { id, label?, store } (or a function returning it): a device lab, anything\n that implements ObjectStore.\n\n --format json (alias --json) publish, stream, merge, doctor and status print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command\n that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store may be an Atlas server (https://\u2026): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.\n\n Exit codes: 0 ok, 1 the report does not pass (or a check failed), 2 the command was wrong.\n";
17
17
  type Env = Readonly<Record<string, string | undefined>>;
18
18
  type Git = (args: readonly string[]) => string | null;
19
19
  /** What `reportMain` is given besides arguments: where output goes (`out`), and, for tests and embedding, the environment, working directory, git, clock and standard input (`stdin`, where `stream` reads checks from). Defaults are the real ones. */
@@ -43,6 +43,7 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
43
43
  [--param key=value]... [--kind <k>] [--title <t>] [--recorded-at <iso|ms>]
44
44
  [--attach [role=]<file>]... [--label key=value]... [--link [name=]<url>]...
45
45
  [--attach-root <dir>]... [--max-attachment-mb <n>] [--max-total-attachment-mb <n>]
46
+ [--require-recordings]
46
47
  atlas report stream [--store <dir|s3://…|https://atlas.example.com>] --producer <name> [--environment <name>]
47
48
  [--branch <b>] [--sha <s>] [--run-url <u>] [--kind <k>] [--param key=value]...
48
49
  [--run-id <id>] [--run-title <t>]
@@ -79,6 +80,10 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
79
80
  Files a report names (CTRF attachments, JUnit [[ATTACHMENT|path]] lines) come from the report's own
80
81
  folder and each --attach-root, and never from a hidden file or a link out: a report is data from a
81
82
  test run, not instructions. One that is missing or not allowed is skipped with a warning.
83
+ --require-recordings exit 1 when a check that declared recordingExpectation "required" has no
84
+ uploaded video (absent, left out as too large or not allowed, or refused). The layer is
85
+ still written and the checks keep their results; only this command fails. The receipt of
86
+ what was uploaded (counts, sizes, checksums) is in the JSON result under layer.recordings.
82
87
  From a checkout with uncommitted changes the layer is marked "modified".
83
88
  stream Send checks as they finish: one JSON object per line on standard input (the fields of one
84
89
  check: id, title, status, and any evidence). Each second's worth becomes one layer, so a server
@@ -310,7 +315,7 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
310
315
  const json = wantsJson(rest);
311
316
  const result = {
312
317
  ok: true, store: target.label,
313
- layer: { id: layer.id, producer, environment: layer.environment ?? null, checks: ingested.checks.length, passed: counts.passed, failed: counts.failed, notRun: counts.other, linkedToSpec: linkedCount, ...(freshness ? { freshness } : {}), attachmentFiles: attachedFiles, attachmentBytes: attachedBytes },
318
+ layer: { id: layer.id, producer, environment: layer.environment ?? null, checks: ingested.checks.length, passed: counts.passed, failed: counts.failed, notRun: counts.other, linkedToSpec: linkedCount, ...(freshness ? { freshness } : {}), attachmentFiles: attachedFiles, attachmentBytes: attachedBytes, ...(layer.recordings ? { recordings: layer.recordings } : {}) },
314
319
  warnings: warnings.map(warning => `attachment skipped: ${warning}`)
315
320
  };
316
321
  if (!json)
@@ -318,6 +323,19 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
318
323
  if (!json)
319
324
  for (const warning of warnings)
320
325
  io.out(`! attachment skipped: ${warning}\n`);
326
+ // Recording completeness is reported apart from the checks' results. Only --require-recordings turns a gap into a failing exit.
327
+ const gaps = (layer.recordings?.items ?? []).filter(item => item.state !== 'uploaded' && item.state !== 'referenced' && item.state !== 'not-applicable');
328
+ const strict = rest.includes('--require-recordings');
329
+ if (layer.recordings) {
330
+ result.recordings = { strict, complete: gaps.length === 0, expected: layer.recordings.expected, uploaded: layer.recordings.uploaded, gaps: gaps.map(({ check, state, reason }) => ({ check, state, ...(reason ? { reason } : {}) })) };
331
+ if (!json) {
332
+ io.out(`Recordings: ${layer.recordings.uploaded + layer.recordings.referenced} of ${layer.recordings.expected} expected published${layer.recordings.notApplicable ? `, ${layer.recordings.notApplicable} not applicable` : ''}\n`);
333
+ for (const gap of gaps)
334
+ io.out(`! recording ${gap.state}: ${gap.check}${gap.reason ? ` (${gap.reason})` : ''}\n`);
335
+ }
336
+ if (strict && gaps.length)
337
+ result.ok = false;
338
+ }
321
339
  if (specsDirectory) {
322
340
  const files = await collectSpecFiles(path.resolve(base, specsDirectory));
323
341
  // A spec named with --spec that the directory does not hold (or whose kind is not a spec file) is published too.
@@ -343,7 +361,7 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
343
361
  }
344
362
  if (json)
345
363
  io.out(`${JSON.stringify(result, null, 2)}\n`);
346
- return 0;
364
+ return result.ok ? 0 : 1;
347
365
  }
348
366
  if (command === 'stream') {
349
367
  const target = writeStoreOf(value(rest, 'store'), base, env);
@@ -93,6 +93,12 @@ export declare class ReportIndex {
93
93
  * and the run list are built from. Read-only for callers; `history.rev` changes whenever a layer is taken in.
94
94
  */
95
95
  get history(): TestHistory;
96
+ /**
97
+ * The ids of layers this index has read and that no longer decide any result. Removing one from its store cannot
98
+ * change the report (every slot it filled is held by a newer layer), which is what makes it safe for garbage
99
+ * collection to delete. A layer that failed to load, is hidden by `include`, or still holds a result is not here.
100
+ */
101
+ superseded(): ReadonlySet<string>;
96
102
  /** For `/readyz` and logs. */
97
103
  stats(): {
98
104
  backends: number;
@@ -50,6 +50,8 @@ export class ReportIndex {
50
50
  /** Layers that stopped holding any winner, and when. Older removals are forgotten; `#forgotten` says how far. */
51
51
  #removed = [];
52
52
  #forgotten = 0;
53
+ /** Layers that were read, took part in the report, and no longer decide any result: all their checks were replaced by newer layers. */
54
+ #superseded = new Set();
53
55
  #refreshing = null;
54
56
  #timer = null;
55
57
  #refreshedAt = 0;
@@ -248,6 +250,9 @@ export class ReportIndex {
248
250
  if (wins.size) {
249
251
  this.#held.set(id, { evidence, wins, rev: ++this.#rev, entry: null, json: null, digest: null });
250
252
  }
253
+ else {
254
+ this.#superseded.add(id);
255
+ }
251
256
  }
252
257
  /** What this layer sends has changed. */
253
258
  #touch(held) {
@@ -264,6 +269,7 @@ export class ReportIndex {
264
269
  return;
265
270
  }
266
271
  this.#held.delete(layerId);
272
+ this.#superseded.add(layerId);
267
273
  this.#removed.push({ id: layerId, rev: ++this.#rev });
268
274
  if (this.#removed.length > REMEMBERED_REMOVALS)
269
275
  this.#forgotten = this.#removed.splice(0, this.#removed.length - REMEMBERED_REMOVALS).at(-1).rev;
@@ -342,6 +348,14 @@ export class ReportIndex {
342
348
  get history() {
343
349
  return this.#history;
344
350
  }
351
+ /**
352
+ * The ids of layers this index has read and that no longer decide any result. Removing one from its store cannot
353
+ * change the report (every slot it filled is held by a newer layer), which is what makes it safe for garbage
354
+ * collection to delete. A layer that failed to load, is hidden by `include`, or still holds a result is not here.
355
+ */
356
+ superseded() {
357
+ return this.#superseded;
358
+ }
345
359
  /** For `/readyz` and logs. */
346
360
  stats() {
347
361
  return { backends: this.#backends.length, layers: this.#attribution.size, slots: this.#winners.size, holding: this.#held.size, failed: this.#failed.size, refreshedAt: this.#refreshedAt };
@@ -26,6 +26,8 @@ export type StatusOptions = {
26
26
  * each required environment with no checks (the name is normalised, so `iOS` means `ios`), then every notice of the
27
27
  * report (an unreadable layer or backend). `failing` lists each failed or running winner, in a fixed order that does
28
28
  * not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded.
29
+ * `recordings` reports recording completeness of the same results apart from `ok`: an incomplete or unknown
30
+ * completeness never changes the gate (use `atlas report publish --require-recordings` to fail on a missing video).
29
31
  *
30
32
  * @example
31
33
  * const status = reportStatus(await combineBackends(backends), { requireEnvironments: ['ios'] });
@@ -12,6 +12,7 @@ import { REPORT_STATUS_SCHEMA_VERSION } from '../../schema/src/layer.js';
12
12
  import { countResults } from './compose.js';
13
13
  import { normalizeEnvironment } from './layer-axes.js';
14
14
  import { isDiagnosticKind } from './layer-merge.js';
15
+ import { recordingCompleteness } from './recording-receipt.js';
15
16
  /** Total string order by code unit. */
16
17
  const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
17
18
  /**
@@ -23,6 +24,8 @@ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
23
24
  * each required environment with no checks (the name is normalised, so `iOS` means `ios`), then every notice of the
24
25
  * report (an unreadable layer or backend). `failing` lists each failed or running winner, in a fixed order that does
25
26
  * not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded.
27
+ * `recordings` reports recording completeness of the same results apart from `ok`: an incomplete or unknown
28
+ * completeness never changes the gate (use `atlas report publish --require-recordings` to fail on a missing video).
26
29
  *
27
30
  * @example
28
31
  * const status = reportStatus(await combineBackends(backends), { requireEnvironments: ['ios'] });
@@ -85,7 +88,12 @@ export function reportStatus(report, options = {}) {
85
88
  ...report.combined.specs.map(entry => specOutcome(entry.spec.path, entry.spec.title, entry.spec.kind, entry.results)),
86
89
  ...(report.combined.unassigned.length ? [specOutcome(null, 'No spec', 'unassigned', report.combined.unassigned)] : [])
87
90
  ],
88
- failing
91
+ failing,
92
+ // Apart from `ok`: whether the expected videos were published says nothing about whether the checks passed.
93
+ recordings: recordingCompleteness(outcomes.map(result => ({
94
+ spec: result.row.specPath, check: result.row.id, environment: result.layer.environment, layer: result.layer.id,
95
+ hasReceipt: result.layer.recordings !== undefined, ...(result.row.recording ? { recording: result.row.recording } : {})
96
+ })))
89
97
  };
90
98
  }
91
99
  /** `spec › check`, unless the check's id already starts with its spec. */
@@ -120,6 +128,14 @@ export function formatStatusText(status) {
120
128
  const where = [item.environment, item.branch, item.sha?.slice(0, 7)].filter(Boolean).join(' · ');
121
129
  lines.push(`✗ ${item.status === 'running' ? '[running] ' : ''}${label(item)}${where ? ` (${where})` : ''}${item.detail ? `\n ${item.detail.split('\n')[0]}` : ''}`);
122
130
  }
131
+ const recordings = status.recordings;
132
+ // Quiet for a report that never mentioned recordings; the JSON says `unknown` explicitly.
133
+ if (recordings && !(recordings.state === 'unknown' && recordings.expected === 0 && recordings.notApplicable === 0)) {
134
+ const published = recordings.uploaded + recordings.referenced;
135
+ lines.push(`· recordings: ${recordings.state} — ${published} of ${recordings.expected} expected published${recordings.notApplicable ? `, ${recordings.notApplicable} not applicable` : ''}${recordings.layersWithoutReceipt ? `, ${recordings.layersWithoutReceipt} layer${recordings.layersWithoutReceipt === 1 ? '' : 's'} unknown` : ''}`);
136
+ for (const gap of recordings.gaps)
137
+ lines.push(` ! recording ${gap.state}: ${label({ spec: gap.spec, check: gap.check })}${gap.reason ? ` (${gap.reason})` : ''}`);
138
+ }
123
139
  for (const notice of status.notices)
124
140
  lines.push(`! ${notice}`);
125
141
  for (const reason of status.reasons)
@@ -82,6 +82,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
82
82
  height?: number | undefined;
83
83
  durationMs?: number | undefined;
84
84
  description?: string | undefined;
85
+ omitted?: string | undefined;
85
86
  tags?: string[] | undefined;
86
87
  at?: {
87
88
  [x: string]: import("hono/utils/types").JSONValue;
@@ -126,6 +127,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
126
127
  height?: number | undefined;
127
128
  durationMs?: number | undefined;
128
129
  description?: string | undefined;
130
+ omitted?: string | undefined;
129
131
  tags?: string[] | undefined;
130
132
  at?: {
131
133
  [x: string]: import("hono/utils/types").JSONValue;
@@ -183,6 +185,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
183
185
  height?: number | undefined;
184
186
  durationMs?: number | undefined;
185
187
  description?: string | undefined;
188
+ omitted?: string | undefined;
186
189
  tags?: string[] | undefined;
187
190
  at?: {
188
191
  [x: string]: import("hono/utils/types").JSONValue;
@@ -250,6 +253,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
250
253
  height?: number | undefined;
251
254
  durationMs?: number | undefined;
252
255
  description?: string | undefined;
256
+ omitted?: string | undefined;
253
257
  tags?: string[] | undefined;
254
258
  at?: {
255
259
  [x: string]: import("hono/utils/types").JSONValue;
@@ -356,6 +360,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
356
360
  height?: number | undefined;
357
361
  durationMs?: number | undefined;
358
362
  description?: string | undefined;
363
+ omitted?: string | undefined;
359
364
  tags?: string[] | undefined;
360
365
  at?: {
361
366
  [x: string]: import("hono/utils/types").JSONValue;
@@ -400,6 +405,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
400
405
  height?: number | undefined;
401
406
  durationMs?: number | undefined;
402
407
  description?: string | undefined;
408
+ omitted?: string | undefined;
403
409
  tags?: string[] | undefined;
404
410
  at?: {
405
411
  [x: string]: import("hono/utils/types").JSONValue;
@@ -457,6 +463,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
457
463
  height?: number | undefined;
458
464
  durationMs?: number | undefined;
459
465
  description?: string | undefined;
466
+ omitted?: string | undefined;
460
467
  tags?: string[] | undefined;
461
468
  at?: {
462
469
  [x: string]: import("hono/utils/types").JSONValue;
@@ -524,6 +531,7 @@ export declare function createReportsExplorerApp(deps: ReportsExplorerDeps): imp
524
531
  height?: number | undefined;
525
532
  durationMs?: number | undefined;
526
533
  description?: string | undefined;
534
+ omitted?: string | undefined;
527
535
  tags?: string[] | undefined;
528
536
  at?: {
529
537
  [x: string]: import("hono/utils/types").JSONValue;
@@ -120,6 +120,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
120
120
  height?: number | undefined;
121
121
  durationMs?: number | undefined;
122
122
  description?: string | undefined;
123
+ omitted?: string | undefined;
123
124
  tags?: string[] | undefined;
124
125
  at?: {
125
126
  [x: string]: import("hono/utils/types").JSONValue;
@@ -164,6 +165,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
164
165
  height?: number | undefined;
165
166
  durationMs?: number | undefined;
166
167
  description?: string | undefined;
168
+ omitted?: string | undefined;
167
169
  tags?: string[] | undefined;
168
170
  at?: {
169
171
  [x: string]: import("hono/utils/types").JSONValue;
@@ -221,6 +223,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
221
223
  height?: number | undefined;
222
224
  durationMs?: number | undefined;
223
225
  description?: string | undefined;
226
+ omitted?: string | undefined;
224
227
  tags?: string[] | undefined;
225
228
  at?: {
226
229
  [x: string]: import("hono/utils/types").JSONValue;
@@ -288,6 +291,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
288
291
  height?: number | undefined;
289
292
  durationMs?: number | undefined;
290
293
  description?: string | undefined;
294
+ omitted?: string | undefined;
291
295
  tags?: string[] | undefined;
292
296
  at?: {
293
297
  [x: string]: import("hono/utils/types").JSONValue;
@@ -394,6 +398,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
394
398
  height?: number | undefined;
395
399
  durationMs?: number | undefined;
396
400
  description?: string | undefined;
401
+ omitted?: string | undefined;
397
402
  tags?: string[] | undefined;
398
403
  at?: {
399
404
  [x: string]: import("hono/utils/types").JSONValue;
@@ -438,6 +443,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
438
443
  height?: number | undefined;
439
444
  durationMs?: number | undefined;
440
445
  description?: string | undefined;
446
+ omitted?: string | undefined;
441
447
  tags?: string[] | undefined;
442
448
  at?: {
443
449
  [x: string]: import("hono/utils/types").JSONValue;
@@ -495,6 +501,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
495
501
  height?: number | undefined;
496
502
  durationMs?: number | undefined;
497
503
  description?: string | undefined;
504
+ omitted?: string | undefined;
498
505
  tags?: string[] | undefined;
499
506
  at?: {
500
507
  [x: string]: import("hono/utils/types").JSONValue;
@@ -562,6 +569,7 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
562
569
  height?: number | undefined;
563
570
  durationMs?: number | undefined;
564
571
  description?: string | undefined;
572
+ omitted?: string | undefined;
565
573
  tags?: string[] | undefined;
566
574
  at?: {
567
575
  [x: string]: import("hono/utils/types").JSONValue;
@@ -165,6 +165,14 @@ export declare const LayerHeaderSchema: z.ZodObject<{
165
165
  available: z.ZodBoolean;
166
166
  }, z.core.$loose>>>;
167
167
  }, z.core.$loose>>;
168
+ recordings: z.ZodOptional<z.ZodObject<{
169
+ expected: z.ZodNumber;
170
+ uploaded: z.ZodNumber;
171
+ referenced: z.ZodNumber;
172
+ skipped: z.ZodNumber;
173
+ unresolved: z.ZodNumber;
174
+ notApplicable: z.ZodNumber;
175
+ }, z.core.$loose>>;
168
176
  }, z.core.$loose>;
169
177
  /** One check of a layer as the report shows it. */
170
178
  export declare const LayerRowSchema: z.ZodObject<{
@@ -207,6 +215,10 @@ export declare const LayerRowSchema: z.ZodObject<{
207
215
  available: z.ZodBoolean;
208
216
  }, z.core.$loose>>>;
209
217
  }, z.core.$loose>>;
218
+ recording: z.ZodOptional<z.ZodObject<{
219
+ state: z.ZodString;
220
+ reason: z.ZodOptional<z.ZodString>;
221
+ }, z.core.$loose>>;
210
222
  }, z.core.$loose>;
211
223
  /** A layer with its checks: the rows of one layer, read apart from its header. */
212
224
  export declare const LayerRowsSchema: z.ZodObject<{
@@ -251,6 +263,10 @@ export declare const LayerRowsSchema: z.ZodObject<{
251
263
  available: z.ZodBoolean;
252
264
  }, z.core.$loose>>>;
253
265
  }, z.core.$loose>>;
266
+ recording: z.ZodOptional<z.ZodObject<{
267
+ state: z.ZodString;
268
+ reason: z.ZodOptional<z.ZodString>;
269
+ }, z.core.$loose>>;
254
270
  }, z.core.$loose>>;
255
271
  fingerprints: z.ZodRecord<z.ZodString, z.ZodString>;
256
272
  }, z.core.$loose>;
@@ -297,6 +313,10 @@ export declare const LayerEvidenceSchema: z.ZodObject<{
297
313
  available: z.ZodBoolean;
298
314
  }, z.core.$loose>>>;
299
315
  }, z.core.$loose>>;
316
+ recording: z.ZodOptional<z.ZodObject<{
317
+ state: z.ZodString;
318
+ reason: z.ZodOptional<z.ZodString>;
319
+ }, z.core.$loose>>;
300
320
  }, z.core.$loose>>;
301
321
  fingerprints: z.ZodRecord<z.ZodString, z.ZodString>;
302
322
  layer: z.ZodObject<{
@@ -348,6 +368,14 @@ export declare const LayerEvidenceSchema: z.ZodObject<{
348
368
  available: z.ZodBoolean;
349
369
  }, z.core.$loose>>>;
350
370
  }, z.core.$loose>>;
371
+ recordings: z.ZodOptional<z.ZodObject<{
372
+ expected: z.ZodNumber;
373
+ uploaded: z.ZodNumber;
374
+ referenced: z.ZodNumber;
375
+ skipped: z.ZodNumber;
376
+ unresolved: z.ZodNumber;
377
+ notApplicable: z.ZodNumber;
378
+ }, z.core.$loose>>;
351
379
  }, z.core.$loose>;
352
380
  }, z.core.$loose>;
353
381
  /**
@@ -399,6 +427,10 @@ export declare const EffectiveEvidenceSchema: z.ZodObject<{
399
427
  available: z.ZodBoolean;
400
428
  }, z.core.$loose>>>;
401
429
  }, z.core.$loose>>;
430
+ recording: z.ZodOptional<z.ZodObject<{
431
+ state: z.ZodString;
432
+ reason: z.ZodOptional<z.ZodString>;
433
+ }, z.core.$loose>>;
402
434
  }, z.core.$loose>>;
403
435
  fingerprints: z.ZodRecord<z.ZodString, z.ZodString>;
404
436
  layer: z.ZodObject<{
@@ -450,6 +482,14 @@ export declare const EffectiveEvidenceSchema: z.ZodObject<{
450
482
  available: z.ZodBoolean;
451
483
  }, z.core.$loose>>>;
452
484
  }, z.core.$loose>>;
485
+ recordings: z.ZodOptional<z.ZodObject<{
486
+ expected: z.ZodNumber;
487
+ uploaded: z.ZodNumber;
488
+ referenced: z.ZodNumber;
489
+ skipped: z.ZodNumber;
490
+ unresolved: z.ZodNumber;
491
+ notApplicable: z.ZodNumber;
492
+ }, z.core.$loose>>;
453
493
  }, z.core.$loose>;
454
494
  }, z.core.$loose>>;
455
495
  backends: z.ZodArray<z.ZodObject<{
@@ -522,6 +562,10 @@ export declare const EffectiveDeltaSchema: z.ZodObject<{
522
562
  available: z.ZodBoolean;
523
563
  }, z.core.$loose>>>;
524
564
  }, z.core.$loose>>;
565
+ recording: z.ZodOptional<z.ZodObject<{
566
+ state: z.ZodString;
567
+ reason: z.ZodOptional<z.ZodString>;
568
+ }, z.core.$loose>>;
525
569
  }, z.core.$loose>>;
526
570
  fingerprints: z.ZodRecord<z.ZodString, z.ZodString>;
527
571
  layer: z.ZodObject<{
@@ -573,6 +617,14 @@ export declare const EffectiveDeltaSchema: z.ZodObject<{
573
617
  available: z.ZodBoolean;
574
618
  }, z.core.$loose>>>;
575
619
  }, z.core.$loose>>;
620
+ recordings: z.ZodOptional<z.ZodObject<{
621
+ expected: z.ZodNumber;
622
+ uploaded: z.ZodNumber;
623
+ referenced: z.ZodNumber;
624
+ skipped: z.ZodNumber;
625
+ unresolved: z.ZodNumber;
626
+ notApplicable: z.ZodNumber;
627
+ }, z.core.$loose>>;
576
628
  }, z.core.$loose>;
577
629
  }, z.core.$loose>>;
578
630
  removed: z.ZodArray<z.ZodString>;
@@ -648,6 +700,14 @@ export declare const EvidencePageSchema: z.ZodObject<{
648
700
  available: z.ZodBoolean;
649
701
  }, z.core.$loose>>>;
650
702
  }, z.core.$loose>>;
703
+ recordings: z.ZodOptional<z.ZodObject<{
704
+ expected: z.ZodNumber;
705
+ uploaded: z.ZodNumber;
706
+ referenced: z.ZodNumber;
707
+ skipped: z.ZodNumber;
708
+ unresolved: z.ZodNumber;
709
+ notApplicable: z.ZodNumber;
710
+ }, z.core.$loose>>;
651
711
  }, z.core.$loose>>;
652
712
  cursor: z.ZodNullable<z.ZodString>;
653
713
  notices: z.ZodArray<z.ZodString>;
@@ -687,6 +747,7 @@ export declare const CheckDetailSchema: z.ZodObject<{
687
747
  height: z.ZodOptional<z.ZodNumber>;
688
748
  durationMs: z.ZodOptional<z.ZodNumber>;
689
749
  description: z.ZodOptional<z.ZodString>;
750
+ omitted: z.ZodOptional<z.ZodString>;
690
751
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
691
752
  at: z.ZodOptional<z.ZodObject<{
692
753
  step: z.ZodOptional<z.ZodString>;
@@ -752,6 +813,7 @@ export declare const CheckDetailSchema: z.ZodObject<{
752
813
  height: z.ZodOptional<z.ZodNumber>;
753
814
  durationMs: z.ZodOptional<z.ZodNumber>;
754
815
  description: z.ZodOptional<z.ZodString>;
816
+ omitted: z.ZodOptional<z.ZodString>;
755
817
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
756
818
  at: z.ZodOptional<z.ZodObject<{
757
819
  step: z.ZodOptional<z.ZodString>;
@@ -797,6 +859,7 @@ export declare const CheckDetailSchema: z.ZodObject<{
797
859
  height: z.ZodOptional<z.ZodNumber>;
798
860
  durationMs: z.ZodOptional<z.ZodNumber>;
799
861
  description: z.ZodOptional<z.ZodString>;
862
+ omitted: z.ZodOptional<z.ZodString>;
800
863
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
801
864
  at: z.ZodOptional<z.ZodObject<{
802
865
  step: z.ZodOptional<z.ZodString>;
@@ -849,6 +912,7 @@ export declare const CheckDetailSchema: z.ZodObject<{
849
912
  height: z.ZodOptional<z.ZodNumber>;
850
913
  durationMs: z.ZodOptional<z.ZodNumber>;
851
914
  description: z.ZodOptional<z.ZodString>;
915
+ omitted: z.ZodOptional<z.ZodString>;
852
916
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
853
917
  at: z.ZodOptional<z.ZodObject<{
854
918
  step: z.ZodOptional<z.ZodString>;
@@ -896,6 +960,7 @@ export declare const CheckDetailSchema: z.ZodObject<{
896
960
  limit: z.ZodOptional<z.ZodNumber>;
897
961
  }, z.core.$loose>>>;
898
962
  durationMs: z.ZodOptional<z.ZodNumber>;
963
+ recordingExpectation: z.ZodOptional<z.ZodString>;
899
964
  }, z.core.$loose>;
900
965
  run: z.ZodObject<{}, z.core.$loose>;
901
966
  }, z.core.$loose>;
@@ -108,7 +108,11 @@ export const LayerHeaderSchema = z.looseObject({
108
108
  live: z.boolean(),
109
109
  counts: z.record(EvidenceStatusSchema, z.number().int().nonnegative()),
110
110
  execution: z.string(),
111
- evidence: EvidenceSummarySchema.optional()
111
+ evidence: EvidenceSummarySchema.optional(),
112
+ recordings: z.looseObject({
113
+ expected: z.number().int().nonnegative(), uploaded: z.number().int().nonnegative(), referenced: z.number().int().nonnegative(),
114
+ skipped: z.number().int().nonnegative(), unresolved: z.number().int().nonnegative(), notApplicable: z.number().int().nonnegative()
115
+ }).optional().describe('How many of the recordings the layer expected were published. Absent: the layer declared no expectations, so its completeness is unknown, not complete')
112
116
  });
113
117
  /** One check of a layer as the report shows it. */
114
118
  export const LayerRowSchema = z.looseObject({
@@ -122,7 +126,8 @@ export const LayerRowSchema = z.looseObject({
122
126
  testId: z.string().nullable(),
123
127
  variant: z.record(z.string(), z.string()),
124
128
  durationMs: z.number().nullable(),
125
- evidence: EvidenceSummarySchema.optional()
129
+ evidence: EvidenceSummarySchema.optional(),
130
+ recording: z.looseObject({ state: z.string(), reason: z.string().optional() }).optional().describe('What became of the check\'s expected recording: uploaded, referenced, skipped, unresolved or not-applicable. Independent of `status`')
126
131
  });
127
132
  /** A layer with its checks: the rows of one layer, read apart from its header. */
128
133
  export const LayerRowsSchema = z.looseObject({