@crvouga/atlas 1.20.0 → 1.22.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 +15 -1
- package/dist/core/src/cli.d.ts +2 -2
- package/dist/core/src/cli.js +7 -1
- package/dist/critics/src/catalog-client.d.ts +91 -0
- package/dist/critics/src/catalog-client.js +194 -0
- package/dist/critics/src/cli-calibrate.d.ts +4 -0
- package/dist/critics/src/cli-calibrate.js +211 -0
- package/dist/critics/src/cli-common.d.ts +108 -0
- package/dist/critics/src/cli-common.js +155 -0
- package/dist/critics/src/cli-review.d.ts +4 -0
- package/dist/critics/src/cli-review.js +174 -0
- package/dist/critics/src/cli-watch.d.ts +4 -0
- package/dist/critics/src/cli-watch.js +191 -0
- package/dist/critics/src/cli.d.ts +13 -0
- package/dist/critics/src/cli.js +490 -0
- package/dist/critics/src/index.d.ts +3 -0
- package/dist/critics/src/index.js +3 -0
- package/dist/critics/src/judging.d.ts +8 -0
- package/dist/critics/src/llm.d.ts +120 -0
- package/dist/critics/src/llm.js +266 -0
- package/dist/critics/src/marks.d.ts +74 -0
- package/dist/critics/src/marks.js +76 -0
- package/dist/critics/src/registry.d.ts +125 -0
- package/dist/critics/src/registry.js +203 -0
- package/dist/critics/src/spool.d.ts +32 -0
- package/dist/critics/src/spool.js +129 -0
- package/dist/critics/src/uiclip.d.ts +82 -0
- package/dist/critics/src/uiclip.js +167 -0
- package/dist/reports/src/compose.d.ts +23 -1
- package/dist/reports/src/compose.js +29 -19
- package/dist/reports/src/critique-review.d.ts +111 -0
- package/dist/reports/src/critique-review.js +123 -0
- package/dist/reports/src/critique-row.d.ts +24 -0
- package/dist/reports/src/critique-row.js +39 -0
- package/dist/reports/src/evidence-types.d.ts +5 -0
- package/dist/reports/src/layer-merge.js +5 -0
- package/dist/reports/src/report-cli.d.ts +13 -0
- package/dist/reports/src/report-cli.js +6 -4
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +12 -0
- package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
- package/dist/reports/src/ui-catalog-cli.js +391 -0
- package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
- package/dist/reports/src/ui-catalog-diff.js +130 -0
- package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
- package/dist/reports/src/ui-catalog-entries.js +190 -0
- package/dist/reports/src/ui-catalog-export.d.ts +82 -0
- package/dist/reports/src/ui-catalog-export.js +221 -0
- package/dist/reports/src/ui-catalog-publish.js +2 -2
- package/dist/reports/src/ui-catalog.d.ts +2 -0
- package/dist/reports/src/ui-catalog.js +1 -1
- package/dist/reports/src/ui-state.d.ts +2 -0
- package/dist/reports/src/ui-state.js +2 -2
- package/dist/schema/src/api.d.ts +653 -1
- package/dist/schema/src/api.js +44 -0
- package/dist/schema/src/cli-results.d.ts +991 -1
- package/dist/schema/src/cli-results.js +265 -1
- package/dist/schema/src/critique.d.ts +31 -0
- package/dist/schema/src/critique.js +26 -0
- package/dist/schema/src/server-config.d.ts +3 -3
- package/dist/server/src/app.js +28 -1
- package/dist/server/src/openapi.js +19 -2
- package/docs/agents.md +16 -2
- package/docs/critics.md +273 -9
- package/docs/extending.md +1 -1
- package/docs/reference/cli.md +126 -1
- package/docs/reference/core.md +1 -1
- package/docs/reference/critics.md +1131 -29
- package/docs/reference/http-api.md +40 -0
- package/docs/reference/reports.md +855 -0
- package/docs/reference/schema.md +197 -1
- package/docs/report-contract.md +2 -1
- package/docs/server.md +2 -0
- package/docs/ui-catalog.md +110 -13
- package/docs/ui.md +69 -2
- package/llms-full.txt +636 -26
- package/llms.txt +1 -1
- package/package.json +9 -1
- package/packages/critics/README.md +1 -0
- package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
- package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
- package/packages/schema/schemas/critic-list-result.schema.json +90 -0
- package/packages/schema/schemas/critic-review-result.schema.json +397 -0
- package/packages/schema/schemas/critic-run-result.schema.json +249 -0
- package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
- package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
- package/packages/schema/schemas/critique-mark.schema.json +81 -0
- package/packages/schema/schemas/effective-delta.schema.json +337 -0
- package/packages/schema/schemas/effective-evidence.schema.json +337 -0
- package/packages/schema/schemas/layer-rows.schema.json +337 -0
- package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
- package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
- package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
- package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
- package/packages/server/openapi.json +6755 -4738
- package/packages/visualizer/src/app/AppShell.tsx +3 -1
- package/packages/visualizer/src/app/router.tsx +20 -7
- package/packages/visualizer/src/catalog/Picture.tsx +32 -0
- package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
- package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
- package/packages/visualizer/src/catalog/history.ts +61 -0
- package/packages/visualizer/src/catalog/search.ts +14 -3
- package/packages/visualizer/src/components/Header.tsx +16 -4
- package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
- package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
- package/packages/visualizer/src/critiques/ranking.ts +54 -0
- package/packages/visualizer/src/critiques/search.ts +41 -0
- package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
- package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
- package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
- package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
- package/packages/visualizer/src/explorer/parts.tsx +19 -1
- package/packages/visualizer/src/explorer/playlist.tsx +11 -7
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* @see docs/integrating.md
|
|
13
13
|
* @see docs/agents.md
|
|
14
14
|
*/
|
|
15
|
+
import { type LayerRun } from '../../schema/src/layer.js';
|
|
15
16
|
/** 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
17
|
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
18
|
type Env = Readonly<Record<string, string | undefined>>;
|
|
@@ -41,11 +42,23 @@ export declare function detectFreshness(origin: {
|
|
|
41
42
|
sha: string | null;
|
|
42
43
|
ci: boolean;
|
|
43
44
|
}, git?: Git): 'modified' | 'unknown' | undefined;
|
|
45
|
+
/** Splits `key=value` (the form of `--param` and `--label`), or throws a message naming `flag`. */
|
|
46
|
+
export declare function keyValue(pair: string, flag: string): [string, string];
|
|
47
|
+
/** An ISO date or epoch milliseconds from a flag, `null` when the flag was not given. Throws a message naming `flag` for anything else. */
|
|
48
|
+
export declare function parseTime(text: string | undefined, flag?: string): number | null;
|
|
49
|
+
/**
|
|
50
|
+
* The execution the producer named, from `--run-id`, `--run-title`, `--run-started-at`, `--run-ended-at` and then
|
|
51
|
+
* `ATLAS_RUN_ID` / `ATLAS_RUN_TITLE`, laid over a `run` the checks file gave. `undefined` when nothing names one, so the
|
|
52
|
+
* documented grouping (CI run URL, else branch@sha) applies. A title or time without an id is refused: it would have nothing to belong to.
|
|
53
|
+
*/
|
|
54
|
+
export declare function executionOf(rest: readonly string[], env: Env, given?: unknown): LayerRun | undefined;
|
|
44
55
|
/**
|
|
45
56
|
* `atlas report …` for a terminal: a mistake in the command or a path is a message on stderr and exit
|
|
46
57
|
* code 2, so a CI job can tell a broken invocation (2) from a report that does not pass (1).
|
|
47
58
|
*/
|
|
48
59
|
export declare function runReportCommand(argv: readonly string[]): Promise<number>;
|
|
60
|
+
/** Does the command ask for a JSON document (`--format json` or `--json`) instead of prose? */
|
|
61
|
+
export declare function wantsJson(argv: readonly string[]): boolean;
|
|
49
62
|
/** `atlas report …`. Returns the exit code: 0 passes, 1 does not. Throws on a mistake in the command (see {@link runReportCommand}). */
|
|
50
63
|
export declare function reportMain(argv: readonly string[], io?: ReportIo): Promise<number>;
|
|
51
64
|
export {};
|
|
@@ -149,7 +149,8 @@ export function detectFreshness(origin, git = gitOutput) {
|
|
|
149
149
|
// `git diff` is empty (so null) when clean; untracked files (test output, build products) do not count.
|
|
150
150
|
return git(['status', '--porcelain', '--untracked-files=no']) ? 'modified' : undefined;
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
/** Splits `key=value` (the form of `--param` and `--label`), or throws a message naming `flag`. */
|
|
153
|
+
export function keyValue(pair, flag) {
|
|
153
154
|
const at = pair.indexOf('=');
|
|
154
155
|
if (at < 1)
|
|
155
156
|
throw new Error(`${flag} takes key=value (got "${pair}")`);
|
|
@@ -173,7 +174,8 @@ function linkSpec(spec) {
|
|
|
173
174
|
const named = /^([^=/:]+)=(.+)$/.exec(spec);
|
|
174
175
|
return named ? { name: named[1], url: named[2] } : { url: spec };
|
|
175
176
|
}
|
|
176
|
-
|
|
177
|
+
/** An ISO date or epoch milliseconds from a flag, `null` when the flag was not given. Throws a message naming `flag` for anything else. */
|
|
178
|
+
export function parseTime(text, flag = '--recorded-at') {
|
|
177
179
|
if (text === undefined)
|
|
178
180
|
return null;
|
|
179
181
|
const numeric = Number(text);
|
|
@@ -187,7 +189,7 @@ function parseTime(text, flag = '--recorded-at') {
|
|
|
187
189
|
* `ATLAS_RUN_ID` / `ATLAS_RUN_TITLE`, laid over a `run` the checks file gave. `undefined` when nothing names one, so the
|
|
188
190
|
* documented grouping (CI run URL, else branch@sha) applies. A title or time without an id is refused: it would have nothing to belong to.
|
|
189
191
|
*/
|
|
190
|
-
function executionOf(rest, env, given) {
|
|
192
|
+
export function executionOf(rest, env, given) {
|
|
191
193
|
const file = typeof given === 'object' && given !== null && !Array.isArray(given) ? given : {};
|
|
192
194
|
const id = value(rest, 'run-id') ?? env.ATLAS_RUN_ID ?? (typeof file.id === 'string' ? file.id : undefined);
|
|
193
195
|
const title = value(rest, 'run-title') ?? env.ATLAS_RUN_TITLE;
|
|
@@ -235,7 +237,7 @@ export async function runReportCommand(argv) {
|
|
|
235
237
|
}
|
|
236
238
|
}
|
|
237
239
|
/** Does the command ask for a JSON document (`--format json` or `--json`) instead of prose? */
|
|
238
|
-
function wantsJson(argv) {
|
|
240
|
+
export function wantsJson(argv) {
|
|
239
241
|
return value(argv, 'format') === 'json' || argv.includes('--json');
|
|
240
242
|
}
|
|
241
243
|
function printChecks(out, label, checks) {
|
|
@@ -88,6 +88,12 @@ export declare class ReportIndex {
|
|
|
88
88
|
readonly text: string;
|
|
89
89
|
readonly version: string;
|
|
90
90
|
} | null;
|
|
91
|
+
/**
|
|
92
|
+
* The ids of the layers that were added or changed (took a slot, or lost one) since the report a reader holds, named by
|
|
93
|
+
* its version: what `delta` sends, without the text. `null` when that version is not remembered, and the reader should
|
|
94
|
+
* take everything. Used to answer "what changed" about a view of the report, such as the entries of the UI catalog.
|
|
95
|
+
*/
|
|
96
|
+
changedSince(since: string): ReadonlySet<string> | null;
|
|
91
97
|
/**
|
|
92
98
|
* Every recorded result of every test and every run, as layers were folded in: what the test catalog, a test's history
|
|
93
99
|
* and the run list are built from. Read-only for callers; `history.rev` changes whenever a layer is taken in.
|
|
@@ -345,6 +345,18 @@ export class ReportIndex {
|
|
|
345
345
|
text: `{"version":${JSON.stringify(value.version)},"base":${JSON.stringify(since)},"delta":true,"layers":[${changed.map(item => (item.json ??= JSON.stringify(this.#entryOf(item)))).join(',')}],"removed":${JSON.stringify(gone)},"backends":${JSON.stringify(value.backends)},"notices":${JSON.stringify(value.notices)},"totalLayers":${value.totalLayers},"active":[]}`
|
|
346
346
|
};
|
|
347
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* The ids of the layers that were added or changed (took a slot, or lost one) since the report a reader holds, named by
|
|
350
|
+
* its version: what `delta` sends, without the text. `null` when that version is not remembered, and the reader should
|
|
351
|
+
* take everything. Used to answer "what changed" about a view of the report, such as the entries of the UI catalog.
|
|
352
|
+
*/
|
|
353
|
+
changedSince(since) {
|
|
354
|
+
const current = this.#current();
|
|
355
|
+
const base = this.#versions.get(since);
|
|
356
|
+
if (base === undefined || base < this.#forgotten)
|
|
357
|
+
return null;
|
|
358
|
+
return new Set(current.held.filter(item => item.rev > base).map(item => item.evidence.id));
|
|
359
|
+
}
|
|
348
360
|
/**
|
|
349
361
|
* Every recorded result of every test and every run, as layers were folded in: what the test catalog, a test's history
|
|
350
362
|
* and the run list are built from. Read-only for callers; `history.rev` changes whenever a layer is taken in.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReportIo } from './report-cli.js';
|
|
2
|
+
/** 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`. */
|
|
3
|
+
export declare const UI_CATALOG_USAGE = "atlas ui-catalog \u2014 a browsable inventory of captured UI states, from any collector\n\n atlas ui-catalog plan --states <file.json> --producer <name> [--store <dir|s3://bucket/prefix|https://\u2026>]\n [--environment <name>] [--param key=value]... [--shard <label>]\n [--branch <b>] [--sha <s>] [--run-url <u>] [--run-id <id>] [--run-title <t>] [--recorded-at <iso|ms>]\n atlas ui-catalog publish --captures <file.json> --producer <name> [the same flags as plan]\n [--attach-root <dir>]... [--max-picture-mb <n>]\n atlas ui-catalog status [--store <dir|s3://\u2026|id=url>]... [--backends <module>] [filters] [--branch <name>]...\n [--ignore-modified] [--format text|json|markdown]\n atlas ui-catalog diff --from <commit> --to <commit> [--store \u2026]... [--backends <module>] [filters]\n [--fail-on-change] [--format text|json|markdown]\n atlas ui-catalog export --out <file.html> [--store \u2026]... [--backends <module>] [filters]\n [--images inline|none] [--max-mb <n>] [--manifest <file.json>] [--title <t>]\n\n filters (status, diff, export): --q <words> --coverage a,b --profile <key|label>... --area a --route r --cohort c\n --flag f --surface s --producer p\n\n plan Write one layer of kind ui-plan: the states (and with \"inventory\": true, the surfaces) that should exist under\n this profile. Every entry is not-run, so a plan never counts as a pass. The file is {\"states\":[{\"id\":\u2026,\n \"title\"?,\"route\"?,\"fixture\"?,\"cohort\"?,\"area\"?,\"kind\"?,\"flags\"?,\"covers\"?,\"inventory\"?}]} or just that list.\n publish Write one layer of kind ui-state: the states one collector or shard captured under this profile. The file is\n {\"states\":[{\"id\":\u2026,\"title\"?,\"status\"?:\"passed|failed|running|not-run\",\"detail\"?,\"route\"?,\"fixture\"?,\"cohort\"?,\n \"area\"?,\"kind\"?,\"flags\"?,\"covers\"?,\"labels\"?,\"tags\"?,\"durationMs\"?,\"screenshot\"?:\"file.png\"|{\"path\"|\"uri\",\n \"width\"?,\"height\"?,\"contentType\"?}}]}. A picture file is read from beside the file and each --attach-root, never\n from a hidden file or a link out. A passed state whose picture cannot be read is published as failed, with the\n reason: a state without its picture is not shown as captured.\n --environment and each --param key=value are the profile (a viewport, a color scheme, a device): another\n profile is another result side by side. --shard only describes the layer; it never splits a result.\n --recorded-at defaults to the modification time of the file, so publishing the same file twice is one layer.\n status Read every store and print the catalog's summary. Exit 1 unless the catalog holds a state and there is nothing\n to attend to: a failed capture, a state a plan expects with nothing captured, a capture still in progress, a\n backend or layer that could not be read. Without a plan nothing is expected, so nothing is missing.\n --profile takes a profile's key or its label, as printed by status.\n diff Set the catalog recorded at one commit (--from) against another (--to): each state in each profile is paired\n with itself and is unchanged, changed in coverage, changed in picture (the stored bytes differ), unverified (a\n side has no content hash), only in --to (added) or only in --from (removed). A commit is the whole id or at\n least 7 digits of it. Exit 0 unless a store could not be read, or, with --fail-on-change, anything differs.\n export Write the catalog as one HTML file with its pictures embedded and its manifest in it: no server, no network.\n It is the catalog at the moment it was made. --images none leaves the pictures out. --max-mb (default 32) limits\n the embedded pictures; those past it are listed in the page as left out, and the manifest stays complete.\n --manifest writes the catalog's JSON manifest too.\n\n --format json (alias --json) every subcommand prints one JSON document instead of prose\n (schemas: ui-catalog-publish-result, ui-catalog-status, ui-catalog-diff-result, ui-catalog-export-result).\n A command that is wrong is {\"ok\":false,\"error\":{\"message\":\u2026,\"code\":\"usage\"}} on standard error.\n A store is a directory, s3://bucket/prefix or an Atlas server (https://\u2026), exactly as for atlas report; select a project\n with --project <slug> or ATLAS_PROJECT. When no --store is given, ATLAS_STORE is used.\n\n Exit codes: 0 ok, 1 the answer is no (see each command), 2 the command was wrong.\n";
|
|
4
|
+
/** `atlas ui-catalog …`. Returns the exit code. Throws on a mistake in the command (see {@link runUiCatalogCommand}). */
|
|
5
|
+
export declare function uiCatalogMain(argv: readonly string[], io?: ReportIo): Promise<number>;
|
|
6
|
+
/**
|
|
7
|
+
* `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
|
|
8
|
+
* tell a broken invocation (2) from a catalog that needs attention (1). With `--format json` the mistake is a JSON document.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runUiCatalogCommand(argv: readonly string[]): Promise<number>;
|
|
@@ -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"?,"kind"?,"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"?,"kind"?,"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
|
+
kind: z.string().optional(), 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
|
+
}
|