@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.
- package/CHANGELOG.md +7 -1
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +3 -0
- package/dist/reports/src/compose.d.ts +23 -1
- package/dist/reports/src/compose.js +29 -19
- package/dist/reports/src/report-cli.d.ts +13 -0
- package/dist/reports/src/report-cli.js +6 -4
- 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-export.d.ts +82 -0
- package/dist/reports/src/ui-catalog-export.js +221 -0
- package/dist/schema/src/cli-results.d.ts +318 -1
- package/dist/schema/src/cli-results.js +89 -1
- package/docs/agents.md +5 -0
- package/docs/reference/cli.md +56 -1
- package/docs/reference/reports.md +392 -0
- package/docs/reference/schema.md +41 -1
- package/docs/ui-catalog.md +106 -12
- package/docs/ui.md +6 -1
- package/llms-full.txt +170 -11
- package/package.json +1 -1
- 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/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/evidence/evidence-context.tsx +2 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog single-file export
|
|
3
|
+
*
|
|
4
|
+
* The UI catalog as one HTML file that needs no server, no network and no build: open it from a disk, attach it to a ticket,
|
|
5
|
+
* host it anywhere that serves a file. Pictures are embedded as `data:` addresses, the manifest is embedded as JSON
|
|
6
|
+
* (`<script type="application/json" id="ui-catalog">`), and a few lines of script filter the states; with scripts off, every
|
|
7
|
+
* state is simply shown. It shows what the viewer's catalog shows, but it does not stream: it is the catalog at the moment it
|
|
8
|
+
* was made, and says when.
|
|
9
|
+
*
|
|
10
|
+
* Hosting platforms cap the size of one file, which is the reason a catalog is also a store and a viewer. The export keeps to a
|
|
11
|
+
* budget ({@link gatherUiPictures}): pictures past it are listed as left out with the reason, never silently dropped, and the
|
|
12
|
+
* manifest stays complete.
|
|
13
|
+
*
|
|
14
|
+
* Everything in the page is data from a test run. Text is escaped; only PNG, JPEG, GIF and WebP bytes whose first bytes say so
|
|
15
|
+
* are embedded (never SVG, never what a layer merely claims); a page address is linked only when it is `http` or `https`; the
|
|
16
|
+
* page carries a Content-Security-Policy that allows no network request at all.
|
|
17
|
+
*
|
|
18
|
+
* @see docs/ui-catalog.md
|
|
19
|
+
*/
|
|
20
|
+
import { type UiCapture, type UiCatalog } from './ui-catalog.js';
|
|
21
|
+
/** A picture ready to embed. */
|
|
22
|
+
export type UiExportPicture = {
|
|
23
|
+
readonly contentType: string;
|
|
24
|
+
readonly data: Uint8Array;
|
|
25
|
+
};
|
|
26
|
+
/** What {@link uiCatalogHtml} may be given besides the catalog. */
|
|
27
|
+
export type UiExportOptions = {
|
|
28
|
+
/** The page's title. Default `UI catalog`. */
|
|
29
|
+
readonly title?: string;
|
|
30
|
+
/** The pictures to embed, by {@link uiPictureKey}. A cell whose key is not here shows why it has no picture. */
|
|
31
|
+
readonly pictures?: ReadonlyMap<string, UiExportPicture>;
|
|
32
|
+
/** Why a picture that could not be embedded is not here, by {@link uiPictureKey}: shown in its place. */
|
|
33
|
+
readonly left?: ReadonlyMap<string, string>;
|
|
34
|
+
/** When the export was made, epoch milliseconds. It is stated on the page so nobody mistakes it for a live view. Default: now. */
|
|
35
|
+
readonly generatedAtMs?: number;
|
|
36
|
+
};
|
|
37
|
+
/** The key of a capture's picture in {@link UiExportOptions.pictures}: its layer and its bundle key, or `null` when the picture is not stored in a layer. */
|
|
38
|
+
export declare function uiPictureKey(capture: Pick<UiCapture, 'origin' | 'image'> | null | undefined): string | null;
|
|
39
|
+
/** One picture an export may embed. */
|
|
40
|
+
export type UiPictureWant = {
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly layer: string;
|
|
43
|
+
readonly backend: string;
|
|
44
|
+
readonly bundleKey: string;
|
|
45
|
+
readonly bytes?: number;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The pictures of a catalog an export would embed, once each, in the order the page shows them. Only a cell that was captured, or
|
|
49
|
+
* that failed (a picture of what the page showed), and whose picture is stored in a layer, has one.
|
|
50
|
+
*/
|
|
51
|
+
export declare function uiPicturesWanted(catalog: UiCatalog): UiPictureWant[];
|
|
52
|
+
/** The media type a picture's first bytes say it has (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), or `null` for anything else, SVG included. */
|
|
53
|
+
export declare function sniffImage(data: Uint8Array): string | null;
|
|
54
|
+
/** What {@link gatherUiPictures} found. */
|
|
55
|
+
export type UiPicturesGathered = {
|
|
56
|
+
readonly pictures: ReadonlyMap<string, UiExportPicture>;
|
|
57
|
+
/** Why each picture that is not in `pictures` is not. */
|
|
58
|
+
readonly left: ReadonlyMap<string, string>;
|
|
59
|
+
/** Bytes embedded. */
|
|
60
|
+
readonly bytes: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Reads the pictures an export wants through `read` (which returns the stored bytes of a picture, or `null` when the store
|
|
64
|
+
* no longer holds it) and keeps those that fit in `maxBytes` in total. A picture that is not stored, is not a PNG, JPEG, GIF or
|
|
65
|
+
* WebP, cannot be read, or would pass the budget is recorded in `left` with the reason; the pictures are taken in the page's
|
|
66
|
+
* order, so which are left out is deterministic. A `read` that throws is a picture left out, never a failed export.
|
|
67
|
+
*/
|
|
68
|
+
export declare function gatherUiPictures(wanted: readonly UiPictureWant[], read: (want: UiPictureWant) => Promise<Uint8Array | null>, options: {
|
|
69
|
+
readonly maxBytes: number;
|
|
70
|
+
}): Promise<UiPicturesGathered>;
|
|
71
|
+
/**
|
|
72
|
+
* The whole catalog as one self-contained HTML document. See the module description for what it contains and how it treats
|
|
73
|
+
* the data in it. Deterministic for the same catalog, pictures and `generatedAtMs`.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const wanted = uiPicturesWanted(catalog);
|
|
78
|
+
* const { pictures, left } = await gatherUiPictures(wanted, want => readPicture(want), { maxBytes: 32 * 1024 * 1024 });
|
|
79
|
+
* await writeFile('catalog.html', uiCatalogHtml(catalog, { pictures, left }));
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function uiCatalogHtml(catalog: UiCatalog, options?: UiExportOptions): string;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog single-file export
|
|
3
|
+
*
|
|
4
|
+
* The UI catalog as one HTML file that needs no server, no network and no build: open it from a disk, attach it to a ticket,
|
|
5
|
+
* host it anywhere that serves a file. Pictures are embedded as `data:` addresses, the manifest is embedded as JSON
|
|
6
|
+
* (`<script type="application/json" id="ui-catalog">`), and a few lines of script filter the states; with scripts off, every
|
|
7
|
+
* state is simply shown. It shows what the viewer's catalog shows, but it does not stream: it is the catalog at the moment it
|
|
8
|
+
* was made, and says when.
|
|
9
|
+
*
|
|
10
|
+
* Hosting platforms cap the size of one file, which is the reason a catalog is also a store and a viewer. The export keeps to a
|
|
11
|
+
* budget ({@link gatherUiPictures}): pictures past it are listed as left out with the reason, never silently dropped, and the
|
|
12
|
+
* manifest stays complete.
|
|
13
|
+
*
|
|
14
|
+
* Everything in the page is data from a test run. Text is escaped; only PNG, JPEG, GIF and WebP bytes whose first bytes say so
|
|
15
|
+
* are embedded (never SVG, never what a layer merely claims); a page address is linked only when it is `http` or `https`; the
|
|
16
|
+
* page carries a Content-Security-Policy that allows no network request at all.
|
|
17
|
+
*
|
|
18
|
+
* @see docs/ui-catalog.md
|
|
19
|
+
*/
|
|
20
|
+
import { UI_COVERAGE } from './ui-catalog.js';
|
|
21
|
+
/** The key of a capture's picture in {@link UiExportOptions.pictures}: its layer and its bundle key, or `null` when the picture is not stored in a layer. */
|
|
22
|
+
export function uiPictureKey(capture) {
|
|
23
|
+
const key = capture?.image?.bundleKey;
|
|
24
|
+
return capture && key ? `${capture.origin.layer}/${key}` : null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The pictures of a catalog an export would embed, once each, in the order the page shows them. Only a cell that was captured, or
|
|
28
|
+
* that failed (a picture of what the page showed), and whose picture is stored in a layer, has one.
|
|
29
|
+
*/
|
|
30
|
+
export function uiPicturesWanted(catalog) {
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const wanted = [];
|
|
33
|
+
for (const state of catalog.states)
|
|
34
|
+
for (const cell of state.cells) {
|
|
35
|
+
if (cell.coverage !== 'captured' && cell.coverage !== 'failed')
|
|
36
|
+
continue;
|
|
37
|
+
const key = uiPictureKey(cell.capture);
|
|
38
|
+
if (!key || seen.has(key) || !cell.capture?.image?.bundleKey)
|
|
39
|
+
continue;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
wanted.push({ key, layer: cell.capture.origin.layer, backend: cell.capture.origin.backend, bundleKey: cell.capture.image.bundleKey, ...(cell.capture.image.bytes !== undefined ? { bytes: cell.capture.image.bytes } : {}) });
|
|
42
|
+
}
|
|
43
|
+
return wanted;
|
|
44
|
+
}
|
|
45
|
+
/** The media type a picture's first bytes say it has (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), or `null` for anything else, SVG included. */
|
|
46
|
+
export function sniffImage(data) {
|
|
47
|
+
const at = (offset, ...bytes) => bytes.every((byte, i) => data[offset + i] === byte);
|
|
48
|
+
if (at(0, 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
|
|
49
|
+
return 'image/png';
|
|
50
|
+
if (at(0, 0xff, 0xd8, 0xff))
|
|
51
|
+
return 'image/jpeg';
|
|
52
|
+
if (at(0, 0x47, 0x49, 0x46, 0x38))
|
|
53
|
+
return 'image/gif';
|
|
54
|
+
if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50))
|
|
55
|
+
return 'image/webp';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reads the pictures an export wants through `read` (which returns the stored bytes of a picture, or `null` when the store
|
|
60
|
+
* no longer holds it) and keeps those that fit in `maxBytes` in total. A picture that is not stored, is not a PNG, JPEG, GIF or
|
|
61
|
+
* WebP, cannot be read, or would pass the budget is recorded in `left` with the reason; the pictures are taken in the page's
|
|
62
|
+
* order, so which are left out is deterministic. A `read` that throws is a picture left out, never a failed export.
|
|
63
|
+
*/
|
|
64
|
+
export async function gatherUiPictures(wanted, read, options) {
|
|
65
|
+
const pictures = new Map();
|
|
66
|
+
const left = new Map();
|
|
67
|
+
let bytes = 0;
|
|
68
|
+
for (const want of wanted) {
|
|
69
|
+
let data;
|
|
70
|
+
try {
|
|
71
|
+
data = await read(want);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
left.set(want.key, `could not be read (${error instanceof Error ? error.message.split('\n')[0] : String(error)})`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!data) {
|
|
78
|
+
left.set(want.key, 'is not stored in its layer');
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const contentType = sniffImage(data);
|
|
82
|
+
if (!contentType) {
|
|
83
|
+
left.set(want.key, 'is not a PNG, JPEG, GIF or WebP image');
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (bytes + data.length > options.maxBytes) {
|
|
87
|
+
left.set(want.key, `would pass the export's size limit of ${(options.maxBytes / 1024 / 1024).toFixed(0)} MiB`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
bytes += data.length;
|
|
91
|
+
pictures.set(want.key, { contentType, data });
|
|
92
|
+
}
|
|
93
|
+
return { pictures, left, bytes };
|
|
94
|
+
}
|
|
95
|
+
const WORDS = { captured: 'Captured', failed: 'Capture failed', capturing: 'Capturing', skipped: 'Skipped', missing: 'Not captured yet' };
|
|
96
|
+
const escapes = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
97
|
+
const esc = (text) => String(text).replace(/[&<>"']/g, character => escapes[character]);
|
|
98
|
+
function base64(data) {
|
|
99
|
+
let text = '';
|
|
100
|
+
for (let i = 0; i < data.length; i += 0x8000)
|
|
101
|
+
text += String.fromCharCode(...data.subarray(i, i + 0x8000));
|
|
102
|
+
return btoa(text);
|
|
103
|
+
}
|
|
104
|
+
/** JSON that is safe inside a `<script>`: nothing in it can close the element or start a comment. */
|
|
105
|
+
function scriptJson(value) {
|
|
106
|
+
return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
107
|
+
}
|
|
108
|
+
const webAddress = (uri) => (uri && /^https?:\/\//i.test(uri) ? uri : null);
|
|
109
|
+
const STYLE = `
|
|
110
|
+
:root{color-scheme:light dark;--line:#8886;--soft:#8882;--good:#1a7f37;--bad:#cf222e;--wait:#0969da}
|
|
111
|
+
body{margin:0 auto;max-width:96rem;padding:1.5rem;font:16px/1.5 system-ui,sans-serif}
|
|
112
|
+
h1{margin:0 0 .25rem}h2{margin:0;font-size:1.1rem}h3{margin:1.5rem 0 .5rem}
|
|
113
|
+
.muted{opacity:.75}.small{font-size:.85rem}
|
|
114
|
+
.bar{display:flex;flex-wrap:wrap;gap:.75rem;align-items:center;margin:1rem 0}
|
|
115
|
+
.bar input,.bar select{font:inherit;padding:.35rem .5rem;border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit}
|
|
116
|
+
.bar input{min-width:18rem}
|
|
117
|
+
ul.states{list-style:none;margin:0;padding:0;display:grid;gap:1rem}
|
|
118
|
+
li.state{border:1px solid var(--line);border-radius:8px;padding:1rem}
|
|
119
|
+
li.state[hidden],figure[hidden]{display:none}
|
|
120
|
+
.cells{display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:1rem;margin-top:.75rem}
|
|
121
|
+
figure{margin:0;border:1px solid var(--soft);border-radius:6px;padding:.5rem}
|
|
122
|
+
figure img{display:block;max-width:100%;height:auto;margin:0 auto;border:1px solid var(--soft)}
|
|
123
|
+
.nopic{padding:1.5rem .5rem;text-align:center;opacity:.8;border:1px dashed var(--line)}
|
|
124
|
+
figcaption{margin-top:.5rem}
|
|
125
|
+
.tag{display:inline-block;padding:0 .5rem;border:1px solid currentColor;border-radius:999px;font-size:.8rem}
|
|
126
|
+
[data-coverage=captured] .tag{color:var(--good)}[data-coverage=failed] .tag{color:var(--bad)}[data-coverage=capturing] .tag{color:var(--wait)}
|
|
127
|
+
.note{border-left:4px solid var(--line);padding:.25rem .75rem;margin:.5rem 0}
|
|
128
|
+
table{border-collapse:collapse}th,td{text-align:left;padding:.25rem .75rem;border-bottom:1px solid var(--soft)}
|
|
129
|
+
code{font-family:ui-monospace,monospace;font-size:.9em}
|
|
130
|
+
`;
|
|
131
|
+
const SCRIPT = `
|
|
132
|
+
(function(){
|
|
133
|
+
var q=document.getElementById('q'),c=document.getElementById('coverage'),n=document.getElementById('shown');
|
|
134
|
+
if(!q||!c)return;
|
|
135
|
+
var items=[].slice.call(document.querySelectorAll('li.state'));
|
|
136
|
+
function run(){
|
|
137
|
+
var terms=q.value.toLowerCase().split(/\\s+/).filter(Boolean),want=c.value,shown=0;
|
|
138
|
+
items.forEach(function(item){
|
|
139
|
+
var text=item.getAttribute('data-text')||'',ok=terms.every(function(t){return text.indexOf(t)>=0});
|
|
140
|
+
var cells=[].slice.call(item.querySelectorAll('figure')),any=false;
|
|
141
|
+
cells.forEach(function(cell){var on=!want||cell.getAttribute('data-coverage')===want;cell.hidden=!on;any=any||on});
|
|
142
|
+
var show=ok&&any;item.hidden=!show;if(show)shown++;
|
|
143
|
+
});
|
|
144
|
+
if(n)n.textContent=shown+' of '+items.length+' states shown';
|
|
145
|
+
}
|
|
146
|
+
q.addEventListener('input',run);c.addEventListener('change',run);run();
|
|
147
|
+
})();
|
|
148
|
+
`;
|
|
149
|
+
function cellHtml(state, cell, options) {
|
|
150
|
+
const capture = cell.capture;
|
|
151
|
+
const key = uiPictureKey(capture);
|
|
152
|
+
const picture = key ? options.pictures?.get(key) : undefined;
|
|
153
|
+
const address = webAddress(capture?.image?.uri);
|
|
154
|
+
const shows = cell.coverage === 'captured' || cell.coverage === 'failed';
|
|
155
|
+
let figure;
|
|
156
|
+
if (shows && picture) {
|
|
157
|
+
const { width, height } = capture.image;
|
|
158
|
+
figure = `<img alt="${esc(`${state.title}, ${cell.profile.label}`)}" loading="lazy" src="data:${picture.contentType};base64,${base64(picture.data)}"${width && height ? ` width="${esc(width)}" height="${esc(height)}"` : ''}>`;
|
|
159
|
+
}
|
|
160
|
+
else if (shows && capture?.image?.omitted)
|
|
161
|
+
figure = `<div class="nopic">Picture left out by its producer (${esc(capture.image.omitted)})</div>`;
|
|
162
|
+
else if (shows && key && options.left?.get(key))
|
|
163
|
+
figure = `<div class="nopic">The picture ${esc(options.left.get(key))}</div>`;
|
|
164
|
+
else if (shows && address)
|
|
165
|
+
figure = `<div class="nopic"><a href="${esc(address)}" rel="noopener noreferrer">Open the picture at its address</a></div>`;
|
|
166
|
+
else
|
|
167
|
+
figure = `<div class="nopic">${cell.coverage === 'captured' ? 'Captured without a picture' : esc(WORDS[cell.coverage])}</div>`;
|
|
168
|
+
const origin = capture?.origin;
|
|
169
|
+
const where = origin ? [
|
|
170
|
+
`layer <code>${esc(origin.layer.slice(0, 12))}</code> in backend <code>${esc(origin.backend)}</code>`, `producer ${esc(origin.producer)}`,
|
|
171
|
+
capture.labels.shard ? `shard ${esc(capture.labels.shard)}` : '', origin.branch ? esc(origin.branch) : '', origin.sha ? `<code>${esc(origin.sha.slice(0, 12))}</code>` : '',
|
|
172
|
+
`<time datetime="${esc(new Date(origin.recordedAtMs).toISOString())}">${esc(new Date(origin.recordedAtMs).toISOString().slice(0, 19).replace('T', ' '))} UTC</time>`,
|
|
173
|
+
origin.freshness === 'modified' ? 'recorded with local changes' : origin.freshness === 'unknown' ? 'origin not captured' : ''
|
|
174
|
+
].filter(Boolean).join(', ') : cell.planned ? `expected by plan layer <code>${esc(cell.planned.layer.slice(0, 12))}</code>; nothing has been captured` : '';
|
|
175
|
+
return `<figure data-coverage="${esc(cell.coverage)}" data-profile="${esc(cell.profile.key)}">${figure}<figcaption><strong>${esc(cell.profile.label)}</strong> <span class="tag">${esc(WORDS[cell.coverage])}</span>${cell.expected === 'undeclared' ? ' <span class="small muted">not in the expected set</span>' : ''}${capture?.detail ? `<div class="small">${esc(capture.detail)}</div>` : ''}${where ? `<div class="small muted">${where}</div>` : ''}</figcaption></figure>`;
|
|
176
|
+
}
|
|
177
|
+
function stateHtml(state, index, options) {
|
|
178
|
+
const facts = [['Route', state.route], ['Fixture', state.fixture], ['Cohort', state.cohort], ['Area', state.area], ['Feature flags', state.flags.join(', ')], ['Surfaces covered', state.covers.join(', ')], ['Producers', state.producers.join(', ')]]
|
|
179
|
+
.filter(([, value]) => value).map(([name, value]) => `${esc(name)}: ${esc(value)}`).join(' · ');
|
|
180
|
+
const text = [state.id, state.title, state.route, state.fixture, state.cohort, state.area, ...state.flags, ...state.covers, ...state.producers].filter(Boolean).join(' ').toLowerCase();
|
|
181
|
+
return `<li class="state" id="state-${index}" data-text="${esc(text)}"><h2>${esc(state.title)}</h2><div class="small muted"><code>${esc(state.id)}</code>${facts ? ` · ${facts}` : ''}</div><div class="cells">${state.cells.map(cell => cellHtml(state, cell, options)).join('')}</div></li>`;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The whole catalog as one self-contained HTML document. See the module description for what it contains and how it treats
|
|
185
|
+
* the data in it. Deterministic for the same catalog, pictures and `generatedAtMs`.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```ts
|
|
189
|
+
* const wanted = uiPicturesWanted(catalog);
|
|
190
|
+
* const { pictures, left } = await gatherUiPictures(wanted, want => readPicture(want), { maxBytes: 32 * 1024 * 1024 });
|
|
191
|
+
* await writeFile('catalog.html', uiCatalogHtml(catalog, { pictures, left }));
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
export function uiCatalogHtml(catalog, options = {}) {
|
|
195
|
+
const { summary } = catalog;
|
|
196
|
+
const title = options.title ?? 'UI catalog';
|
|
197
|
+
const made = new Date(options.generatedAtMs ?? Date.now()).toISOString();
|
|
198
|
+
const open = catalog.surfaces.filter(surface => surface.status !== 'covered');
|
|
199
|
+
const unavailable = catalog.sources.backends.filter(backend => !backend.available);
|
|
200
|
+
const notices = [...catalog.sources.notices, ...unavailable.map(backend => `${backend.label} is not available: captures held there are not shown, and nothing that depends on them is shown as captured.`)];
|
|
201
|
+
return `<!doctype html>
|
|
202
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
203
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'">
|
|
204
|
+
<title>${esc(title)}</title><style>${STYLE}</style></head><body>
|
|
205
|
+
<header><h1>${esc(title)}</h1>
|
|
206
|
+
<p class="muted">Made ${esc(made.slice(0, 19).replace('T', ' '))} UTC from ${esc(catalog.sources.layers)} layer${catalog.sources.layers === 1 ? '' : 's'}. This is a copy as of that moment; it does not update.</p>
|
|
207
|
+
<p role="status">${esc(summary.states)} state${summary.states === 1 ? '' : 's'} in ${esc(summary.profiles)} profile${summary.profiles === 1 ? '' : 's'}: ${UI_COVERAGE.map(value => `${esc(summary.coverage[value])} ${esc(WORDS[value].toLowerCase())}`).join(', ')}.${summary.planned ? (summary.undeclared ? ` ${esc(summary.undeclared)} captured cells are not in the expected set.` : '') : ' No expected set was published, so what should exist is unknown and nothing is listed as not captured yet.'}</p>
|
|
208
|
+
${notices.map(notice => `<p class="note" role="status">${esc(notice)}</p>`).join('\n')}
|
|
209
|
+
</header>
|
|
210
|
+
<form class="bar" role="search" onsubmit="return false"><label>Search <input id="q" type="search" placeholder="Words in a state, route, area, surface…"></label>
|
|
211
|
+
<label>Show <select id="coverage"><option value="">every coverage</option>${UI_COVERAGE.map(value => `<option value="${value}">${esc(WORDS[value])}</option>`).join('')}</select></label>
|
|
212
|
+
<span id="shown" class="muted small" role="status"></span></form>
|
|
213
|
+
${open.length ? `<section aria-label="Surfaces without a captured state"><h3>Surfaces without a captured state</h3><table><thead><tr><th scope="col">Surface</th><th scope="col">Coverage</th></tr></thead><tbody>${open.map(surface => `<tr><td>${esc(surface.id)}</td><td>${surface.status === 'uncovered' ? 'No state covers it' : 'States cover it, none captured yet'}</td></tr>`).join('')}</tbody></table></section>` : ''}
|
|
214
|
+
<main><ul class="states" aria-label="States">
|
|
215
|
+
${catalog.states.map((state, index) => stateHtml(state, index, options)).join('\n')}
|
|
216
|
+
</ul>${catalog.states.length ? '' : '<p class="muted">No UI states have been recorded.</p>'}</main>
|
|
217
|
+
<script type="application/json" id="ui-catalog">${scriptJson(catalog)}</script>
|
|
218
|
+
<script>${SCRIPT}</script>
|
|
219
|
+
</body></html>
|
|
220
|
+
`;
|
|
221
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module Command results (`--format json`)
|
|
3
3
|
*
|
|
4
|
-
* What `atlas report publish`, `stream`, `merge`, `doctor` and `verify-recordings` print on standard output with `--format json` (alias
|
|
4
|
+
* What `atlas report publish`, `stream`, `merge`, `doctor` and `verify-recordings`, and `atlas ui-catalog plan`, `publish`, `status`, `diff` and `export`, print on standard output with `--format json` (alias
|
|
5
5
|
* `--json`), and what any `atlas` command prints on standard error for a command that was wrong: one JSON document,
|
|
6
6
|
* so an agent or a script never has to parse prose. (`status` and `policy` already print documents:
|
|
7
7
|
* `ReportStatusSchema`, and an IAM policy.) Each is also published as a JSON Schema.
|
|
@@ -289,6 +289,153 @@ export declare const InitPlanSchema: z.ZodObject<{
|
|
|
289
289
|
nextSteps: z.ZodArray<z.ZodString>;
|
|
290
290
|
warnings: z.ZodArray<z.ZodString>;
|
|
291
291
|
}, z.core.$loose>;
|
|
292
|
+
/** What `atlas ui-catalog plan --format json` and `atlas ui-catalog publish --format json` print: the one layer that was written. */
|
|
293
|
+
export declare const UiCatalogPublishResultSchema: z.ZodObject<{
|
|
294
|
+
ok: z.ZodLiteral<true>;
|
|
295
|
+
store: z.ZodString;
|
|
296
|
+
layer: z.ZodObject<{
|
|
297
|
+
id: z.ZodString;
|
|
298
|
+
kind: z.ZodEnum<{
|
|
299
|
+
"ui-state": "ui-state";
|
|
300
|
+
"ui-plan": "ui-plan";
|
|
301
|
+
}>;
|
|
302
|
+
producer: z.ZodString;
|
|
303
|
+
environment: z.ZodNullable<z.ZodString>;
|
|
304
|
+
parameters: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
305
|
+
states: z.ZodNumber;
|
|
306
|
+
captured: z.ZodNumber;
|
|
307
|
+
failed: z.ZodNumber;
|
|
308
|
+
pictures: z.ZodNumber;
|
|
309
|
+
pictureBytes: z.ZodNumber;
|
|
310
|
+
shard: z.ZodNullable<z.ZodString>;
|
|
311
|
+
}, z.core.$loose>;
|
|
312
|
+
warnings: z.ZodArray<z.ZodString>;
|
|
313
|
+
}, z.core.$loose>;
|
|
314
|
+
/** One thing that keeps a UI catalog from being complete and good. */
|
|
315
|
+
export declare const UiCatalogProblemSchema: z.ZodObject<{
|
|
316
|
+
kind: z.ZodEnum<{
|
|
317
|
+
failed: "failed";
|
|
318
|
+
missing: "missing";
|
|
319
|
+
unavailable: "unavailable";
|
|
320
|
+
capturing: "capturing";
|
|
321
|
+
unreadable: "unreadable";
|
|
322
|
+
empty: "empty";
|
|
323
|
+
}>;
|
|
324
|
+
state: z.ZodOptional<z.ZodString>;
|
|
325
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
326
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
327
|
+
}, z.core.$loose>;
|
|
328
|
+
/** What `atlas ui-catalog status --format json` prints. */
|
|
329
|
+
export declare const UiCatalogStatusSchema: z.ZodObject<{
|
|
330
|
+
ok: z.ZodBoolean;
|
|
331
|
+
summary: z.ZodObject<{
|
|
332
|
+
states: z.ZodNumber;
|
|
333
|
+
profiles: z.ZodNumber;
|
|
334
|
+
cells: z.ZodNumber;
|
|
335
|
+
coverage: z.ZodRecord<z.ZodEnum<{
|
|
336
|
+
failed: "failed";
|
|
337
|
+
skipped: "skipped";
|
|
338
|
+
missing: "missing";
|
|
339
|
+
captured: "captured";
|
|
340
|
+
capturing: "capturing";
|
|
341
|
+
}>, z.ZodNumber>;
|
|
342
|
+
undeclared: z.ZodNumber;
|
|
343
|
+
planned: z.ZodBoolean;
|
|
344
|
+
surfaces: z.ZodNumber;
|
|
345
|
+
uncoveredSurfaces: z.ZodNumber;
|
|
346
|
+
}, z.core.$loose>;
|
|
347
|
+
profiles: z.ZodArray<z.ZodObject<{
|
|
348
|
+
key: z.ZodString;
|
|
349
|
+
label: z.ZodString;
|
|
350
|
+
}, z.core.$loose>>;
|
|
351
|
+
problems: z.ZodArray<z.ZodObject<{
|
|
352
|
+
kind: z.ZodEnum<{
|
|
353
|
+
failed: "failed";
|
|
354
|
+
missing: "missing";
|
|
355
|
+
unavailable: "unavailable";
|
|
356
|
+
capturing: "capturing";
|
|
357
|
+
unreadable: "unreadable";
|
|
358
|
+
empty: "empty";
|
|
359
|
+
}>;
|
|
360
|
+
state: z.ZodOptional<z.ZodString>;
|
|
361
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
362
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
363
|
+
}, z.core.$loose>>;
|
|
364
|
+
sources: z.ZodObject<{
|
|
365
|
+
layers: z.ZodNumber;
|
|
366
|
+
backends: z.ZodArray<z.ZodObject<{
|
|
367
|
+
id: z.ZodString;
|
|
368
|
+
label: z.ZodString;
|
|
369
|
+
available: z.ZodBoolean;
|
|
370
|
+
}, z.core.$loose>>;
|
|
371
|
+
notices: z.ZodArray<z.ZodString>;
|
|
372
|
+
}, z.core.$loose>;
|
|
373
|
+
}, z.core.$loose>;
|
|
374
|
+
/** What `atlas ui-catalog diff --format json` prints. */
|
|
375
|
+
export declare const UiCatalogDiffResultSchema: z.ZodObject<{
|
|
376
|
+
ok: z.ZodBoolean;
|
|
377
|
+
from: z.ZodObject<{
|
|
378
|
+
sha: z.ZodString;
|
|
379
|
+
states: z.ZodNumber;
|
|
380
|
+
layers: z.ZodNumber;
|
|
381
|
+
}, z.core.$loose>;
|
|
382
|
+
to: z.ZodObject<{
|
|
383
|
+
sha: z.ZodString;
|
|
384
|
+
states: z.ZodNumber;
|
|
385
|
+
layers: z.ZodNumber;
|
|
386
|
+
}, z.core.$loose>;
|
|
387
|
+
summary: z.ZodObject<{
|
|
388
|
+
coverage: z.ZodNumber;
|
|
389
|
+
picture: z.ZodNumber;
|
|
390
|
+
unverified: z.ZodNumber;
|
|
391
|
+
added: z.ZodNumber;
|
|
392
|
+
removed: z.ZodNumber;
|
|
393
|
+
unchanged: z.ZodNumber;
|
|
394
|
+
total: z.ZodNumber;
|
|
395
|
+
}, z.core.$loose>;
|
|
396
|
+
changes: z.ZodArray<z.ZodObject<{
|
|
397
|
+
state: z.ZodString;
|
|
398
|
+
title: z.ZodString;
|
|
399
|
+
profile: z.ZodString;
|
|
400
|
+
change: z.ZodEnum<{
|
|
401
|
+
coverage: "coverage";
|
|
402
|
+
removed: "removed";
|
|
403
|
+
unverified: "unverified";
|
|
404
|
+
added: "added";
|
|
405
|
+
picture: "picture";
|
|
406
|
+
}>;
|
|
407
|
+
from: z.ZodNullable<z.ZodEnum<{
|
|
408
|
+
failed: "failed";
|
|
409
|
+
skipped: "skipped";
|
|
410
|
+
missing: "missing";
|
|
411
|
+
captured: "captured";
|
|
412
|
+
capturing: "capturing";
|
|
413
|
+
}>>;
|
|
414
|
+
to: z.ZodNullable<z.ZodEnum<{
|
|
415
|
+
failed: "failed";
|
|
416
|
+
skipped: "skipped";
|
|
417
|
+
missing: "missing";
|
|
418
|
+
captured: "captured";
|
|
419
|
+
capturing: "capturing";
|
|
420
|
+
}>>;
|
|
421
|
+
}, z.core.$loose>>;
|
|
422
|
+
notices: z.ZodArray<z.ZodString>;
|
|
423
|
+
}, z.core.$loose>;
|
|
424
|
+
/** What `atlas ui-catalog export --format json` prints. */
|
|
425
|
+
export declare const UiCatalogExportResultSchema: z.ZodObject<{
|
|
426
|
+
ok: z.ZodLiteral<true>;
|
|
427
|
+
out: z.ZodString;
|
|
428
|
+
bytes: z.ZodNumber;
|
|
429
|
+
states: z.ZodNumber;
|
|
430
|
+
cells: z.ZodNumber;
|
|
431
|
+
pictures: z.ZodObject<{
|
|
432
|
+
embedded: z.ZodNumber;
|
|
433
|
+
embeddedBytes: z.ZodNumber;
|
|
434
|
+
left: z.ZodNumber;
|
|
435
|
+
}, z.core.$loose>;
|
|
436
|
+
manifest: z.ZodNullable<z.ZodString>;
|
|
437
|
+
warnings: z.ZodArray<z.ZodString>;
|
|
438
|
+
}, z.core.$loose>;
|
|
292
439
|
/**
|
|
293
440
|
* JSON command contracts, shared by schema publication, agent documentation and executable command checks.
|
|
294
441
|
* A new result needs an entry here and a runtime scenario in the documentation contract tests.
|
|
@@ -582,6 +729,176 @@ export declare const CLI_RESULTS: {
|
|
|
582
729
|
notices: z.ZodArray<z.ZodString>;
|
|
583
730
|
}, z.core.$loose>;
|
|
584
731
|
};
|
|
732
|
+
readonly uiCatalogPlan: {
|
|
733
|
+
readonly command: "atlas ui-catalog plan";
|
|
734
|
+
readonly schemaName: "ui-catalog-publish-result";
|
|
735
|
+
readonly schema: z.ZodObject<{
|
|
736
|
+
ok: z.ZodLiteral<true>;
|
|
737
|
+
store: z.ZodString;
|
|
738
|
+
layer: z.ZodObject<{
|
|
739
|
+
id: z.ZodString;
|
|
740
|
+
kind: z.ZodEnum<{
|
|
741
|
+
"ui-state": "ui-state";
|
|
742
|
+
"ui-plan": "ui-plan";
|
|
743
|
+
}>;
|
|
744
|
+
producer: z.ZodString;
|
|
745
|
+
environment: z.ZodNullable<z.ZodString>;
|
|
746
|
+
parameters: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
747
|
+
states: z.ZodNumber;
|
|
748
|
+
captured: z.ZodNumber;
|
|
749
|
+
failed: z.ZodNumber;
|
|
750
|
+
pictures: z.ZodNumber;
|
|
751
|
+
pictureBytes: z.ZodNumber;
|
|
752
|
+
shard: z.ZodNullable<z.ZodString>;
|
|
753
|
+
}, z.core.$loose>;
|
|
754
|
+
warnings: z.ZodArray<z.ZodString>;
|
|
755
|
+
}, z.core.$loose>;
|
|
756
|
+
};
|
|
757
|
+
readonly uiCatalogPublish: {
|
|
758
|
+
readonly command: "atlas ui-catalog publish";
|
|
759
|
+
readonly schemaName: "ui-catalog-publish-result";
|
|
760
|
+
readonly schema: z.ZodObject<{
|
|
761
|
+
ok: z.ZodLiteral<true>;
|
|
762
|
+
store: z.ZodString;
|
|
763
|
+
layer: z.ZodObject<{
|
|
764
|
+
id: z.ZodString;
|
|
765
|
+
kind: z.ZodEnum<{
|
|
766
|
+
"ui-state": "ui-state";
|
|
767
|
+
"ui-plan": "ui-plan";
|
|
768
|
+
}>;
|
|
769
|
+
producer: z.ZodString;
|
|
770
|
+
environment: z.ZodNullable<z.ZodString>;
|
|
771
|
+
parameters: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
772
|
+
states: z.ZodNumber;
|
|
773
|
+
captured: z.ZodNumber;
|
|
774
|
+
failed: z.ZodNumber;
|
|
775
|
+
pictures: z.ZodNumber;
|
|
776
|
+
pictureBytes: z.ZodNumber;
|
|
777
|
+
shard: z.ZodNullable<z.ZodString>;
|
|
778
|
+
}, z.core.$loose>;
|
|
779
|
+
warnings: z.ZodArray<z.ZodString>;
|
|
780
|
+
}, z.core.$loose>;
|
|
781
|
+
};
|
|
782
|
+
readonly uiCatalogStatus: {
|
|
783
|
+
readonly command: "atlas ui-catalog status";
|
|
784
|
+
readonly schemaName: "ui-catalog-status";
|
|
785
|
+
readonly schema: z.ZodObject<{
|
|
786
|
+
ok: z.ZodBoolean;
|
|
787
|
+
summary: z.ZodObject<{
|
|
788
|
+
states: z.ZodNumber;
|
|
789
|
+
profiles: z.ZodNumber;
|
|
790
|
+
cells: z.ZodNumber;
|
|
791
|
+
coverage: z.ZodRecord<z.ZodEnum<{
|
|
792
|
+
failed: "failed";
|
|
793
|
+
skipped: "skipped";
|
|
794
|
+
missing: "missing";
|
|
795
|
+
captured: "captured";
|
|
796
|
+
capturing: "capturing";
|
|
797
|
+
}>, z.ZodNumber>;
|
|
798
|
+
undeclared: z.ZodNumber;
|
|
799
|
+
planned: z.ZodBoolean;
|
|
800
|
+
surfaces: z.ZodNumber;
|
|
801
|
+
uncoveredSurfaces: z.ZodNumber;
|
|
802
|
+
}, z.core.$loose>;
|
|
803
|
+
profiles: z.ZodArray<z.ZodObject<{
|
|
804
|
+
key: z.ZodString;
|
|
805
|
+
label: z.ZodString;
|
|
806
|
+
}, z.core.$loose>>;
|
|
807
|
+
problems: z.ZodArray<z.ZodObject<{
|
|
808
|
+
kind: z.ZodEnum<{
|
|
809
|
+
failed: "failed";
|
|
810
|
+
missing: "missing";
|
|
811
|
+
unavailable: "unavailable";
|
|
812
|
+
capturing: "capturing";
|
|
813
|
+
unreadable: "unreadable";
|
|
814
|
+
empty: "empty";
|
|
815
|
+
}>;
|
|
816
|
+
state: z.ZodOptional<z.ZodString>;
|
|
817
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
818
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
819
|
+
}, z.core.$loose>>;
|
|
820
|
+
sources: z.ZodObject<{
|
|
821
|
+
layers: z.ZodNumber;
|
|
822
|
+
backends: z.ZodArray<z.ZodObject<{
|
|
823
|
+
id: z.ZodString;
|
|
824
|
+
label: z.ZodString;
|
|
825
|
+
available: z.ZodBoolean;
|
|
826
|
+
}, z.core.$loose>>;
|
|
827
|
+
notices: z.ZodArray<z.ZodString>;
|
|
828
|
+
}, z.core.$loose>;
|
|
829
|
+
}, z.core.$loose>;
|
|
830
|
+
};
|
|
831
|
+
readonly uiCatalogDiff: {
|
|
832
|
+
readonly command: "atlas ui-catalog diff";
|
|
833
|
+
readonly schemaName: "ui-catalog-diff-result";
|
|
834
|
+
readonly schema: z.ZodObject<{
|
|
835
|
+
ok: z.ZodBoolean;
|
|
836
|
+
from: z.ZodObject<{
|
|
837
|
+
sha: z.ZodString;
|
|
838
|
+
states: z.ZodNumber;
|
|
839
|
+
layers: z.ZodNumber;
|
|
840
|
+
}, z.core.$loose>;
|
|
841
|
+
to: z.ZodObject<{
|
|
842
|
+
sha: z.ZodString;
|
|
843
|
+
states: z.ZodNumber;
|
|
844
|
+
layers: z.ZodNumber;
|
|
845
|
+
}, z.core.$loose>;
|
|
846
|
+
summary: z.ZodObject<{
|
|
847
|
+
coverage: z.ZodNumber;
|
|
848
|
+
picture: z.ZodNumber;
|
|
849
|
+
unverified: z.ZodNumber;
|
|
850
|
+
added: z.ZodNumber;
|
|
851
|
+
removed: z.ZodNumber;
|
|
852
|
+
unchanged: z.ZodNumber;
|
|
853
|
+
total: z.ZodNumber;
|
|
854
|
+
}, z.core.$loose>;
|
|
855
|
+
changes: z.ZodArray<z.ZodObject<{
|
|
856
|
+
state: z.ZodString;
|
|
857
|
+
title: z.ZodString;
|
|
858
|
+
profile: z.ZodString;
|
|
859
|
+
change: z.ZodEnum<{
|
|
860
|
+
coverage: "coverage";
|
|
861
|
+
removed: "removed";
|
|
862
|
+
unverified: "unverified";
|
|
863
|
+
added: "added";
|
|
864
|
+
picture: "picture";
|
|
865
|
+
}>;
|
|
866
|
+
from: z.ZodNullable<z.ZodEnum<{
|
|
867
|
+
failed: "failed";
|
|
868
|
+
skipped: "skipped";
|
|
869
|
+
missing: "missing";
|
|
870
|
+
captured: "captured";
|
|
871
|
+
capturing: "capturing";
|
|
872
|
+
}>>;
|
|
873
|
+
to: z.ZodNullable<z.ZodEnum<{
|
|
874
|
+
failed: "failed";
|
|
875
|
+
skipped: "skipped";
|
|
876
|
+
missing: "missing";
|
|
877
|
+
captured: "captured";
|
|
878
|
+
capturing: "capturing";
|
|
879
|
+
}>>;
|
|
880
|
+
}, z.core.$loose>>;
|
|
881
|
+
notices: z.ZodArray<z.ZodString>;
|
|
882
|
+
}, z.core.$loose>;
|
|
883
|
+
};
|
|
884
|
+
readonly uiCatalogExport: {
|
|
885
|
+
readonly command: "atlas ui-catalog export";
|
|
886
|
+
readonly schemaName: "ui-catalog-export-result";
|
|
887
|
+
readonly schema: z.ZodObject<{
|
|
888
|
+
ok: z.ZodLiteral<true>;
|
|
889
|
+
out: z.ZodString;
|
|
890
|
+
bytes: z.ZodNumber;
|
|
891
|
+
states: z.ZodNumber;
|
|
892
|
+
cells: z.ZodNumber;
|
|
893
|
+
pictures: z.ZodObject<{
|
|
894
|
+
embedded: z.ZodNumber;
|
|
895
|
+
embeddedBytes: z.ZodNumber;
|
|
896
|
+
left: z.ZodNumber;
|
|
897
|
+
}, z.core.$loose>;
|
|
898
|
+
manifest: z.ZodNullable<z.ZodString>;
|
|
899
|
+
warnings: z.ZodArray<z.ZodString>;
|
|
900
|
+
}, z.core.$loose>;
|
|
901
|
+
};
|
|
585
902
|
readonly error: {
|
|
586
903
|
readonly command: "a command that was wrong (standard error)";
|
|
587
904
|
readonly schemaName: "command-error";
|