@crvouga/atlas 1.20.0 → 1.21.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 (33) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/core/src/cli.d.ts +1 -1
  3. package/dist/core/src/cli.js +3 -0
  4. package/dist/reports/src/compose.d.ts +23 -1
  5. package/dist/reports/src/compose.js +29 -19
  6. package/dist/reports/src/report-cli.d.ts +13 -0
  7. package/dist/reports/src/report-cli.js +6 -4
  8. package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
  9. package/dist/reports/src/ui-catalog-cli.js +391 -0
  10. package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
  11. package/dist/reports/src/ui-catalog-diff.js +130 -0
  12. package/dist/reports/src/ui-catalog-export.d.ts +82 -0
  13. package/dist/reports/src/ui-catalog-export.js +221 -0
  14. package/dist/schema/src/cli-results.d.ts +318 -1
  15. package/dist/schema/src/cli-results.js +89 -1
  16. package/docs/agents.md +5 -0
  17. package/docs/reference/cli.md +56 -1
  18. package/docs/reference/reports.md +392 -0
  19. package/docs/reference/schema.md +41 -1
  20. package/docs/ui-catalog.md +106 -12
  21. package/docs/ui.md +6 -1
  22. package/llms-full.txt +170 -11
  23. package/package.json +1 -1
  24. package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
  25. package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
  26. package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
  27. package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
  28. package/packages/visualizer/src/catalog/Picture.tsx +32 -0
  29. package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
  30. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
  31. package/packages/visualizer/src/catalog/history.ts +61 -0
  32. package/packages/visualizer/src/catalog/search.ts +14 -3
  33. package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
