@crvouga/atlas 1.17.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -1
- package/dist/reports/src/artifact-query.d.ts +14 -2
- package/dist/reports/src/artifact-query.js +13 -4
- package/dist/reports/src/evidence-detail.js +19 -6
- package/dist/reports/src/evidence-types.d.ts +8 -0
- package/dist/reports/src/recording-receipt.d.ts +23 -0
- package/dist/reports/src/recording-receipt.js +16 -0
- package/dist/reports/src/recording-verify.d.ts +74 -0
- package/dist/reports/src/recording-verify.js +142 -0
- package/dist/reports/src/report-cli.d.ts +2 -2
- package/dist/reports/src/report-cli.js +53 -3
- package/dist/reports/src/test-catalog.d.ts +7 -1
- package/dist/reports/src/test-catalog.js +10 -4
- package/dist/schema/src/api.d.ts +134 -0
- package/dist/schema/src/api.js +13 -2
- package/dist/schema/src/cli-results.d.ts +51 -1
- package/dist/schema/src/cli-results.js +25 -1
- package/dist/server/src/openapi.js +1 -0
- package/docs/agents.md +1 -0
- package/docs/reference/cli.md +11 -2
- package/docs/reference/http-api.md +2 -0
- package/docs/reference/reports.md +189 -9
- package/docs/reference/schema.md +19 -1
- package/docs/rich-evidence.md +33 -5
- package/docs/server.md +1 -1
- package/docs/ui.md +40 -1
- package/llms-full.txt +88 -9
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +26 -0
- package/packages/schema/schemas/effective-evidence.schema.json +26 -0
- package/packages/schema/schemas/evidence-page.schema.json +13 -0
- package/packages/schema/schemas/layer-rows.schema.json +13 -0
- package/packages/schema/schemas/report-run.schema.json +83 -0
- package/packages/schema/schemas/report-runs.schema.json +86 -0
- package/packages/schema/schemas/review-collection.schema.json +13 -0
- package/packages/schema/schemas/review-manifest.schema.json +13 -0
- package/packages/schema/schemas/test-catalog.schema.json +13 -0
- package/packages/schema/schemas/test-history.schema.json +26 -0
- package/packages/schema/schemas/verify-recordings-result.schema.json +97 -0
- package/packages/server/openapi.json +378 -0
- package/packages/visualizer/src/app/AppShell.tsx +2 -2
- package/packages/visualizer/src/app/router.tsx +25 -1
- package/packages/visualizer/src/components/Header.tsx +15 -4
- package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
- package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
- package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
- package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
- package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
- package/packages/visualizer/src/evidence/behavior.ts +32 -1
- package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
- package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
- package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
- package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
- package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +7 -6
- package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
- package/packages/visualizer/src/explorer/RunsView.tsx +32 -7
- package/packages/visualizer/src/explorer/collections.tsx +5 -5
- package/packages/visualizer/src/explorer/filters.tsx +11 -3
- package/packages/visualizer/src/explorer/navigate.ts +17 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
- package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
- package/packages/visualizer/src/explorer/playlist.tsx +43 -8
- package/packages/visualizer/src/explorer/search.ts +13 -3
- package/packages/visualizer/src/find/Finder.tsx +1 -62
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog: @crvouga/atlas
|
|
2
2
|
|
|
3
|
-
## 1.
|
|
3
|
+
## 1.19.0 (2026-10-29)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- add a project-wide behavior index, a read-and-watch workspace and a scoped summary ([bb2d270](https://github.com/crvouga/atlas/commit/bb2d270fcfc2374c316db804e80af67885bdd8ee))
|
|
8
|
+
- restore the watch playlist from the address and show clip posters and lengths ([2dfc480](https://github.com/crvouga/atlas/commit/2dfc4806a72621f67f304cd9fda7619a9dbbea3f))
|
|
9
|
+
|
|
10
|
+
## 1.18.0 (2026-10-28)
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
- verify stored recordings and filter runs by recording completeness ([576d9ea](https://github.com/crvouga/atlas/commit/576d9ea3f8b1abfa45906e71c1cf329cae75d303))
|
|
15
|
+
|
|
16
|
+
## 1.17.0 (2026-10-28)
|
|
4
17
|
|
|
5
18
|
### Features
|
|
6
19
|
|
|
@@ -4,7 +4,7 @@ export type QueryFacetValue = {
|
|
|
4
4
|
readonly count: number;
|
|
5
5
|
};
|
|
6
6
|
/** The dimensions a query narrows by, with their address parameters. */
|
|
7
|
-
export declare const QUERY_DIMENSIONS: readonly ["artifactTag", "type", "label", "environment", "producer", "branch", "sha", "outcome", "domain", "spec"];
|
|
7
|
+
export declare const QUERY_DIMENSIONS: readonly ["artifactTag", "type", "label", "environment", "producer", "branch", "sha", "outcome", "domain", "spec", "recordings"];
|
|
8
8
|
/** One of {@link QUERY_DIMENSIONS}. */
|
|
9
9
|
export type QueryDimension = (typeof QUERY_DIMENSIONS)[number];
|
|
10
10
|
/** What a query asks for. Every field is optional; nothing asked is everything. See the module comment for the semantics. */
|
|
@@ -31,6 +31,12 @@ export type ArtifactFilter = {
|
|
|
31
31
|
readonly domain?: readonly string[];
|
|
32
32
|
/** Spec paths. Not applicable to runs. */
|
|
33
33
|
readonly spec?: readonly string[];
|
|
34
|
+
/**
|
|
35
|
+
* Recording completeness of a run: `complete`, `incomplete`, `none-expected` or `unknown` (a run whose layers declared no
|
|
36
|
+
* recording expectations is `unknown`, never `complete`). Answered from the layers' receipts alone, without reading any
|
|
37
|
+
* recording. Applies to runs only; it is independent of `outcome`.
|
|
38
|
+
*/
|
|
39
|
+
readonly recordings?: readonly string[];
|
|
34
40
|
/** Only records at or after this time (epoch ms): a result's recording time, a run's end. */
|
|
35
41
|
readonly since?: number;
|
|
36
42
|
/** Only records at or before this time (epoch ms). */
|
|
@@ -65,6 +71,8 @@ export type FilterFields = {
|
|
|
65
71
|
/** Absent when the dimension does not apply (runs have no domain). */
|
|
66
72
|
readonly domains?: readonly string[];
|
|
67
73
|
readonly specs?: readonly string[];
|
|
74
|
+
/** The recording completeness of a run (one value). Absent when the dimension does not apply (results). */
|
|
75
|
+
readonly recordings?: readonly string[];
|
|
68
76
|
readonly at: number;
|
|
69
77
|
};
|
|
70
78
|
/** The facets of a query: for each dimension, its values with disjunctive counts (see the module comment). */
|
|
@@ -78,6 +86,8 @@ export type QueryFacets = {
|
|
|
78
86
|
readonly branches: readonly QueryFacetValue[];
|
|
79
87
|
readonly outcomes: readonly QueryFacetValue[];
|
|
80
88
|
readonly domains?: readonly QueryFacetValue[];
|
|
89
|
+
/** Runs only: `complete`, `incomplete`, `none-expected` and `unknown`, with how many runs have each. */
|
|
90
|
+
readonly recordings?: readonly QueryFacetValue[];
|
|
81
91
|
};
|
|
82
92
|
/**
|
|
83
93
|
* Splits a comma-separated list from an address: `a,b` is two values; `\,` is a comma and `\\` a backslash inside a
|
|
@@ -102,9 +112,11 @@ export declare function parseArtifactFilter(params: URLSearchParams): ArtifactFi
|
|
|
102
112
|
export declare function filterParams(filter: ArtifactFilter): URLSearchParams;
|
|
103
113
|
/** Which filters a query uses, by the names an address gives them. */
|
|
104
114
|
export declare function usedFilters(filter: ArtifactFilter): FilterName[];
|
|
115
|
+
/** Every filter in `filter` that may be applied to a recorded result, as the artifacts query applies them (no recording completeness, which is a property of a run). */
|
|
116
|
+
export declare function forArtifacts(filter: ArtifactFilter): ArtifactFilter;
|
|
105
117
|
/** Whether a query narrows anything. */
|
|
106
118
|
export declare function isFiltered(filter: ArtifactFilter): boolean;
|
|
107
|
-
/** Every filter in `filter` that may be applied to a
|
|
119
|
+
/** Every filter in `filter` that may be applied to a run, as the runs query applies them (no domain or spec). */
|
|
108
120
|
export declare function forRuns(filter: ArtifactFilter): ArtifactFilter;
|
|
109
121
|
/**
|
|
110
122
|
* Whether a record satisfies a query. `skip` leaves one dimension out: what {@link facetsOf} uses to count the values of
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One shareable query for the runs of a project and for the recorded results that carry evidence (recordings,
|
|
5
5
|
* screenshots, traces): by artifact tag, label, media type, feature (domain, spec), environment or device, producer,
|
|
6
|
-
* branch, revision, outcome and
|
|
6
|
+
* branch, revision, outcome, time and (runs only) recording completeness. The same rules answer `GET /api/reports/runs`, `GET /api/reports/artifacts`
|
|
7
7
|
* and the viewer's Runs and Artifacts views, so a link means the same thing everywhere.
|
|
8
8
|
*
|
|
9
9
|
* The semantics are fixed, not inferred:
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { normalizeTag } from './artifact-tags.js';
|
|
28
28
|
/** The dimensions a query narrows by, with their address parameters. */
|
|
29
|
-
export const QUERY_DIMENSIONS = ['artifactTag', 'type', 'label', 'environment', 'producer', 'branch', 'sha', 'outcome', 'domain', 'spec'];
|
|
29
|
+
export const QUERY_DIMENSIONS = ['artifactTag', 'type', 'label', 'environment', 'producer', 'branch', 'sha', 'outcome', 'domain', 'spec', 'recordings'];
|
|
30
30
|
/**
|
|
31
31
|
* Splits a comma-separated list from an address: `a,b` is two values; `\,` is a comma and `\\` a backslash inside a
|
|
32
32
|
* value. Values are trimmed and empty ones dropped.
|
|
@@ -133,11 +133,16 @@ export function usedFilters(filter) {
|
|
|
133
133
|
used.push('video');
|
|
134
134
|
return used;
|
|
135
135
|
}
|
|
136
|
+
/** Every filter in `filter` that may be applied to a recorded result, as the artifacts query applies them (no recording completeness, which is a property of a run). */
|
|
137
|
+
export function forArtifacts(filter) {
|
|
138
|
+
const { recordings: _recordings, ...rest } = filter;
|
|
139
|
+
return rest;
|
|
140
|
+
}
|
|
136
141
|
/** Whether a query narrows anything. */
|
|
137
142
|
export function isFiltered(filter) {
|
|
138
143
|
return usedFilters(filter).some(name => name !== 'inherited');
|
|
139
144
|
}
|
|
140
|
-
/** Every filter in `filter` that may be applied to a
|
|
145
|
+
/** Every filter in `filter` that may be applied to a run, as the runs query applies them (no domain or spec). */
|
|
141
146
|
export function forRuns(filter) {
|
|
142
147
|
const { domain: _domain, spec: _spec, ...rest } = filter;
|
|
143
148
|
return rest;
|
|
@@ -186,6 +191,8 @@ export function matchesFilter(fields, filter, skip) {
|
|
|
186
191
|
return false;
|
|
187
192
|
if (skip !== 'spec' && filter.spec?.length && fields.specs && !filter.spec.some(value => fields.specs.includes(value)))
|
|
188
193
|
return false;
|
|
194
|
+
if (skip !== 'recordings' && filter.recordings?.length && fields.recordings && !filter.recordings.some(value => fields.recordings.includes(value)))
|
|
195
|
+
return false;
|
|
189
196
|
if (filter.since !== undefined && fields.at < filter.since)
|
|
190
197
|
return false;
|
|
191
198
|
if (filter.until !== undefined && fields.at > filter.until)
|
|
@@ -213,6 +220,7 @@ export function facetsOf(records, filter) {
|
|
|
213
220
|
const dimension = (skip, pick, chosen) => withChosen(tally(scope(skip).map(pick)), chosen);
|
|
214
221
|
const normalTags = (filter.artifactTag ?? []).map(tag => normalizeTag(tag)).filter((tag) => tag !== null);
|
|
215
222
|
const domains = records.some(record => record.domains);
|
|
223
|
+
const recordings = records.some(record => record.recordings);
|
|
216
224
|
return {
|
|
217
225
|
artifactTags: dimension('artifactTag', fields => fields.artifactTags, normalTags),
|
|
218
226
|
types: dimension('type', fields => fields.types, filter.type),
|
|
@@ -221,7 +229,8 @@ export function facetsOf(records, filter) {
|
|
|
221
229
|
producers: dimension('producer', fields => fields.producers, filter.producer),
|
|
222
230
|
branches: dimension('branch', fields => fields.branches, filter.branch),
|
|
223
231
|
outcomes: dimension('outcome', fields => fields.outcomes, filter.outcome),
|
|
224
|
-
...(domains ? { domains: dimension('domain', fields => fields.domains ?? [], filter.domain) } : {})
|
|
232
|
+
...(domains ? { domains: dimension('domain', fields => fields.domains ?? [], filter.domain) } : {}),
|
|
233
|
+
...(recordings ? { recordings: dimension('recordings', fields => fields.recordings ?? [], filter.recordings) } : {})
|
|
225
234
|
};
|
|
226
235
|
}
|
|
227
236
|
/** The outcome of a run from its results by status: `failed`, else `running`, else `passed`, else `not-run`. */
|
|
@@ -62,7 +62,7 @@ function summarize(holders, owner, inherited, firstIsParent = false, unstored) {
|
|
|
62
62
|
for (const tag of normalizeTags(item.tags))
|
|
63
63
|
artifactTags.add(tag);
|
|
64
64
|
roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
|
|
65
|
-
const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes), unstored);
|
|
65
|
+
const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes), unstored, list);
|
|
66
66
|
if (video)
|
|
67
67
|
videos.push(video);
|
|
68
68
|
}
|
|
@@ -110,14 +110,24 @@ export function isVideoAttachment(attachment) {
|
|
|
110
110
|
export function attachmentIdentity(item) {
|
|
111
111
|
return item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
|
|
112
112
|
}
|
|
113
|
-
|
|
113
|
+
/** The bundle key of the image an attachment names as its poster among its siblings, when the layer's bundle holds it. */
|
|
114
|
+
function posterKey(item, siblings, unstored) {
|
|
115
|
+
if (item.poster === undefined)
|
|
116
|
+
return undefined;
|
|
117
|
+
const poster = siblings?.find(other => other !== item && other.id === item.poster);
|
|
118
|
+
if (!poster?.bundleKey || unstored?.has(poster.bundleKey))
|
|
119
|
+
return undefined;
|
|
120
|
+
return (poster.contentType ?? contentTypeOf(poster.name)).toLowerCase().startsWith('image/') ? poster.bundleKey : undefined;
|
|
121
|
+
}
|
|
122
|
+
function videoOf(item, owner, where, scope, unstored, siblings) {
|
|
114
123
|
if (!isVideoAttachment(item))
|
|
115
124
|
return undefined;
|
|
116
125
|
// A bundle key the layer's bundle does not hold names bytes that are not there: only an address or inline text can still carry them, and the digest of lost bytes identifies nothing playable.
|
|
117
126
|
const lost = item.bundleKey !== undefined && unstored?.has(item.bundleKey) === true;
|
|
118
127
|
const recording = lost ? attachmentIdentity({ name: item.name, uri: item.uri, text: item.text }) : attachmentIdentity(item);
|
|
119
128
|
const tags = normalizeTags(item.tags);
|
|
120
|
-
|
|
129
|
+
const poster = posterKey(item, siblings, unstored);
|
|
130
|
+
return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined, ...(lost && recording === undefined ? { reason: 'not-in-bundle' } : {}), ...(item.durationMs !== undefined && Number.isFinite(item.durationMs) ? { durationMs: item.durationMs } : {}), ...(poster === undefined ? {} : { poster }), ...(scope ? { scope } : {}) };
|
|
121
131
|
}
|
|
122
132
|
/**
|
|
123
133
|
* Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
|
|
@@ -127,14 +137,17 @@ export function videoCounts(videos) {
|
|
|
127
137
|
if (!videos?.length)
|
|
128
138
|
return undefined;
|
|
129
139
|
const distinct = new Set();
|
|
130
|
-
let unavailable = 0;
|
|
140
|
+
let unavailable = 0, missing = 0;
|
|
131
141
|
for (const video of videos) {
|
|
132
142
|
if (video.available && video.recording !== undefined)
|
|
133
143
|
distinct.add(video.recording);
|
|
134
|
-
else
|
|
144
|
+
else {
|
|
135
145
|
unavailable += 1;
|
|
146
|
+
if (video.reason === 'not-in-bundle')
|
|
147
|
+
missing += 1;
|
|
148
|
+
}
|
|
136
149
|
}
|
|
137
|
-
return { references: videos.length, recordings: distinct.size, unavailable };
|
|
150
|
+
return { references: videos.length, recordings: distinct.size, unavailable, ...(missing ? { missing } : {}) };
|
|
138
151
|
}
|
|
139
152
|
/** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
|
|
140
153
|
export function roleOfType(contentType) {
|
|
@@ -58,6 +58,12 @@ export type VideoRef = {
|
|
|
58
58
|
readonly recording?: string;
|
|
59
59
|
/** False when the reference names no bytes at all (no bundle key, address or inline text), or a bundle key that the layer's own bundle does not hold, so it can never be played. */
|
|
60
60
|
readonly available: boolean;
|
|
61
|
+
/** Why it is unavailable, when the report can tell: `not-in-bundle` names a file the layer should hold (its bundle key) but does not, a recording that was acknowledged and is no longer there. Absent otherwise, and for an available video. An open vocabulary: treat an unknown reason as plain unavailable. */
|
|
62
|
+
readonly reason?: 'not-in-bundle';
|
|
63
|
+
/** How long the recording is, in milliseconds, as its producer declared (`Attachment.durationMs`): a hint to show before the file is opened, never measured here and never a latency. Absent when none was declared, and in documents of older servers. */
|
|
64
|
+
readonly durationMs?: number;
|
|
65
|
+
/** The bundle key, in the same layer, of the image the producer named as this recording's poster (`Attachment.poster`): a hint to show before the file is opened. Only an image the layer's bundle holds; absent when there is none, and in documents of older servers. */
|
|
66
|
+
readonly poster?: string;
|
|
61
67
|
/** What this recording shows, as declared for it: its own `scope`, else its attempt's, check's, part's or layer's. Absent when none declared one: its scope is unknown, not live, physical or audible. */
|
|
62
68
|
readonly scope?: EvidenceScope;
|
|
63
69
|
};
|
|
@@ -69,6 +75,8 @@ export type VideoCounts = {
|
|
|
69
75
|
readonly recordings: number;
|
|
70
76
|
/** References that name no bytes, or bytes the layer does not hold. A report that predates availability metadata has none of these and none of the others. */
|
|
71
77
|
readonly unavailable: number;
|
|
78
|
+
/** Of `unavailable`, those that name a file the layer's bundle does not hold (stored, then gone). Absent when there are none. */
|
|
79
|
+
readonly missing?: number;
|
|
72
80
|
};
|
|
73
81
|
/**
|
|
74
82
|
* What a result (or a run) carries beyond its outcome, as counts and short facts: attachments, steps,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* @see docs/rich-evidence.md#recording-completeness
|
|
12
12
|
*/
|
|
13
13
|
import type { RecordingCompleteness, RecordingReceipt } from '../../schema/src/layer.js';
|
|
14
|
+
import type { RecordingCounts } from './evidence-types.js';
|
|
14
15
|
type Loose = Record<string, unknown>;
|
|
15
16
|
/** Is this attachment a screen recording: a role of `video`, or a `video/*` media type? */
|
|
16
17
|
export declare function isVideoAttachment(attachment: Loose): boolean;
|
|
@@ -45,4 +46,26 @@ export type RecordingResult = {
|
|
|
45
46
|
* nothing was. Gaps are sorted so the output never depends on read order.
|
|
46
47
|
*/
|
|
47
48
|
export declare function recordingCompleteness(results: readonly RecordingResult[]): RecordingCompleteness;
|
|
49
|
+
/** A run's recording completeness, from the receipts of its layers (see {@link runRecordings}). */
|
|
50
|
+
export type RunRecordings = {
|
|
51
|
+
/** `incomplete`: a recording is skipped or unresolved. `unknown`: no gap is known, but a layer declared no expectations. `complete`: everything expected was published. `none-expected`: every layer declared expectations and none required a recording. */
|
|
52
|
+
readonly state: 'complete' | 'incomplete' | 'none-expected' | 'unknown';
|
|
53
|
+
/** Recordings the layers expected. */
|
|
54
|
+
readonly expected: number;
|
|
55
|
+
/** Of those, uploaded or referenced by address. */
|
|
56
|
+
readonly published: number;
|
|
57
|
+
readonly skipped: number;
|
|
58
|
+
readonly unresolved: number;
|
|
59
|
+
/** Intentionally unrecorded checks: neither expected nor missing. */
|
|
60
|
+
readonly notApplicable: number;
|
|
61
|
+
/** Layers of the run that declared no expectations: nothing is claimed for them. */
|
|
62
|
+
readonly layersWithoutReceipt: number;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The recording completeness of a run, from the `recordings` counts of each of its layers (`undefined` for a layer that
|
|
66
|
+
* has no receipt). `undefined` when no layer has a receipt: the run claims nothing, which a reader treats as `unknown`.
|
|
67
|
+
* A gap always wins (`incomplete`); otherwise a layer without a receipt keeps the run `unknown`, never `complete`. Reads
|
|
68
|
+
* only the counts, never a recording, and never changes a status.
|
|
69
|
+
*/
|
|
70
|
+
export declare function runRecordings(layers: readonly (RecordingCounts | undefined)[]): RunRecordings | undefined;
|
|
48
71
|
export {};
|
|
@@ -101,3 +101,19 @@ export function recordingCompleteness(results) {
|
|
|
101
101
|
const state = gaps.length ? 'incomplete' : without.size || !results.length ? 'unknown' : count.expected ? 'complete' : 'none-expected';
|
|
102
102
|
return { state, ...count, layersWithoutReceipt: without.size, gaps };
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The recording completeness of a run, from the `recordings` counts of each of its layers (`undefined` for a layer that
|
|
106
|
+
* has no receipt). `undefined` when no layer has a receipt: the run claims nothing, which a reader treats as `unknown`.
|
|
107
|
+
* A gap always wins (`incomplete`); otherwise a layer without a receipt keeps the run `unknown`, never `complete`. Reads
|
|
108
|
+
* only the counts, never a recording, and never changes a status.
|
|
109
|
+
*/
|
|
110
|
+
export function runRecordings(layers) {
|
|
111
|
+
const receipts = layers.filter((layer) => layer !== undefined);
|
|
112
|
+
if (!receipts.length)
|
|
113
|
+
return undefined;
|
|
114
|
+
const sum = (pick) => receipts.reduce((total, counts) => total + pick(counts), 0);
|
|
115
|
+
const skipped = sum(counts => counts.skipped), unresolved = sum(counts => counts.unresolved), expected = sum(counts => counts.expected);
|
|
116
|
+
const layersWithoutReceipt = layers.length - receipts.length;
|
|
117
|
+
const state = skipped + unresolved > 0 ? 'incomplete' : layersWithoutReceipt > 0 ? 'unknown' : expected > 0 ? 'complete' : 'none-expected';
|
|
118
|
+
return { state, expected, published: sum(counts => counts.uploaded + counts.referenced), skipped, unresolved, notApplicable: sum(counts => counts.notApplicable), layersWithoutReceipt };
|
|
119
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ObjectStore } from './object-store.js';
|
|
2
|
+
import type { ReportLayer } from './layer-types.js';
|
|
3
|
+
/** What is wrong with a stored recording. An open vocabulary: a reader treats an unknown problem as a gap. */
|
|
4
|
+
export type RecordingProblem =
|
|
5
|
+
/** The layer names the file but its bundle does not list it: stored, then gone (or never arrived). */
|
|
6
|
+
'not-in-bundle'
|
|
7
|
+
/** The bundle lists the file but a blob holding its bytes is no longer in the store (a 404 when played). */
|
|
8
|
+
| 'blob-missing'
|
|
9
|
+
/** The store could not be read just now. Not proof the bytes are gone, and not a pass. */
|
|
10
|
+
| 'unreadable'
|
|
11
|
+
/** The stored bytes are not as many as were recorded. */
|
|
12
|
+
| 'size-mismatch'
|
|
13
|
+
/** The stored bytes do not hash to the SHA-256 recorded at upload. */
|
|
14
|
+
| 'checksum-mismatch'
|
|
15
|
+
/** The stored bytes do not begin like the container their media type names, or are empty: incompatible or corrupt. */
|
|
16
|
+
| 'undecodable';
|
|
17
|
+
/** One video of a layer that failed verification. */
|
|
18
|
+
export type RecordingGap = {
|
|
19
|
+
/** The check that holds the video, or `null` for a video attached to the layer or a part. */
|
|
20
|
+
readonly check: string | null;
|
|
21
|
+
/** The attachment's name. */
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly bundleKey: string;
|
|
24
|
+
readonly problem: RecordingProblem;
|
|
25
|
+
/** A short, non-secret explanation: sizes or the container found. */
|
|
26
|
+
readonly reason: string;
|
|
27
|
+
};
|
|
28
|
+
/** What verifying one layer found. */
|
|
29
|
+
export type LayerRecordingVerification = {
|
|
30
|
+
readonly layer: string;
|
|
31
|
+
/** Videos read back from the store: those that name a bundle key. */
|
|
32
|
+
readonly checked: number;
|
|
33
|
+
/** Of those, the ones that passed every check. */
|
|
34
|
+
readonly ok: number;
|
|
35
|
+
/** Videos given only by `uri`, which Atlas cannot read back. */
|
|
36
|
+
readonly referenced: number;
|
|
37
|
+
readonly gaps: readonly RecordingGap[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Whether the first bytes of a stored video are the container its media type names, as a reason it is not, or `null` when
|
|
41
|
+
* it looks right or the type is one this check has no signature for (then nothing is claimed). Recognizes WebM and
|
|
42
|
+
* Matroska (EBML), MP4, QuickTime and 3GPP (an ISO box), Ogg and AVI. An empty file is never a video.
|
|
43
|
+
*/
|
|
44
|
+
export declare function containerProblem(bytes: Uint8Array, contentType: string): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Reads back every video of `layer` that is kept in its bundle and reports the ones that are no longer retrievable or no
|
|
47
|
+
* longer look like what was published (see {@link RecordingProblem}). Each blob is read once however many checks
|
|
48
|
+
* reference it. A store that fails to answer is `unreadable`, never a pass. It only reads: nothing is written, and no
|
|
49
|
+
* status changes.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const layer = await readLayer(store, id);
|
|
54
|
+
* const { gaps } = await verifyLayerRecordings(store, layer!);
|
|
55
|
+
* gaps.map(gap => `${gap.check}: ${gap.problem}`);
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export declare function verifyLayerRecordings(store: ObjectStore, layer: ReportLayer): Promise<LayerRecordingVerification>;
|
|
59
|
+
/** What verifying the layers of one store found. */
|
|
60
|
+
export type StoreRecordingVerification = {
|
|
61
|
+
readonly layers: readonly LayerRecordingVerification[];
|
|
62
|
+
/** Layers that could not be read at all (their manifest is unreadable or gone): their recordings are unknown, not verified. */
|
|
63
|
+
readonly notices: readonly string[];
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Verifies the recordings of the layers a store lists, newest first. `ids` narrows to layers whose id starts with one of
|
|
67
|
+
* them (at least 7 hex digits); `limit` keeps only that many of the newest. A layer that cannot be read is a notice, never
|
|
68
|
+
* skipped silently and never counted as verified. A store that cannot be listed rejects.
|
|
69
|
+
*/
|
|
70
|
+
export declare function verifyStoreRecordings(store: ObjectStore, options?: {
|
|
71
|
+
readonly ids?: readonly string[];
|
|
72
|
+
readonly limit?: number;
|
|
73
|
+
readonly include?: (layer: ReportLayer) => boolean;
|
|
74
|
+
}): Promise<StoreRecordingVerification>;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createBundleReader } from './bundle.js';
|
|
2
|
+
import { isVideoAttachment } from './evidence-detail.js';
|
|
3
|
+
import { contentTypeOf } from './media-types.js';
|
|
4
|
+
import { LAYER_INDEX, readLayer } from './layer-store.js';
|
|
5
|
+
import { sha256Hex } from './sigv4.js';
|
|
6
|
+
const MP4_BOXES = new Set(['ftyp', 'moov', 'mdat', 'free', 'wide', 'skip', 'pnot']);
|
|
7
|
+
const ascii = (bytes, from, to) => String.fromCharCode(...bytes.subarray(from, to));
|
|
8
|
+
/**
|
|
9
|
+
* Whether the first bytes of a stored video are the container its media type names, as a reason it is not, or `null` when
|
|
10
|
+
* it looks right or the type is one this check has no signature for (then nothing is claimed). Recognizes WebM and
|
|
11
|
+
* Matroska (EBML), MP4, QuickTime and 3GPP (an ISO box), Ogg and AVI. An empty file is never a video.
|
|
12
|
+
*/
|
|
13
|
+
export function containerProblem(bytes, contentType) {
|
|
14
|
+
if (!bytes.length)
|
|
15
|
+
return 'the file is empty';
|
|
16
|
+
const type = contentType.toLowerCase();
|
|
17
|
+
const found = () => `its first bytes are not ${type}`;
|
|
18
|
+
if (type.includes('webm') || type.includes('matroska'))
|
|
19
|
+
return bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3 ? null : found();
|
|
20
|
+
if (type.includes('mp4') || type.includes('quicktime') || type.includes('3gpp') || type.includes('m4v'))
|
|
21
|
+
return bytes.length >= 8 && MP4_BOXES.has(ascii(bytes, 4, 8)) ? null : found();
|
|
22
|
+
if (type.includes('ogg'))
|
|
23
|
+
return ascii(bytes, 0, 4) === 'OggS' ? null : found();
|
|
24
|
+
if (type.includes('msvideo'))
|
|
25
|
+
return ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'AVI ' ? null : found();
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** Every video attachment of a holder, its steps and its attempts. */
|
|
29
|
+
function videosOf(holder, check) {
|
|
30
|
+
const found = [];
|
|
31
|
+
const take = (list) => { for (const attachment of list ?? [])
|
|
32
|
+
if (isVideoAttachment(attachment))
|
|
33
|
+
found.push({ check, attachment }); };
|
|
34
|
+
take(holder.attachments);
|
|
35
|
+
for (const step of holder.steps ?? [])
|
|
36
|
+
take(step.attachments);
|
|
37
|
+
for (const attempt of holder.attempts ?? []) {
|
|
38
|
+
take(attempt.attachments);
|
|
39
|
+
for (const step of attempt.steps ?? [])
|
|
40
|
+
take(step.attachments);
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reads back every video of `layer` that is kept in its bundle and reports the ones that are no longer retrievable or no
|
|
46
|
+
* longer look like what was published (see {@link RecordingProblem}). Each blob is read once however many checks
|
|
47
|
+
* reference it. A store that fails to answer is `unreadable`, never a pass. It only reads: nothing is written, and no
|
|
48
|
+
* status changes.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const layer = await readLayer(store, id);
|
|
53
|
+
* const { gaps } = await verifyLayerRecordings(store, layer!);
|
|
54
|
+
* gaps.map(gap => `${gap.check}: ${gap.problem}`);
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export async function verifyLayerRecordings(store, layer) {
|
|
58
|
+
const holders = [...videosOf(layer, null)];
|
|
59
|
+
for (const part of layer.parts) {
|
|
60
|
+
holders.push(...videosOf(part, null));
|
|
61
|
+
for (const check of part.checks)
|
|
62
|
+
holders.push(...videosOf(check, check.id));
|
|
63
|
+
}
|
|
64
|
+
const read = createBundleReader(store, layer.bundle);
|
|
65
|
+
const bytesOf = new Map();
|
|
66
|
+
const load = (key) => {
|
|
67
|
+
let pending = bytesOf.get(key);
|
|
68
|
+
if (!pending) {
|
|
69
|
+
pending = read(key).then(entry => entry ?? 'missing', () => 'unreadable');
|
|
70
|
+
bytesOf.set(key, pending);
|
|
71
|
+
}
|
|
72
|
+
return pending;
|
|
73
|
+
};
|
|
74
|
+
const gaps = [];
|
|
75
|
+
let checked = 0, ok = 0, referenced = 0;
|
|
76
|
+
for (const { check, attachment } of holders) {
|
|
77
|
+
const key = attachment.bundleKey;
|
|
78
|
+
if (key === undefined) {
|
|
79
|
+
if (attachment.uri !== undefined)
|
|
80
|
+
referenced += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
checked += 1;
|
|
84
|
+
const gap = (problem, reason) => { gaps.push({ check, name: attachment.name, bundleKey: key, problem, reason }); };
|
|
85
|
+
if (!Object.hasOwn(layer.bundle, key)) {
|
|
86
|
+
gap('not-in-bundle', 'the layer names this file but its bundle does not list it');
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const stored = await load(key);
|
|
90
|
+
if (stored === 'unreadable') {
|
|
91
|
+
gap('unreadable', 'the store could not be read');
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (stored === 'missing') {
|
|
95
|
+
gap('blob-missing', 'a blob holding this file is no longer in the store');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (attachment.bytes !== undefined && attachment.bytes !== stored.bytes.length) {
|
|
99
|
+
gap('size-mismatch', `${stored.bytes.length} bytes stored, ${attachment.bytes} recorded`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (attachment.sha256 !== undefined && attachment.sha256 !== await sha256Hex(stored.bytes)) {
|
|
103
|
+
gap('checksum-mismatch', 'the stored bytes do not match the recorded SHA-256');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const problem = containerProblem(stored.bytes, attachment.contentType ?? (stored.contentType || contentTypeOf(attachment.name)));
|
|
107
|
+
if (problem) {
|
|
108
|
+
gap('undecodable', problem);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
ok += 1;
|
|
112
|
+
}
|
|
113
|
+
return { layer: layer.id, checked, ok, referenced, gaps };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Verifies the recordings of the layers a store lists, newest first. `ids` narrows to layers whose id starts with one of
|
|
117
|
+
* them (at least 7 hex digits); `limit` keeps only that many of the newest. A layer that cannot be read is a notice, never
|
|
118
|
+
* skipped silently and never counted as verified. A store that cannot be listed rejects.
|
|
119
|
+
*/
|
|
120
|
+
export async function verifyStoreRecordings(store, options = {}) {
|
|
121
|
+
const keys = await store.listKeys(LAYER_INDEX);
|
|
122
|
+
const wanted = options.ids?.length ? options.ids.map(id => id.toLowerCase()) : null;
|
|
123
|
+
const ids = keys.flatMap(key => { const id = /-([0-9a-f]{64})\.json$/.exec(key)?.[1]; return id && (!wanted || wanted.some(prefix => id.startsWith(prefix))) ? [id] : []; });
|
|
124
|
+
const layers = [];
|
|
125
|
+
const notices = [];
|
|
126
|
+
for (const id of ids.slice(0, options.limit ?? ids.length)) {
|
|
127
|
+
try {
|
|
128
|
+
const layer = await readLayer(store, id);
|
|
129
|
+
if (!layer) {
|
|
130
|
+
notices.push(`layer ${id.slice(0, 12)} is listed but its manifest is missing`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (options.include && !options.include(layer))
|
|
134
|
+
continue;
|
|
135
|
+
layers.push(await verifyLayerRecordings(store, layer));
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
notices.push(`layer ${id.slice(0, 12)} could not be read: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { layers, notices };
|
|
142
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module The `atlas report` command
|
|
3
3
|
*
|
|
4
|
-
* `reportMain` is `atlas report publish | stream | status | merge | doctor | policy`, written so it can be called from code and
|
|
4
|
+
* `reportMain` is `atlas report publish | stream | status | verify-recordings | merge | doctor | policy`, written so it can be called from code and
|
|
5
5
|
* tests as well as a terminal: it takes its arguments, environment, working directory, clock, git and standard input as
|
|
6
6
|
* parameters and returns the exit code. Every command can print a JSON document (`--format json`) instead of prose, and a
|
|
7
7
|
* command that is wrong throws, which `runReportCommand` turns into a message (or a JSON error) and exit code 2.
|
|
@@ -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 [--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
|
|
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 verify-recordings [--store <dir|s3://\u2026|id=url>]... [--backends <module>] [--layer <id>]...\n [--limit <n>] [--branch <name>]... [--ignore-modified] [--format text|json]\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 verify-recordings Read back the videos the layers published and check they are still there as published: the\n layer still lists the file, its blob is still stored, its size and SHA-256 are the recorded ones, and it\n begins like the container its media type names. Exit 1 when any video is missing, unreadable, corrupt or\n not that container, naming the layer and check; test results are never changed. This checks stored bytes,\n not playback in a browser. Videos given only by address are counted, not checked.\n --layer <id> only the layer whose id starts with this (repeatable; at least 7 digits)\n --limit <n> only the newest n layers\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, status and verify-recordings print one JSON document instead of prose\n (schemas: publish-result, stream-result, merge-result, doctor-result, report-status, verify-recordings-result). 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. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module The `atlas report` command
|
|
3
3
|
*
|
|
4
|
-
* `reportMain` is `atlas report publish | stream | status | merge | doctor | policy`, written so it can be called from code and
|
|
4
|
+
* `reportMain` is `atlas report publish | stream | status | verify-recordings | merge | doctor | policy`, written so it can be called from code and
|
|
5
5
|
* tests as well as a terminal: it takes its arguments, environment, working directory, clock, git and standard input as
|
|
6
6
|
* parameters and returns the exit code. Every command can print a JSON document (`--format json`) instead of prose, and a
|
|
7
7
|
* command that is wrong throws, which `runReportCommand` turns into a message (or a JSON error) and exit code 2.
|
|
@@ -28,6 +28,7 @@ import { parseWith } from './parse-with.js';
|
|
|
28
28
|
import { publishChecks } from './publish-checks.js';
|
|
29
29
|
import { createReporter } from './reporter.js';
|
|
30
30
|
import { formatStatusMarkdown, formatStatusText, reportStatus } from './report-status.js';
|
|
31
|
+
import { verifyStoreRecordings } from './recording-verify.js';
|
|
31
32
|
import { publishRunBundle } from './run-bundle.js';
|
|
32
33
|
import { collectSpecFiles, latestSpecSet, publishSpecSet } from './spec-set.js';
|
|
33
34
|
import { linkChecksToSpecs } from './spec-link.js';
|
|
@@ -52,6 +53,8 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
|
|
|
52
53
|
[--specs <dir>] [--format text|json|markdown]
|
|
53
54
|
[--require-environments a,b] [--require-all-specs]
|
|
54
55
|
[--branch <name>]... [--ignore-modified]
|
|
56
|
+
atlas report verify-recordings [--store <dir|s3://…|id=url>]... [--backends <module>] [--layer <id>]...
|
|
57
|
+
[--limit <n>] [--branch <name>]... [--ignore-modified] [--format text|json]
|
|
55
58
|
atlas report merge [--store …]... [--backends <module>] --into <dir|s3://…>
|
|
56
59
|
atlas report doctor [--store …]... [--role publisher|reader|both]
|
|
57
60
|
atlas report policy --store s3://bucket/prefix [--role publisher|reader|both]
|
|
@@ -95,6 +98,13 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
|
|
|
95
98
|
--require-all-specs fail while any spec has no results
|
|
96
99
|
--branch <name> only layers recorded on this branch (repeatable)
|
|
97
100
|
--ignore-modified leave out layers from modified checkouts and unknown origins
|
|
101
|
+
verify-recordings Read back the videos the layers published and check they are still there as published: the
|
|
102
|
+
layer still lists the file, its blob is still stored, its size and SHA-256 are the recorded ones, and it
|
|
103
|
+
begins like the container its media type names. Exit 1 when any video is missing, unreadable, corrupt or
|
|
104
|
+
not that container, naming the layer and check; test results are never changed. This checks stored bytes,
|
|
105
|
+
not playback in a browser. Videos given only by address are counted, not checked.
|
|
106
|
+
--layer <id> only the layer whose id starts with this (repeatable; at least 7 digits)
|
|
107
|
+
--limit <n> only the newest n layers
|
|
98
108
|
merge Copy the layers of several stores into one.
|
|
99
109
|
doctor Check that a store works: credentials, listing, writing, and what it holds.
|
|
100
110
|
policy Print the least-privilege IAM policy for publishers, readers, or both.
|
|
@@ -103,8 +113,8 @@ export const REPORT_USAGE = `atlas report — layered evidence for any kind of s
|
|
|
103
113
|
{ id, label?, store } (or a function returning it): a device lab, anything
|
|
104
114
|
that implements ObjectStore.
|
|
105
115
|
|
|
106
|
-
--format json (alias --json) publish, stream, merge, doctor and
|
|
107
|
-
(schemas: publish-result, stream-result, merge-result, doctor-result, report-status). A command
|
|
116
|
+
--format json (alias --json) publish, stream, merge, doctor, status and verify-recordings print one JSON document instead of prose
|
|
117
|
+
(schemas: publish-result, stream-result, merge-result, doctor-result, report-status, verify-recordings-result). A command
|
|
108
118
|
that is wrong is {"ok":false,"error":{"message":…,"code":"usage"}} on standard error.
|
|
109
119
|
A store may be an Atlas server (https://…): ATLAS_STORE=https://atlas.example.com, with ATLAS_TOKEN.
|
|
110
120
|
|
|
@@ -475,6 +485,46 @@ export async function reportMain(argv, io = { out: text => process.stdout.write(
|
|
|
475
485
|
io.out(format === 'json' ? `${JSON.stringify(status, null, 2)}\n` : format === 'markdown' ? formatStatusMarkdown(status) : formatStatusText(status));
|
|
476
486
|
return status.ok ? 0 : 1;
|
|
477
487
|
}
|
|
488
|
+
if (command === 'verify-recordings') {
|
|
489
|
+
const format = value(rest, 'format') ?? (rest.includes('--json') ? 'json' : 'text');
|
|
490
|
+
if (!['text', 'json'].includes(format))
|
|
491
|
+
throw new Error(`--format takes text|json (got "${format}")`);
|
|
492
|
+
const ids = values(rest, 'layer');
|
|
493
|
+
const short = ids.find(id => !/^[0-9a-f]{7,64}$/i.test(id));
|
|
494
|
+
if (short)
|
|
495
|
+
throw new Error(`--layer takes at least 7 hexadecimal digits of a layer id (got "${short}")`);
|
|
496
|
+
const limitText = value(rest, 'limit');
|
|
497
|
+
if (limitText !== undefined && !/^\d+$/.test(limitText))
|
|
498
|
+
throw new Error(`--limit takes a whole number (got "${limitText}")`);
|
|
499
|
+
const include = layerFilter(rest, env);
|
|
500
|
+
let layers = 0, checked = 0, verified = 0, referenced = 0;
|
|
501
|
+
const gaps = [];
|
|
502
|
+
const notices = [];
|
|
503
|
+
for (const backend of await backendsOf(rest, base, env)) {
|
|
504
|
+
const found = await verifyStoreRecordings(backend.store, { ids, ...(limitText !== undefined ? { limit: Number(limitText) } : {}), ...(include ? { include } : {}) });
|
|
505
|
+
for (const layer of found.layers) {
|
|
506
|
+
layers += 1;
|
|
507
|
+
checked += layer.checked;
|
|
508
|
+
verified += layer.ok;
|
|
509
|
+
referenced += layer.referenced;
|
|
510
|
+
gaps.push(...layer.gaps.map(gap => ({ ...gap, layer: layer.layer })));
|
|
511
|
+
}
|
|
512
|
+
notices.push(...found.notices);
|
|
513
|
+
}
|
|
514
|
+
const ok = gaps.length === 0 && notices.length === 0;
|
|
515
|
+
if (format === 'json') {
|
|
516
|
+
io.out(`${JSON.stringify({ ok, layers, checked, verified, referenced, gaps, notices }, null, 2)}\n`);
|
|
517
|
+
return ok ? 0 : 1;
|
|
518
|
+
}
|
|
519
|
+
io.out(`Recordings in ${layers} layer${layers === 1 ? '' : 's'}: ${verified} of ${checked} stored video${checked === 1 ? '' : 's'} verified${referenced ? `, ${referenced} given by address (not checked)` : ''}\n`);
|
|
520
|
+
for (const gap of gaps)
|
|
521
|
+
io.out(`! ${gap.problem}: ${gap.check ?? 'the run'} / ${gap.name} in layer ${gap.layer.slice(0, 12)} (${gap.reason})\n`);
|
|
522
|
+
for (const notice of notices)
|
|
523
|
+
io.out(`! ${notice}\n`);
|
|
524
|
+
if (!ok)
|
|
525
|
+
io.out('Test results are unchanged. Publish the missing recordings again to repair the gaps.\n');
|
|
526
|
+
return ok ? 0 : 1;
|
|
527
|
+
}
|
|
478
528
|
if (command === 'merge') {
|
|
479
529
|
const into = value(rest, 'into');
|
|
480
530
|
if (!into)
|