@@ -0,0 +1,391 @@
1
+ /**
2
+ * @module The `atlas ui-catalog` command
3
+ *
4
+ * `uiCatalogMain` is `atlas ui-catalog plan | publish | status | diff | export`: the UI catalog ({@link ./ui-catalog}) without
5
+ * a script. `plan` and `publish` turn a small JSON file (and the pictures beside it) into the layers of the expected set and of
6
+ * the captures; `status` reads every store and says whether the catalog is complete and free of failures; `diff` sets two
7
+ * commits against each other; `export` writes the catalog as one HTML file that needs no server. Like `atlas report`, it is
8
+ * written so it can be called from code and tests: it takes its arguments, environment, working directory, clock and git as
9
+ * parameters and returns the exit code, every command prints a JSON document with `--format json`, and a command that is wrong
10
+ * throws, which `runUiCatalogCommand` turns into a message (or a JSON error) and exit code 2.
11
+ *
12
+ * Nothing here knows how a picture was taken: the files it reads are the same data any collector can write. Publishing a
13
+ * catalog to a hosting platform is not a command: it would need that platform's credentials and API, which Atlas does not
14
+ * hold. The file `export` writes, or a folder of them, is what to hand to whatever hosts static files.
15
+ *
16
+ * Exit codes: 0 it worked, 1 it ran and the answer is no (an incomplete catalog, a failed capture, an unreadable store, a
17
+ * difference under `--fail-on-change`), 2 the command itself was wrong.
18
+ *
19
+ * @see docs/ui-catalog.md
20
+ * @see docs/agents.md
21
+ */
22
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import { z } from 'zod/v4';
25
+ import { readAttachmentFiles } from './attachment-files.js';
26
+ import { backendsOf, layerFilter, value, values, writeStoreOf } from './cli-stores.js';
27
+ import { readBackendLayers } from './compose.js';
28
+ import { distinctEvidence } from './layer-merge.js';
29
+ import { readLayerFile } from './layer-store.js';
30
+ import { parseWith } from './parse-with.js';
31
+ import { publishChecks } from './publish-checks.js';
32
+ import { detectFreshness, detectOrigin, executionOf, keyValue, parseTime, wantsJson } from './report-cli.js';
33
+ import { filterUiCatalog, parseUiCatalogQuery, uiCatalog, uiCatalogMarkdown } from './ui-catalog.js';
34
+ import { uiPlanLayer, uiStateLayer } from './ui-catalog-publish.js';
35
+ import { diffUiCatalogs, layersAtCommit, uiCatalogCommits } from './ui-catalog-diff.js';
36
+ import { gatherUiPictures, uiCatalogHtml, uiPicturesWanted } from './ui-catalog-export.js';
37
+ /** The text `atlas ui-catalog --help` prints: every subcommand, its flags, what each does and the exit codes. Also the source of its entry in `docs/reference/cli.md`. */
38
+ export const UI_CATALOG_USAGE = `atlas ui-catalog — a browsable inventory of captured UI states, from any collector
39
+
40
+ atlas ui-catalog plan --states <file.json> --producer <name> [--store <dir|s3://bucket/prefix|https://…>]
41
+ [--environment <name>] [--param key=value]... [--shard <label>]
42
+ [--branch <b>] [--sha <s>] [--run-url <u>] [--run-id <id>] [--run-title <t>] [--recorded-at <iso|ms>]
43
+ atlas ui-catalog publish --captures <file.json> --producer <name> [the same flags as plan]
44
+ [--attach-root <dir>]... [--max-picture-mb <n>]
45
+ atlas ui-catalog status [--store <dir|s3://…|id=url>]... [--backends <module>] [filters] [--branch <name>]...
46
+ [--ignore-modified] [--format text|json|markdown]
47
+ atlas ui-catalog diff --from <commit> --to <commit> [--store …]... [--backends <module>] [filters]
48
+ [--fail-on-change] [--format text|json|markdown]
49
+ atlas ui-catalog export --out <file.html> [--store …]... [--backends <module>] [filters]
50
+ [--images inline|none] [--max-mb <n>] [--manifest <file.json>] [--title <t>]
51
+
52
+ filters (status, diff, export): --q <words> --coverage a,b --profile <key|label>... --area a --route r --cohort c
53
+ --flag f --surface s --producer p
54
+
55
+ plan Write one layer of kind ui-plan: the states (and with "inventory": true, the surfaces) that should exist under
56
+ this profile. Every entry is not-run, so a plan never counts as a pass. The file is {"states":[{"id":…,
57
+ "title"?,"route"?,"fixture"?,"cohort"?,"area"?,"flags"?,"covers"?,"inventory"?}]} or just that list.
58
+ publish Write one layer of kind ui-state: the states one collector or shard captured under this profile. The file is
59
+ {"states":[{"id":…,"title"?,"status"?:"passed|failed|running|not-run","detail"?,"route"?,"fixture"?,"cohort"?,
60
+ "area"?,"flags"?,"covers"?,"labels"?,"tags"?,"durationMs"?,"screenshot"?:"file.png"|{"path"|"uri",
61
+ "width"?,"height"?,"contentType"?}}]}. A picture file is read from beside the file and each --attach-root, never
62
+ from a hidden file or a link out. A passed state whose picture cannot be read is published as failed, with the
63
+ reason: a state without its picture is not shown as captured.
64
+ --environment and each --param key=value are the profile (a viewport, a color scheme, a device): another
65
+ profile is another result side by side. --shard only describes the layer; it never splits a result.
66
+ --recorded-at defaults to the modification time of the file, so publishing the same file twice is one layer.
67
+ status Read every store and print the catalog's summary. Exit 1 unless the catalog holds a state and there is nothing
68
+ to attend to: a failed capture, a state a plan expects with nothing captured, a capture still in progress, a
69
+ backend or layer that could not be read. Without a plan nothing is expected, so nothing is missing.
70
+ --profile takes a profile's key or its label, as printed by status.
71
+ diff Set the catalog recorded at one commit (--from) against another (--to): each state in each profile is paired
72
+ with itself and is unchanged, changed in coverage, changed in picture (the stored bytes differ), unverified (a
73
+ side has no content hash), only in --to (added) or only in --from (removed). A commit is the whole id or at
74
+ least 7 digits of it. Exit 0 unless a store could not be read, or, with --fail-on-change, anything differs.
75
+ export Write the catalog as one HTML file with its pictures embedded and its manifest in it: no server, no network.
76
+ It is the catalog at the moment it was made. --images none leaves the pictures out. --max-mb (default 32) limits
77
+ the embedded pictures; those past it are listed in the page as left out, and the manifest stays complete.
78
+ --manifest writes the catalog's JSON manifest too.
79
+
80
+ --format json (alias --json) every subcommand prints one JSON document instead of prose
81
+ (schemas: ui-catalog-publish-result, ui-catalog-status, ui-catalog-diff-result, ui-catalog-export-result).
82
+ A command that is wrong is {"ok":false,"error":{"message":…,"code":"usage"}} on standard error.
83
+ A store is a directory, s3://bucket/prefix or an Atlas server (https://…), exactly as for atlas report; select a project
84
+ with --project <slug> or ATLAS_PROJECT. When no --store is given, ATLAS_STORE is used.
85
+
86
+ Exit codes: 0 ok, 1 the answer is no (see each command), 2 the command was wrong.
87
+ `;
88
+ const Facts = {
89
+ route: z.string().optional(), fixture: z.string().optional(), cohort: z.string().optional(), area: z.string().optional(),
90
+ flags: z.array(z.string()).optional(), covers: z.array(z.string()).optional()
91
+ };
92
+ const PlanState = z.strictObject({ id: z.string().min(1), title: z.string().optional(), inventory: z.boolean().optional(), ...Facts });
93
+ const Picture = z.union([
94
+ z.string().min(1),
95
+ z.strictObject({ path: z.string().min(1).optional(), uri: z.string().min(1).optional(), name: z.string().optional(), contentType: z.string().optional(), width: z.number().int().positive().optional(), height: z.number().int().positive().optional() })
96
+ .refine(picture => (picture.path === undefined) !== (picture.uri === undefined), 'give exactly one of path and uri')
97
+ ]);
98
+ const CaptureState = z.strictObject({
99
+ id: z.string().min(1), title: z.string().optional(), status: z.enum(['passed', 'failed', 'running', 'not-run']).optional(), detail: z.string().optional(),
100
+ durationMs: z.number().nonnegative().optional(), labels: z.record(z.string(), z.string()).optional(), tags: z.array(z.string()).optional(), screenshot: Picture.optional(), ...Facts
101
+ });
102
+ const PlanFile = z.union([z.array(PlanState), z.strictObject({ states: z.array(PlanState) }).transform(file => file.states)]);
103
+ const CaptureFile = z.union([z.array(CaptureState), z.strictObject({ states: z.array(CaptureState) }).transform(file => file.states)]);
104
+ async function readJson(file, base) {
105
+ const at = path.resolve(base, file);
106
+ let text, info;
107
+ try {
108
+ [text, info] = await Promise.all([readFile(at, 'utf8'), stat(at)]);
109
+ }
110
+ catch {
111
+ throw new Error(`Cannot read ${file}: it is not there, or not a file`);
112
+ }
113
+ try {
114
+ return { json: JSON.parse(text), at, modifiedAtMs: Math.floor(info.mtimeMs) };
115
+ }
116
+ catch {
117
+ throw new Error(`${file} is not JSON`);
118
+ }
119
+ }
120
+ /** The profile and provenance every layer of `plan` and `publish` carries, from the flags, the CI's variables and git. */
121
+ function layerOrigin(rest, env, io, recordedAtMs) {
122
+ const producer = value(rest, 'producer');
123
+ if (!producer)
124
+ throw new Error('give --producer <name>: a stable name for this collector, such as "web-capture" or "ui-plan"');
125
+ const origin = detectOrigin(env, io.git);
126
+ const sha = value(rest, 'sha') ?? origin.sha;
127
+ const freshness = detectFreshness({ sha, ci: origin.ci && !value(rest, 'sha') }, io.git);
128
+ const environment = value(rest, 'environment');
129
+ const run = executionOf(rest, env);
130
+ const shard = value(rest, 'shard');
131
+ return {
132
+ producer, recordedAtMs, sha, branch: value(rest, 'branch') ?? origin.branch, runUrl: value(rest, 'run-url') ?? origin.runUrl,
133
+ ...(environment ? { environment } : {}), profile: Object.fromEntries(values(rest, 'param').map(pair => keyValue(pair, '--param'))),
134
+ ...(shard ? { shard } : {}), ...(run ? { run: { id: run.id, ...(run.title ? { title: run.title } : {}) } } : {}),
135
+ freshness
136
+ };
137
+ }
138
+ const FILTERS = ['coverage', 'profile', 'area', 'route', 'cohort', 'flag', 'surface', 'producer'];
139
+ /** The catalog filters on the command line: `--q` and the lists. A profile may be named by key or by label. */
140
+ function queryOf(rest, catalog) {
141
+ const params = new URLSearchParams();
142
+ const q = value(rest, 'q');
143
+ if (q)
144
+ params.set('q', q);
145
+ for (const name of FILTERS)
146
+ for (const given of values(rest, name))
147
+ params.append(name, given);
148
+ const query = parseUiCatalogQuery(params);
149
+ const coverage = values(rest, 'coverage').flatMap(list => list.split(',')).map(item => item.trim()).filter(Boolean);
150
+ const unknown = coverage.find(item => !['captured', 'failed', 'capturing', 'skipped', 'missing'].includes(item));
151
+ if (unknown)
152
+ throw new Error(`--coverage takes captured, failed, capturing, skipped or missing (got "${unknown}")`);
153
+ if (!query.profile?.length)
154
+ return query;
155
+ const profiles = values(rest, 'profile').map(given => {
156
+ const found = catalog.profiles.find(profile => profile.key === given || profile.label === given);
157
+ if (!found)
158
+ throw new Error(`--profile "${given}" is not a profile of this catalog. Profiles: ${catalog.profiles.map(profile => `"${profile.label}"`).join(', ') || 'none'}`);
159
+ return found.key;
160
+ });
161
+ return { ...query, profile: profiles };
162
+ }
163
+ async function layersOf(rest, base, env) {
164
+ const backends = await backendsOf(rest, base, env);
165
+ const read = await readBackendLayers(backends);
166
+ const include = layerFilter(rest, env);
167
+ const all = distinctEvidence(read.loaded.map(item => item.evidence)).filter(entry => !include || include(entry.layer));
168
+ return { backends, layers: all, info: read.backends, notices: read.notices };
169
+ }
170
+ const sentence = (text) => text.replace(/\s+/g, ' ').trim();
171
+ /** `atlas ui-catalog …`. Returns the exit code. Throws on a mistake in the command (see {@link runUiCatalogCommand}). */
172
+ export async function uiCatalogMain(argv, io = { out: text => process.stdout.write(text) }) {
173
+ const [command, ...rest] = argv;
174
+ const base = io.cwd ?? process.cwd();
175
+ const env = { ...(io.env ?? process.env), ...(value(rest, 'project') !== undefined ? { ATLAS_PROJECT: value(rest, 'project') } : {}) };
176
+ const json = wantsJson(rest);
177
+ const format = value(rest, 'format') ?? (rest.includes('--json') ? 'json' : 'text');
178
+ if (!['text', 'json', 'markdown'].includes(format))
179
+ throw new Error(`--format takes text|json|markdown (got "${format}")`);
180
+ if (command === 'plan' || command === 'publish') {
181
+ const flag = command === 'plan' ? 'states' : 'captures';
182
+ const file = value(rest, flag);
183
+ if (!file)
184
+ throw new Error(`${command} needs --${flag} <file.json>`);
185
+ const target = writeStoreOf(value(rest, 'store'), base, env);
186
+ const given = await readJson(file, base);
187
+ const recordedAtMs = parseTime(value(rest, 'recorded-at')) ?? given.modifiedAtMs;
188
+ const origin = layerOrigin(rest, env, io, recordedAtMs);
189
+ const { freshness, ...layerFields } = origin;
190
+ const warnings = [];
191
+ let input;
192
+ let counts = { states: 0, captured: 0, failed: 0, pictures: 0, pictureBytes: 0 };
193
+ if (command === 'plan') {
194
+ const states = parseWith(PlanFile, given.json, file);
195
+ counts = { ...counts, states: states.length };
196
+ input = uiPlanLayer({ ...layerFields, states });
197
+ }
198
+ else {
199
+ const parsed = parseWith(CaptureFile, given.json, file);
200
+ const roots = [path.dirname(given.at), ...values(rest, 'attach-root').map(root => path.resolve(base, root))];
201
+ const maxBytes = Number(value(rest, 'max-picture-mb') ?? 64) * 1024 * 1024;
202
+ const states = [];
203
+ for (const state of parsed) {
204
+ const { screenshot, ...kept } = state;
205
+ let capture = kept;
206
+ const picture = typeof screenshot === 'string' ? { path: screenshot } : screenshot;
207
+ if (picture?.uri !== undefined)
208
+ capture = { ...capture, screenshot: { uri: picture.uri, ...(picture.contentType ? { contentType: picture.contentType } : {}), ...(picture.width ? { width: picture.width } : {}), ...(picture.height ? { height: picture.height } : {}) } };
209
+ else if (picture?.path !== undefined) {
210
+ const read = await readAttachmentFiles({ attachments: [{ name: picture.name ?? path.basename(picture.path), role: 'screenshot', path: picture.path, ...(picture.contentType ? { contentType: picture.contentType } : {}) }] }, roots, { maxBytes });
211
+ const data = read.value.attachments[0];
212
+ if (data?.data) {
213
+ counts.pictures += 1;
214
+ counts.pictureBytes += data.data.length;
215
+ capture = { ...capture, screenshot: { name: picture.name ?? path.basename(picture.path), data: data.data, ...(data.contentType ? { contentType: data.contentType } : {}), ...(picture.width ? { width: picture.width } : {}), ...(picture.height ? { height: picture.height } : {}) } };
216
+ }
217
+ else {
218
+ warnings.push(...read.warnings.map(warning => `${state.id}: ${warning}`));
219
+ // A state whose picture is missing is not a capture: it is shown as failed, never as captured without a picture.
220
+ if ((capture.status ?? 'passed') === 'passed')
221
+ capture = { ...capture, status: 'failed', detail: sentence(`The picture could not be read: ${read.warnings[0] ?? picture.path}${capture.detail ? `. ${capture.detail}` : ''}`) };
222
+ }
223
+ }
224
+ states.push(capture);
225
+ }
226
+ counts = { ...counts, states: states.length, captured: states.filter(state => (state.status ?? 'passed') === 'passed').length, failed: states.filter(state => state.status === 'failed').length };
227
+ input = uiStateLayer({ ...layerFields, states });
228
+ }
229
+ const layer = await publishChecks(target.store, { ...input, ...(freshness ? { freshness } : {}) });
230
+ const result = {
231
+ ok: true, store: target.label,
232
+ layer: { id: layer.id, kind: command === 'plan' ? 'ui-plan' : 'ui-state', producer: origin.producer, environment: layer.environment ?? null, parameters: { ...origin.profile }, ...counts, shard: origin.shard ?? null },
233
+ warnings
234
+ };
235
+ if (json)
236
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
237
+ else {
238
+ io.out(command === 'plan'
239
+ ? `Published plan ${layer.id.slice(0, 12)}: ${counts.states} expected state${counts.states === 1 ? '' : 's'} as "${origin.producer}"${origin.environment ? ` on ${origin.environment}` : ''}\n`
240
+ : `Published layer ${layer.id.slice(0, 12)}: ${counts.states} state${counts.states === 1 ? '' : 's'} (${counts.captured} captured, ${counts.failed} failed, ${counts.pictures} picture${counts.pictures === 1 ? '' : 's'}) as "${origin.producer}"${origin.environment ? ` on ${origin.environment}` : ''}\n`);
241
+ for (const warning of warnings)
242
+ io.out(`! ${warning}\n`);
243
+ }
244
+ return 0;
245
+ }
246
+ if (command === 'status') {
247
+ const read = await layersOf(rest, base, env);
248
+ const whole = uiCatalog(read.layers, { backends: read.info, notices: read.notices });
249
+ const catalog = filterUiCatalog(whole, queryOf(rest, whole));
250
+ const problems = [];
251
+ for (const state of catalog.states)
252
+ for (const cell of state.cells) {
253
+ if (cell.coverage === 'failed' || cell.coverage === 'missing' || cell.coverage === 'capturing') {
254
+ problems.push({ kind: cell.coverage, state: state.id, profile: cell.profile.label, ...(cell.capture?.detail ? { detail: cell.capture.detail } : {}) });
255
+ }
256
+ }
257
+ for (const backend of read.info)
258
+ if (!backend.available)
259
+ problems.push({ kind: 'unavailable', detail: `${backend.label} could not be read` });
260
+ for (const notice of read.notices)
261
+ if (!read.info.some(backend => !backend.available && notice.startsWith(`${backend.label} is unavailable`)))
262
+ problems.push({ kind: 'unreadable', detail: notice });
263
+ if (whole.states.length === 0)
264
+ problems.push({ kind: 'empty', detail: 'No UI states have been recorded' });
265
+ const result = {
266
+ ok: problems.length === 0, summary: catalog.summary, profiles: whole.profiles.map(profile => ({ key: profile.key, label: profile.label })), problems,
267
+ sources: { layers: catalog.sources.layers, backends: read.info.map(backend => ({ id: backend.id, label: backend.label, available: backend.available })), notices: [...read.notices] }
268
+ };
269
+ if (format === 'json')
270
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
271
+ else if (format === 'markdown')
272
+ io.out(uiCatalogMarkdown(catalog));
273
+ else {
274
+ const { summary } = catalog;
275
+ io.out(`UI catalog: ${summary.states} state${summary.states === 1 ? '' : 's'} in ${summary.profiles} profile${summary.profiles === 1 ? '' : 's'}; ${['captured', 'failed', 'capturing', 'skipped', 'missing'].map(name => `${summary.coverage[name]} ${name === 'missing' ? 'not captured yet' : name}`).join(', ')}\n`);
276
+ if (!summary.planned)
277
+ io.out('No plan was published, so what should exist is unknown and nothing can be missing.\n');
278
+ for (const problem of problems.slice(0, 50))
279
+ io.out(`! ${problem.kind}${problem.state ? `: ${problem.state}${problem.profile ? ` (${problem.profile})` : ''}` : ''}${problem.detail ? ` — ${problem.detail}` : ''}\n`);
280
+ if (problems.length > 50)
281
+ io.out(`! … and ${problems.length - 50} more (use --format json)\n`);
282
+ io.out(result.ok ? 'Complete.\n' : 'Needs attention.\n');
283
+ }
284
+ return result.ok ? 0 : 1;
285
+ }
286
+ if (command === 'diff') {
287
+ const from = value(rest, 'from'), to = value(rest, 'to');
288
+ if (!from || !to)
289
+ throw new Error('diff needs --from <commit> and --to <commit>');
290
+ const read = await layersOf(rest, base, env);
291
+ const commits = uiCatalogCommits(read.layers.map(entry => entry.layer));
292
+ const options = { backends: read.info, notices: read.notices };
293
+ let beforeLayers, afterLayers;
294
+ try {
295
+ beforeLayers = layersAtCommit(read.layers, from);
296
+ afterLayers = layersAtCommit(read.layers, to);
297
+ }
298
+ catch (error) {
299
+ throw new Error(`${error instanceof Error ? error.message : String(error)}. Commits with layers: ${commits.slice(0, 10).map(commit => commit.sha.slice(0, 12)).join(', ') || 'none'}`);
300
+ }
301
+ const before = uiCatalog(beforeLayers, options), after = uiCatalog(afterLayers, options);
302
+ const query = queryOf(rest, uiCatalog(read.layers, options));
303
+ const diff = diffUiCatalogs(filterUiCatalog(before, query), filterUiCatalog(after, query));
304
+ const side = (layers, catalog) => ({ sha: layers[0].layer.sha, states: catalog.states.length, layers: catalog.sources.layers });
305
+ const changes = diff.entries.filter(entry => entry.change !== 'unchanged').map(entry => ({
306
+ state: entry.id, title: entry.title, profile: (entry.profile ?? entry.before?.profile ?? entry.after?.profile).label, change: entry.change,
307
+ from: entry.before?.coverage ?? null, to: entry.after?.coverage ?? null
308
+ }));
309
+ const { total, ...counts } = diff.summary;
310
+ const failing = rest.includes('--fail-on-change') && changes.length > 0;
311
+ const result = { ok: read.notices.length === 0 && !failing, from: side(beforeLayers, before), to: side(afterLayers, after), summary: { ...counts, total }, changes, notices: [...read.notices] };
312
+ if (format === 'json')
313
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
314
+ else if (format === 'markdown') {
315
+ io.out(`# UI catalog: ${result.from.sha.slice(0, 12)} to ${result.to.sha.slice(0, 12)}\n\n${changes.length} of ${total} state profiles differ.\n\n${changes.map(change => `- **${change.title}** (${change.state}, ${change.profile}): ${change.change}${change.from || change.to ? ` — ${change.from ?? 'absent'} to ${change.to ?? 'absent'}` : ''}`).join('\n')}${changes.length ? '\n' : ''}`);
316
+ }
317
+ else {
318
+ io.out(`UI catalog ${result.from.sha.slice(0, 12)} (${result.from.states} states) to ${result.to.sha.slice(0, 12)} (${result.to.states} states): ${counts.coverage} coverage, ${counts.picture} picture, ${counts.unverified} unverified, ${counts.added} added, ${counts.removed} removed, ${counts.unchanged} unchanged\n`);
319
+ for (const change of changes.slice(0, 50))
320
+ io.out(` ${change.change.padEnd(10)} ${change.state} (${change.profile})${change.change === 'coverage' ? ` ${change.from} → ${change.to}` : ''}\n`);
321
+ if (changes.length > 50)
322
+ io.out(` … and ${changes.length - 50} more (use --format json)\n`);
323
+ for (const notice of read.notices)
324
+ io.out(`! ${notice}\n`);
325
+ }
326
+ return result.ok ? 0 : 1;
327
+ }
328
+ if (command === 'export') {
329
+ const out = value(rest, 'out');
330
+ if (!out)
331
+ throw new Error('export needs --out <file.html>');
332
+ const images = value(rest, 'images') ?? 'inline';
333
+ if (!['inline', 'none'].includes(images))
334
+ throw new Error(`--images takes inline|none (got "${images}")`);
335
+ const maxMb = Number(value(rest, 'max-mb') ?? 32);
336
+ if (!Number.isFinite(maxMb) || maxMb < 0)
337
+ throw new Error(`--max-mb takes a number of megabytes (got "${value(rest, 'max-mb')}")`);
338
+ const read = await layersOf(rest, base, env);
339
+ const whole = uiCatalog(read.layers, { backends: read.info, notices: read.notices });
340
+ const catalog = filterUiCatalog(whole, queryOf(rest, whole));
341
+ const warnings = [...read.notices];
342
+ const all = uiPicturesWanted(catalog);
343
+ const gathered = await gatherUiPictures(images === 'none' ? [] : all, async (want) => {
344
+ const backend = read.backends.find(item => item.id === want.backend);
345
+ return backend ? (await readLayerFile(backend.store, want.layer, want.bundleKey))?.bytes ?? null : null;
346
+ }, { maxBytes: maxMb * 1024 * 1024 });
347
+ // With --images none every picture is left out, and the page says it was asked to.
348
+ const left = images === 'none' ? new Map(all.map(want => [want.key, 'was left out because the export was made with --images none'])) : gathered.left;
349
+ if (images !== 'none')
350
+ for (const [key, reason] of left)
351
+ warnings.push(`picture ${key.slice(0, 12)}…: ${reason}`);
352
+ const html = uiCatalogHtml(catalog, { pictures: gathered.pictures, left, ...(value(rest, 'title') ? { title: value(rest, 'title') } : {}), generatedAtMs: (io.clock ?? Date.now)() });
353
+ const target = path.resolve(base, out);
354
+ await mkdir(path.dirname(target), { recursive: true });
355
+ await writeFile(target, html);
356
+ const manifestFile = value(rest, 'manifest');
357
+ if (manifestFile) {
358
+ const manifest = path.resolve(base, manifestFile);
359
+ await mkdir(path.dirname(manifest), { recursive: true });
360
+ await writeFile(manifest, `${JSON.stringify(catalog, null, 2)}\n`);
361
+ }
362
+ const result = {
363
+ ok: true, out, bytes: Buffer.byteLength(html), states: catalog.summary.states, cells: catalog.summary.cells,
364
+ pictures: { embedded: gathered.pictures.size, embeddedBytes: gathered.bytes, left: left.size }, manifest: manifestFile ?? null, warnings
365
+ };
366
+ if (json)
367
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
368
+ else {
369
+ io.out(`Wrote ${out}: ${result.states} states, ${gathered.pictures.size} pictures embedded (${(gathered.bytes / 1024 / 1024).toFixed(1)} MiB), ${(result.bytes / 1024 / 1024).toFixed(1)} MiB in all\n`);
370
+ for (const warning of warnings)
371
+ io.out(`! ${warning}\n`);
372
+ }
373
+ return 0;
374
+ }
375
+ io.out(UI_CATALOG_USAGE);
376
+ return command && command !== '--help' && command !== '-h' ? 2 : 0;
377
+ }
378
+ /**
379
+ * `atlas ui-catalog …` for a terminal: a mistake in the command or a path is a message on stderr and exit code 2, so a CI job can
380
+ * tell a broken invocation (2) from a catalog that needs attention (1). With `--format json` the mistake is a JSON document.
381
+ */
382
+ export async function runUiCatalogCommand(argv) {
383
+ try {
384
+ return await uiCatalogMain(argv);
385
+ }
386
+ catch (error) {
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ process.stderr.write(wantsJson(argv) ? `${JSON.stringify({ ok: false, error: { message, code: 'usage' } })}\n` : `atlas ui-catalog: ${message}\n`);
389
+ return 2;
390
+ }
391
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @module UI catalog comparison
3
+ *
4
+ * What changed in the UI catalog between two commits, or between two profiles, without claiming more than the evidence
5
+ * supports. Pure functions of {@link UiCatalog} documents (and of the layers they came from), safe in a browser; nothing here
6
+ * changes a status, a slot or a winner.
7
+ *
8
+ * - **Two commits.** The catalog of a commit is the catalog of the layers recorded at that commit ({@link uiCatalogAtCommit}).
9
+ * A state is paired with itself in the same profile. A layer replaces the older one of its slot, so the effective report no
10
+ * longer holds an earlier commit's captures: compare from the layers themselves ({@link readBackendLayers}), which keep them.
11
+ * - **Two profiles.** Within one catalog, each state's cell in one profile is set beside its cell in another
12
+ * ({@link diffUiProfiles}): desktop against mobile, light against dark.
13
+ * - **Pictures are compared by content.** Two captures have the same picture when their stored bytes have the same SHA-256.
14
+ * A different hash means different bytes, which is not a measure of how different the pixels are. When a side has no
15
+ * content hash (a picture given only by address, or left out), whether it changed is `unverified`, never "unchanged".
16
+ * - **A state only one side has** is `added` or `removed`. It may be new or renamed, or the other side's shard may not have
17
+ * run, and the comparison cannot tell which: it is not called a regression.
18
+ * - **A capture that did not pass is not a picture.** A cell that failed, is being taken or was skipped on a side is a change of
19
+ * `coverage`, never compared by picture.
20
+ *
21
+ * @see docs/ui-catalog.md
22
+ */
23
+ import type { LayerEvidence } from './evidence-types.js';
24
+ import { type UiCatalog, type UiCatalogOptions, type UiCell, type UiImage, type UiProfile } from './ui-catalog.js';
25
+ /** The `schemaVersion` of the comparison document. */
26
+ export declare const UI_DIFF_VERSION = 1;
27
+ /**
28
+ * How one pair of cells stands. `added`: only the after side has the cell. `removed`: only the before side has it.
29
+ * `coverage`: both have it and it stands differently (captured against failed, for example). `picture`: both captured and
30
+ * the bytes differ. `unverified`: both captured, but a side has no content hash, so whether the picture changed is unknown.
31
+ * `unchanged`: the same coverage and, when captured, the same bytes.
32
+ */
33
+ export type UiCellChange = 'added' | 'removed' | 'coverage' | 'picture' | 'unverified' | 'unchanged';
34
+ /** The values of {@link UiCellChange}, in the order a review reads them. */
35
+ export declare const UI_CELL_CHANGES: readonly ["coverage", "picture", "unverified", "added", "removed", "unchanged"];
36
+ /** One state in one profile on each side. */
37
+ export type UiDiffEntry = {
38
+ /** The state's id. */
39
+ readonly id: string;
40
+ /** Its title, from the after side when it has the state. */
41
+ readonly title: string;
42
+ /** The profile both sides are in (a comparison of commits), or null when the two sides are different profiles. */
43
+ readonly profile: UiProfile | null;
44
+ readonly before: UiCell | null;
45
+ readonly after: UiCell | null;
46
+ readonly change: UiCellChange;
47
+ };
48
+ /** A comparison, as `atlas ui-catalog diff --format json` prints it and the viewer shows it. */
49
+ export type UiCatalogDiff = {
50
+ readonly schemaVersion: typeof UI_DIFF_VERSION;
51
+ /** How many entries are in each {@link UiCellChange}. */
52
+ readonly summary: Readonly<Record<UiCellChange, number>> & {
53
+ readonly total: number;
54
+ };
55
+ /** Every pair, ordered by state id and then profile label. */
56
+ readonly entries: readonly UiDiffEntry[];
57
+ };
58
+ /**
59
+ * What identifies a picture's content: its recorded SHA-256, else the hash in a content-addressed bundle key
60
+ * (`attachments/<sha256>`), else `null`. A picture given only by address, or left out, has no identity here: its content is
61
+ * not Atlas's to vouch for.
62
+ */
63
+ export declare function pictureIdentity(image: UiImage | null | undefined): string | null;
64
+ /**
65
+ * Sets the catalog of an earlier commit (`before`) against a later one (`after`): each state in each profile is paired with
66
+ * itself. A cell that is only a plan (`missing`) on one side is a change of coverage like any other. The order of the
67
+ * states in either catalog does not matter.
68
+ */
69
+ export declare function diffUiCatalogs(before: UiCatalog, after: UiCatalog): UiCatalogDiff;
70
+ /**
71
+ * Sets one profile of a catalog (`from`, a {@link UiProfile.key}) against another (`to`): each state's cell in `from` beside its
72
+ * cell in `to`. A state that has a cell in only one of them is `added` or `removed`. Throws when the two are the same profile,
73
+ * because that compares a catalog with itself.
74
+ */
75
+ export declare function diffUiProfiles(catalog: UiCatalog, from: string, to: string): UiCatalogDiff;
76
+ /** The entries of a comparison that changed, or all of them. A change the comparison cannot verify is a change worth looking at, so it is kept. */
77
+ export declare function changedUiEntries(diff: UiCatalogDiff): readonly UiDiffEntry[];
78
+ /** A commit that has layers of the catalog's producers. */
79
+ export type UiCommit = {
80
+ readonly sha: string;
81
+ /** The branch of the newest layer recorded at the commit, or null. */
82
+ readonly branch: string | null;
83
+ /** When the newest layer at the commit was recorded, epoch milliseconds. */
84
+ readonly recordedAtMs: number;
85
+ /** How many layers were recorded at the commit. */
86
+ readonly layers: number;
87
+ };
88
+ /**
89
+ * The commits layers were recorded at, newest first. A layer with no commit is left out: it cannot be compared by commit.
90
+ * Layers recorded from a checkout with uncommitted changes (`freshness` `modified`) are not the commit's own result and are
91
+ * left out too, as the gate does by default for comparisons.
92
+ */
93
+ export declare function uiCatalogCommits(layers: readonly Pick<LayerEvidence['layer'], 'sha' | 'branch' | 'recordedAtMs' | 'freshness'>[]): UiCommit[];
94
+ /**
95
+ * The layers recorded at one commit (`sha` is the whole commit id, or a prefix of at least 7 hexadecimal digits that names
96
+ * exactly one commit among `layers`). Throws when a prefix names none, or more than one, so a comparison never silently uses
97
+ * the wrong commit.
98
+ */
99
+ export declare function layersAtCommit(layers: readonly LayerEvidence[], sha: string): LayerEvidence[];
100
+ /**
101
+ * The UI catalog as it was recorded at one commit: only the layers recorded there take part. A state the commit's layers do
102
+ * not hold is absent, not failed. See {@link layersAtCommit} for how `sha` is matched.
103
+ */
104
+ export declare function uiCatalogAtCommit(layers: readonly LayerEvidence[], sha: string, options?: UiCatalogOptions): UiCatalog;
@@ -0,0 +1,130 @@
1
+ import { uiCatalog } from './ui-catalog.js';
2
+ /** The `schemaVersion` of the comparison document. */
3
+ export const UI_DIFF_VERSION = 1;
4
+ /** The values of {@link UiCellChange}, in the order a review reads them. */
5
+ export const UI_CELL_CHANGES = ['coverage', 'picture', 'unverified', 'added', 'removed', 'unchanged'];
6
+ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
7
+ const SHA256 = /^[0-9a-f]{64}$/;
8
+ /**
9
+ * What identifies a picture's content: its recorded SHA-256, else the hash in a content-addressed bundle key
10
+ * (`attachments/<sha256>`), else `null`. A picture given only by address, or left out, has no identity here: its content is
11
+ * not Atlas's to vouch for.
12
+ */
13
+ export function pictureIdentity(image) {
14
+ if (!image || image.omitted)
15
+ return null;
16
+ const hash = image.sha256?.toLowerCase();
17
+ if (hash && SHA256.test(hash))
18
+ return hash;
19
+ const keyed = /^attachments\/([0-9a-fA-F]{64})$/.exec(image.bundleKey ?? '')?.[1];
20
+ return keyed ? keyed.toLowerCase() : null;
21
+ }
22
+ function changeOf(before, after) {
23
+ if (!before)
24
+ return 'added';
25
+ if (!after)
26
+ return 'removed';
27
+ if (before.coverage !== after.coverage)
28
+ return 'coverage';
29
+ if (before.coverage !== 'captured')
30
+ return 'unchanged';
31
+ const a = pictureIdentity(before.capture?.image), b = pictureIdentity(after.capture?.image);
32
+ if (a === null || b === null)
33
+ return 'unverified';
34
+ return a === b ? 'unchanged' : 'picture';
35
+ }
36
+ function summarize(entries) {
37
+ const counts = { coverage: 0, picture: 0, unverified: 0, added: 0, removed: 0, unchanged: 0 };
38
+ for (const entry of entries)
39
+ counts[entry.change] += 1;
40
+ return { ...counts, total: entries.length };
41
+ }
42
+ function ordered(entries) {
43
+ entries.sort((a, b) => compare(a.id, b.id) || compare(a.profile?.label ?? '', b.profile?.label ?? '') || compare(a.before?.profile.label ?? '', b.before?.profile.label ?? ''));
44
+ return { schemaVersion: UI_DIFF_VERSION, summary: summarize(entries), entries };
45
+ }
46
+ /**
47
+ * Sets the catalog of an earlier commit (`before`) against a later one (`after`): each state in each profile is paired with
48
+ * itself. A cell that is only a plan (`missing`) on one side is a change of coverage like any other. The order of the
49
+ * states in either catalog does not matter.
50
+ */
51
+ export function diffUiCatalogs(before, after) {
52
+ const cells = (catalog) => new Map(catalog.states.flatMap(state => state.cells.map(cell => [`${state.id}\u0000${cell.profile.key}`, { state, cell }])));
53
+ const left = cells(before), right = cells(after);
54
+ const entries = [];
55
+ for (const key of new Set([...left.keys(), ...right.keys()])) {
56
+ const a = left.get(key), b = right.get(key);
57
+ const first = (b ?? a);
58
+ entries.push({ id: first.state.id, title: first.state.title, profile: first.cell.profile, before: a?.cell ?? null, after: b?.cell ?? null, change: changeOf(a?.cell ?? null, b?.cell ?? null) });
59
+ }
60
+ return ordered(entries);
61
+ }
62
+ /**
63
+ * Sets one profile of a catalog (`from`, a {@link UiProfile.key}) against another (`to`): each state's cell in `from` beside its
64
+ * cell in `to`. A state that has a cell in only one of them is `added` or `removed`. Throws when the two are the same profile,
65
+ * because that compares a catalog with itself.
66
+ */
67
+ export function diffUiProfiles(catalog, from, to) {
68
+ if (from === to)
69
+ throw new Error('diffUiProfiles: choose two different profiles');
70
+ const entries = [];
71
+ for (const state of catalog.states) {
72
+ const a = state.cells.find(cell => cell.profile.key === from) ?? null;
73
+ const b = state.cells.find(cell => cell.profile.key === to) ?? null;
74
+ if (!a && !b)
75
+ continue;
76
+ entries.push({ id: state.id, title: state.title, profile: null, before: a, after: b, change: changeOf(a, b) });
77
+ }
78
+ return ordered(entries);
79
+ }
80
+ /** The entries of a comparison that changed, or all of them. A change the comparison cannot verify is a change worth looking at, so it is kept. */
81
+ export function changedUiEntries(diff) {
82
+ return diff.entries.filter(entry => entry.change !== 'unchanged');
83
+ }
84
+ /**
85
+ * The commits layers were recorded at, newest first. A layer with no commit is left out: it cannot be compared by commit.
86
+ * Layers recorded from a checkout with uncommitted changes (`freshness` `modified`) are not the commit's own result and are
87
+ * left out too, as the gate does by default for comparisons.
88
+ */
89
+ export function uiCatalogCommits(layers) {
90
+ const commits = new Map();
91
+ for (const layer of layers) {
92
+ if (!layer.sha || layer.freshness === 'modified')
93
+ continue;
94
+ const held = commits.get(layer.sha);
95
+ if (!held)
96
+ commits.set(layer.sha, { sha: layer.sha, branch: layer.branch, recordedAtMs: layer.recordedAtMs, layers: 1 });
97
+ else {
98
+ held.layers += 1;
99
+ if (layer.recordedAtMs > held.recordedAtMs) {
100
+ held.recordedAtMs = layer.recordedAtMs;
101
+ held.branch = layer.branch;
102
+ }
103
+ }
104
+ }
105
+ return [...commits.values()].sort((a, b) => b.recordedAtMs - a.recordedAtMs || compare(a.sha, b.sha));
106
+ }
107
+ /**
108
+ * The layers recorded at one commit (`sha` is the whole commit id, or a prefix of at least 7 hexadecimal digits that names
109
+ * exactly one commit among `layers`). Throws when a prefix names none, or more than one, so a comparison never silently uses
110
+ * the wrong commit.
111
+ */
112
+ export function layersAtCommit(layers, sha) {
113
+ const wanted = sha.toLowerCase();
114
+ if (!/^[0-9a-f]{7,64}$/.test(wanted))
115
+ throw new Error(`"${sha}" is not a commit: give at least 7 hexadecimal digits of its id`);
116
+ const shas = new Set(layers.flatMap(entry => (entry.layer.sha && entry.layer.freshness !== 'modified' && entry.layer.sha.toLowerCase().startsWith(wanted) ? [entry.layer.sha.toLowerCase()] : [])));
117
+ if (shas.size === 0)
118
+ throw new Error(`No layer was recorded at commit ${sha}`);
119
+ if (shas.size > 1)
120
+ throw new Error(`"${sha}" names more than one commit (${[...shas].map(name => name.slice(0, 12)).join(', ')}): give more digits`);
121
+ const [only] = shas;
122
+ return layers.filter(entry => entry.layer.sha?.toLowerCase() === only && entry.layer.freshness !== 'modified');
123
+ }
124
+ /**
125
+ * The UI catalog as it was recorded at one commit: only the layers recorded there take part. A state the commit's layers do
126
+ * not hold is absent, not failed. See {@link layersAtCommit} for how `sha` is matched.
127
+ */
128
+ export function uiCatalogAtCommit(layers, sha, options = {}) {
129
+ return uiCatalog(layersAtCommit(layers, sha), options);
130
+ }