@crvouga/atlas 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/playwright/src/index.d.ts +4 -0
  3. package/dist/playwright/src/index.js +4 -0
  4. package/dist/playwright/src/ui-catalog.d.ts +51 -0
  5. package/dist/playwright/src/ui-catalog.js +44 -0
  6. package/dist/reports/src/evidence-types.d.ts +3 -0
  7. package/dist/reports/src/layer-merge.d.ts +5 -1
  8. package/dist/reports/src/layer-merge.js +9 -2
  9. package/dist/reports/src/report-index.js +9 -5
  10. package/dist/reports/src/ui-catalog-publish.d.ts +92 -0
  11. package/dist/reports/src/ui-catalog-publish.js +68 -0
  12. package/dist/reports/src/ui-catalog.d.ts +229 -0
  13. package/dist/reports/src/ui-catalog.js +273 -0
  14. package/dist/reports/src/ui-state.d.ts +70 -0
  15. package/dist/reports/src/ui-state.js +66 -0
  16. package/dist/schema/src/api.d.ts +350 -0
  17. package/dist/schema/src/api.js +71 -0
  18. package/dist/server/src/app.js +15 -0
  19. package/dist/server/src/openapi.js +22 -2
  20. package/docs/reference/http-api.md +46 -0
  21. package/docs/reference/playwright.md +63 -0
  22. package/docs/reference/reports.md +588 -1
  23. package/docs/reference/schema.md +33 -0
  24. package/docs/report-contract.md +2 -2
  25. package/docs/rich-evidence.md +5 -0
  26. package/docs/server.md +2 -0
  27. package/docs/ui-catalog.md +173 -0
  28. package/docs/ui.md +15 -0
  29. package/llms-full.txt +246 -2
  30. package/llms.txt +1 -0
  31. package/package.json +2 -1
  32. package/packages/schema/schemas/effective-delta.schema.json +73 -0
  33. package/packages/schema/schemas/effective-evidence.schema.json +73 -0
  34. package/packages/schema/schemas/layer-rows.schema.json +73 -0
  35. package/packages/server/openapi.json +1441 -79
  36. package/packages/visualizer/src/app/AppShell.tsx +36 -30
  37. package/packages/visualizer/src/app/router.tsx +10 -1
  38. package/packages/visualizer/src/catalog/UiCatalogFilters.tsx +65 -0
  39. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +342 -0
  40. package/packages/visualizer/src/catalog/provider.tsx +25 -0
  41. package/packages/visualizer/src/catalog/search.ts +68 -0
  42. package/packages/visualizer/src/catalog/words.ts +26 -0
  43. package/packages/visualizer/src/components/Header.tsx +17 -4
@@ -0,0 +1,273 @@
1
+ import { joinList, splitList } from './artifact-query.js';
2
+ import { combineEvidence, parameterKey, replaces } from './layer-merge.js';
3
+ import { environmentLabel } from './layer-axes.js';
4
+ import { UI_PLAN_KIND, UI_STATE_KIND } from './ui-state.js';
5
+ /** The `schemaVersion` of the catalog document. */
6
+ export const UI_CATALOG_VERSION = 1;
7
+ /** The values of {@link UiCoverage}, in the order a person reads them. */
8
+ export const UI_COVERAGE = ['captured', 'failed', 'capturing', 'skipped', 'missing'];
9
+ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
10
+ /** The profile of a layer: its environment and its other parameters. */
11
+ export function uiProfile(layer) {
12
+ const parameters = layer.parameters;
13
+ const values = Object.entries(parameters).sort(([a], [b]) => compare(a, b)).map(([, value]) => value);
14
+ return {
15
+ key: `${layer.environment ?? ''}|${parameterKey(parameters)}`,
16
+ environment: layer.environment,
17
+ parameters,
18
+ label: [layer.environment ? environmentLabel(layer.environment) : 'No environment', ...values].join(' · ')
19
+ };
20
+ }
21
+ function encodeKey(key) {
22
+ return key.split('/').map(encodeURIComponent).join('/');
23
+ }
24
+ function imageOf(result) {
25
+ const shot = result.row.state?.screenshot;
26
+ if (!shot)
27
+ return null;
28
+ const webAddress = shot.uri && /^https?:\/\//i.test(shot.uri) ? shot.uri : undefined;
29
+ const { uri: _uri, ...rest } = shot;
30
+ // A live layer has no stored files; an archived one serves them by their key.
31
+ const path = shot.bundleKey && !result.layer.live ? `/layers/${encodeURIComponent(result.layer.id)}/blobs/${encodeKey(shot.bundleKey)}` : undefined;
32
+ return { ...rest, ...(webAddress ? { uri: webAddress } : {}), ...(path ? { path } : {}) };
33
+ }
34
+ function originOf(layer) {
35
+ return {
36
+ layer: layer.id, backend: layer.backend, producer: layer.producer, recordedAtMs: layer.recordedAtMs, sha: layer.sha, branch: layer.branch, runUrl: layer.runUrl,
37
+ ...(layer.freshness ? { freshness: layer.freshness } : {})
38
+ };
39
+ }
40
+ function coverageOf(status) {
41
+ return status === 'passed' ? 'captured' : status === 'failed' ? 'failed' : status === 'running' ? 'capturing' : 'skipped';
42
+ }
43
+ /** The newest result first, ties broken by the report contract's own order so no input order shows. */
44
+ function newestFirst(a, b) {
45
+ return replaces(a, b) ? -1 : replaces(b, a) ? 1 : 0;
46
+ }
47
+ function facetOf(values, labels) {
48
+ const counts = new Map();
49
+ for (const list of values)
50
+ for (const value of new Set(list))
51
+ counts.set(value, (counts.get(value) ?? 0) + 1);
52
+ return [...counts].map(([value, count]) => ({ value, count, ...(labels?.get(value) ? { label: labels.get(value) } : {}) })).sort((a, b) => b.count - a.count || compare(a.value, b.value));
53
+ }
54
+ function emptyCoverage() {
55
+ return { captured: 0, failed: 0, capturing: 0, skipped: 0, missing: 0 };
56
+ }
57
+ /**
58
+ * The UI catalog of some layers (as the effective report holds them, or any set of {@link LayerEvidence}).
59
+ *
60
+ * Only rows of `ui-state` and `ui-plan` parts take part. The winner of every slot is decided by the report contract
61
+ * (`combineEvidence`), so the result does not depend on the order of `layers` or on how they are divided among backends. A
62
+ * state is the set of cells that share a check `id`; a cell is one profile of it. A cell is `captured` only when the winning
63
+ * capture passed; a plan that nothing has captured is `missing`. When no plan exists nothing is `missing` and nothing is
64
+ * `undeclared`: what is expected is then unknown, and the catalog says so (`summary.planned`).
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * const catalog = uiCatalog(effective.layers, { version: effective.version, backends: effective.backends });
69
+ * catalog.states.map(state => [state.id, state.cells.map(cell => cell.coverage)]);
70
+ * ```
71
+ */
72
+ export function uiCatalog(layers, options = {}) {
73
+ const taking = [];
74
+ for (const entry of layers) {
75
+ const rows = entry.rows.filter(row => row.kind === UI_STATE_KIND || row.kind === UI_PLAN_KIND);
76
+ if (rows.length)
77
+ taking.push({ ...entry, rows });
78
+ }
79
+ const winners = combineEvidence({ specs: [], sha: null, changedFiles: [] }, taking).unassigned;
80
+ const planned = winners.some(result => result.row.kind === UI_PLAN_KIND && !result.row.state?.inventory);
81
+ const states = new Map();
82
+ const inventory = new Set();
83
+ for (const result of winners) {
84
+ if (result.row.kind === UI_PLAN_KIND && result.row.state?.inventory) {
85
+ inventory.add(result.row.id);
86
+ continue;
87
+ }
88
+ let state = states.get(result.row.id);
89
+ if (!state)
90
+ states.set(result.row.id, state = { results: [], cells: new Map() });
91
+ state.results.push(result);
92
+ const profile = uiProfile(result.layer);
93
+ let cell = state.cells.get(profile.key);
94
+ if (!cell)
95
+ state.cells.set(profile.key, cell = { profile, captures: [], plans: [] });
96
+ (result.row.kind === UI_PLAN_KIND ? cell.plans : cell.captures).push(result);
97
+ }
98
+ const profiles = new Map();
99
+ const catalogStates = [];
100
+ for (const [id, state] of [...states].sort(([a], [b]) => compare(a, b))) {
101
+ const ordered = state.results.slice().sort(newestFirst);
102
+ const scalar = (pick) => ordered.map(pick).find((value) => value !== undefined) ?? null;
103
+ const union = (pick) => [...new Set(ordered.flatMap(result => pick(result) ?? []))].sort(compare);
104
+ const cells = [...state.cells.values()].sort((a, b) => compare(a.profile.label, b.profile.label) || compare(a.profile.key, b.profile.key)).map(cell => {
105
+ profiles.set(cell.profile.key, cell.profile);
106
+ const best = cell.captures.slice().sort(newestFirst)[0];
107
+ const plan = cell.plans.slice().sort(newestFirst)[0];
108
+ const capture = best ? {
109
+ status: best.row.status, detail: best.row.detail, durationMs: best.row.durationMs, image: imageOf(best),
110
+ labels: best.row.evidence?.labels ?? {}, origin: originOf(best.layer), alternatives: cell.captures.length - 1
111
+ } : null;
112
+ return {
113
+ profile: cell.profile,
114
+ coverage: best ? coverageOf(best.row.status) : 'missing',
115
+ expected: plan ? 'declared' : planned ? 'undeclared' : 'unknown',
116
+ capture,
117
+ planned: plan ? { layer: plan.layer.id, backend: plan.layer.backend } : null
118
+ };
119
+ });
120
+ catalogStates.push({
121
+ id, title: ordered[0]?.row.title ?? id,
122
+ route: scalar(result => result.row.state?.route), fixture: scalar(result => result.row.state?.fixture), cohort: scalar(result => result.row.state?.cohort), area: scalar(result => result.row.state?.area),
123
+ flags: union(result => result.row.state?.flags), covers: union(result => result.row.state?.covers),
124
+ producers: [...new Set(ordered.map(result => result.layer.producer))].sort(compare), cells
125
+ });
126
+ }
127
+ const surfaces = new Map();
128
+ for (const id of inventory)
129
+ surfaces.set(id, { states: 0, captured: 0 });
130
+ for (const state of catalogStates) {
131
+ const has = state.cells.some(cell => cell.coverage === 'captured');
132
+ for (const surface of state.covers) {
133
+ const found = surfaces.get(surface) ?? { states: 0, captured: 0 };
134
+ found.states += 1;
135
+ if (has)
136
+ found.captured += 1;
137
+ surfaces.set(surface, found);
138
+ }
139
+ }
140
+ const surfaceList = [...surfaces].sort(([a], [b]) => compare(a, b)).map(([id, found]) => ({
141
+ id, inventoried: inventory.has(id), states: found.states, captured: found.captured,
142
+ status: found.captured > 0 ? 'covered' : found.states > 0 ? 'uncaptured' : 'uncovered'
143
+ }));
144
+ const coverage = emptyCoverage();
145
+ let undeclared = 0, cells = 0;
146
+ for (const state of catalogStates)
147
+ for (const cell of state.cells) {
148
+ coverage[cell.coverage] += 1;
149
+ cells += 1;
150
+ if (cell.expected === 'undeclared')
151
+ undeclared += 1;
152
+ }
153
+ const profileList = [...profiles.values()].sort((a, b) => compare(a.label, b.label) || compare(a.key, b.key));
154
+ const labels = new Map(profileList.map(profile => [profile.key, profile.label]));
155
+ return {
156
+ schemaVersion: UI_CATALOG_VERSION,
157
+ ...(options.version ? { version: options.version } : {}),
158
+ summary: { states: catalogStates.length, profiles: profileList.length, cells, coverage, undeclared, planned, surfaces: surfaceList.length, uncoveredSurfaces: surfaceList.filter(surface => surface.status !== 'covered').length },
159
+ profiles: profileList,
160
+ states: catalogStates,
161
+ surfaces: surfaceList,
162
+ facets: {
163
+ profiles: facetOf(catalogStates.map(state => state.cells.map(cell => cell.profile.key)), labels),
164
+ coverage: UI_COVERAGE.map(value => ({ value, count: catalogStates.filter(state => state.cells.some(cell => cell.coverage === value)).length })),
165
+ areas: facetOf(catalogStates.map(state => (state.area ? [state.area] : []))),
166
+ routes: facetOf(catalogStates.map(state => (state.route ? [state.route] : []))),
167
+ cohorts: facetOf(catalogStates.map(state => (state.cohort ? [state.cohort] : []))),
168
+ flags: facetOf(catalogStates.map(state => state.flags)),
169
+ surfaces: facetOf(catalogStates.map(state => state.covers)),
170
+ producers: facetOf(catalogStates.map(state => state.producers))
171
+ },
172
+ sources: { layers: taking.length, backends: options.backends ?? [], notices: options.notices ?? [] }
173
+ };
174
+ }
175
+ const LIST_FIELDS = ['coverage', 'profile', 'area', 'route', 'cohort', 'flag', 'surface', 'producer'];
176
+ /** Reads a query from URL search parameters (`q`, and the comma-separated {@link UiCatalogQuery} lists). Unknown coverage values are left out, never an error. */
177
+ export function parseUiCatalogQuery(params) {
178
+ const query = {};
179
+ const q = params.get('q')?.trim();
180
+ if (q)
181
+ query.q = q;
182
+ for (const name of LIST_FIELDS) {
183
+ const values = params.getAll(name).flatMap(splitList);
184
+ const kept = name === 'coverage' ? values.filter(value => UI_COVERAGE.includes(value)) : values;
185
+ if (kept.length)
186
+ query[name] = kept;
187
+ }
188
+ return query;
189
+ }
190
+ /** A query as URL search parameters, the form {@link parseUiCatalogQuery} reads back. */
191
+ export function uiCatalogParams(query) {
192
+ const params = new URLSearchParams();
193
+ if (query.q)
194
+ params.set('q', query.q);
195
+ for (const name of LIST_FIELDS) {
196
+ const joined = joinList(query[name] ?? []);
197
+ if (joined)
198
+ params.set(name, joined);
199
+ }
200
+ return params;
201
+ }
202
+ /** Whether a query narrows anything. */
203
+ export function isUiCatalogFiltered(query) {
204
+ return Boolean(query.q) || LIST_FIELDS.some(name => (query[name]?.length ?? 0) > 0);
205
+ }
206
+ function haystack(state) {
207
+ return [state.id, state.title, state.route, state.fixture, state.cohort, state.area, ...state.flags, ...state.covers, ...state.producers].filter(Boolean).join(' ').toLowerCase();
208
+ }
209
+ /**
210
+ * The part of a catalog a query asks for. A state is kept when it matches the words and the fact filters; its cells are
211
+ * kept when they match the `profile` and `coverage` filters, and a state left with no cell is dropped. The summary is
212
+ * recomputed for what is kept; the profiles, surfaces, facets and sources stay those of the whole catalog, so the
213
+ * controls keep offering every choice.
214
+ */
215
+ export function filterUiCatalog(catalog, query) {
216
+ if (!isUiCatalogFiltered(query))
217
+ return catalog;
218
+ const terms = (query.q ?? '').toLowerCase().split(/\s+/).filter(Boolean);
219
+ const any = (wanted, has) => !wanted?.length || wanted.some(has);
220
+ const states = [];
221
+ for (const state of catalog.states) {
222
+ if (terms.length) {
223
+ const text = haystack(state);
224
+ if (!terms.every(term => text.includes(term)))
225
+ continue;
226
+ }
227
+ if (!any(query.area, value => state.area === value) || !any(query.route, value => state.route === value) || !any(query.cohort, value => state.cohort === value))
228
+ continue;
229
+ if (!any(query.flag, value => state.flags.includes(value)) || !any(query.surface, value => state.covers.includes(value)) || !any(query.producer, value => state.producers.includes(value)))
230
+ continue;
231
+ const cells = state.cells.filter(cell => any(query.profile, value => cell.profile.key === value) && any(query.coverage, value => cell.coverage === value));
232
+ if (cells.length)
233
+ states.push(cells.length === state.cells.length ? state : { ...state, cells });
234
+ }
235
+ const coverage = emptyCoverage();
236
+ let cells = 0, undeclared = 0;
237
+ for (const state of states)
238
+ for (const cell of state.cells) {
239
+ coverage[cell.coverage] += 1;
240
+ cells += 1;
241
+ if (cell.expected === 'undeclared')
242
+ undeclared += 1;
243
+ }
244
+ return { ...catalog, states, summary: { ...catalog.summary, states: states.length, cells, coverage, undeclared } };
245
+ }
246
+ /** A page of a catalog's states, for a document that would otherwise be large. The summary, facets and sources are the whole catalog's. */
247
+ export function pageUiCatalog(catalog, page) {
248
+ const offset = Math.max(0, page.offset ?? 0);
249
+ const limit = Math.max(0, page.limit ?? 200);
250
+ return { ...catalog, total: catalog.states.length, offset, limit, states: catalog.states.slice(offset, offset + limit) };
251
+ }
252
+ /**
253
+ * The catalog as Markdown for a person or an agent reading text: the summary in words, the surfaces that are not covered, and
254
+ * one line per state with its coverage in each profile. Only `captured` is called captured.
255
+ */
256
+ export function uiCatalogMarkdown(catalog) {
257
+ const { summary } = catalog;
258
+ const lines = [
259
+ '# UI catalog', '',
260
+ `${summary.states} states in ${summary.profiles} profiles: ${UI_COVERAGE.map(value => `${summary.coverage[value]} ${value}`).join(', ')}.`,
261
+ summary.planned ? `${summary.undeclared} captured cells are not in any plan.` : 'No plan was published, so what should exist is unknown and nothing is shown as missing.'
262
+ ];
263
+ for (const notice of catalog.sources.notices)
264
+ lines.push('', `> ${notice}`);
265
+ const open = catalog.surfaces.filter(surface => surface.status !== 'covered');
266
+ if (open.length)
267
+ lines.push('', '## Surfaces without a captured state', '', ...open.map(surface => `- ${surface.id}: ${surface.status === 'uncovered' ? 'no state covers it' : 'covered by states that are not captured'}`));
268
+ lines.push('', '## States', '');
269
+ for (const state of catalog.states) {
270
+ lines.push(`- **${state.title}** (${state.id})${state.route ? ` ${state.route}` : ''}: ${state.cells.map(cell => `${cell.profile.label} ${cell.coverage}`).join('; ')}`);
271
+ }
272
+ return `${lines.join('\n')}\n`;
273
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @module UI state conventions
3
+ *
4
+ * How a captured UI state is written in a layer, using nothing but what the layer format already has. A **state** is a
5
+ * check in a part of kind `ui-state`: its `id` names the state, its `status` says how the capture went, a `screenshot`
6
+ * attachment is the picture, and the facts the producer declares about it (route, fixture, cohort, flags, the surfaces it
7
+ * covers) sit under the `atlas.ui-state` extension. A **profile** (viewport, color scheme, device) is the layer's
8
+ * `environment` and `parameters`, so a state captured at another viewport is another result, side by side. What is
9
+ * **expected** is a layer of kind `ui-plan` with the same ids and profiles, every check `not-run`.
10
+ *
11
+ * Nothing here decides a result: a status, a slot and a winner are what the report contract says they are. This module
12
+ * only reads the convention into a small optional row field ({@link UiStateFact}) so a viewer needs no second request to
13
+ * list a catalog. A producer that writes something else is still read; what cannot be read is left out, never rejected.
14
+ *
15
+ * @see docs/ui-catalog.md
16
+ */
17
+ import type { Check } from '../../schema/src/layer.js';
18
+ /** The part kind of captured states. */
19
+ export declare const UI_STATE_KIND = "ui-state";
20
+ /** The part kind of the expected set: states (and surfaces) that should exist, whether or not they were captured. */
21
+ export declare const UI_PLAN_KIND = "ui-plan";
22
+ /** The check extension that carries the facts a producer declares about a state. */
23
+ export declare const UI_STATE_EXTENSION = "atlas.ui-state";
24
+ /** Whether a part kind belongs to the UI catalog (`ui-state` or `ui-plan`). */
25
+ export declare function isUiKind(kind: string): boolean;
26
+ /** The picture of a captured state: a stored file (`bundleKey`), or an address (`uri`). Everything but `name` is optional. */
27
+ export type UiScreenshot = {
28
+ readonly name: string;
29
+ readonly bundleKey?: string;
30
+ readonly uri?: string;
31
+ readonly contentType?: string;
32
+ readonly width?: number;
33
+ readonly height?: number;
34
+ readonly bytes?: number;
35
+ readonly sha256?: string;
36
+ /** Why the producer left the bytes out (`too-large`, `policy`…): a picture that was never taken is told apart from one left out. */
37
+ readonly omitted?: string;
38
+ };
39
+ /** The facts a producer declares about a state, under the `atlas.ui-state` extension. All optional, all strings or lists of strings. */
40
+ export type UiStateFacts = {
41
+ /** The address or screen the state is of (`/checkout/payment`). */
42
+ readonly route?: string;
43
+ /** The test data it was captured with. */
44
+ readonly fixture?: string;
45
+ /** The audience or account kind it shows (`new-customer`). */
46
+ readonly cohort?: string;
47
+ /** Feature flags that were on. */
48
+ readonly flags?: readonly string[];
49
+ /** The product surfaces (screens, components) this state covers, for coverage against an inventory. */
50
+ readonly covers?: readonly string[];
51
+ /** A grouping a person navigates by (`checkout`). */
52
+ readonly area?: string;
53
+ /** On a `ui-plan` check only: this entry is a surface of the product's inventory, not a state. Nothing is captured for it. */
54
+ readonly inventory?: boolean;
55
+ };
56
+ /** What a row of a `ui-state` or `ui-plan` part carries for the catalog. Rides in the report row, only for those kinds. */
57
+ export type UiStateFact = UiStateFacts & {
58
+ /** The check's picture: its `screenshot` attachment, else its first image. */
59
+ readonly screenshot?: UiScreenshot;
60
+ };
61
+ /**
62
+ * The declared facts of a check, read from its `atlas.ui-state` extension. Strings are trimmed and bounded, lists are
63
+ * distinct and bounded, and anything of another shape is dropped; a check without the extension has no facts.
64
+ */
65
+ export declare function uiStateFacts(check: Pick<Check, 'extensions'>): UiStateFacts;
66
+ /**
67
+ * What a check of a part of `kind` carries for the catalog: its declared facts and, in a `ui-state` part, its picture.
68
+ * `undefined` for any other kind, and for a UI check that carries neither, so ordinary rows stay as small as they were.
69
+ */
70
+ export declare function uiStateOf(kind: string, check: Pick<Check, 'extensions' | 'attachments'>): UiStateFact | undefined;
@@ -0,0 +1,66 @@
1
+ /** The part kind of captured states. */
2
+ export const UI_STATE_KIND = 'ui-state';
3
+ /** The part kind of the expected set: states (and surfaces) that should exist, whether or not they were captured. */
4
+ export const UI_PLAN_KIND = 'ui-plan';
5
+ /** The check extension that carries the facts a producer declares about a state. */
6
+ export const UI_STATE_EXTENSION = 'atlas.ui-state';
7
+ /** Whether a part kind belongs to the UI catalog (`ui-state` or `ui-plan`). */
8
+ export function isUiKind(kind) {
9
+ return kind === UI_STATE_KIND || kind === UI_PLAN_KIND;
10
+ }
11
+ const MAX_TEXT = 512;
12
+ const MAX_LIST = 64;
13
+ function text(value) {
14
+ return typeof value === 'string' && value.trim() !== '' ? value.trim().slice(0, MAX_TEXT) : undefined;
15
+ }
16
+ function list(value) {
17
+ if (!Array.isArray(value))
18
+ return undefined;
19
+ const out = [...new Set(value.map(text).filter((item) => item !== undefined))].slice(0, MAX_LIST);
20
+ return out.length ? out : undefined;
21
+ }
22
+ function screenshotOf(check) {
23
+ const images = (check.attachments ?? []).filter(item => item.role === 'screenshot' || (item.contentType ?? '').startsWith('image/'));
24
+ const picked = images.find(item => item.role === 'screenshot') ?? images[0];
25
+ if (!picked)
26
+ return undefined;
27
+ return {
28
+ name: picked.name,
29
+ ...(picked.bundleKey ? { bundleKey: picked.bundleKey } : {}),
30
+ ...(picked.uri ? { uri: picked.uri } : {}),
31
+ ...(picked.contentType ? { contentType: picked.contentType } : {}),
32
+ ...(picked.width !== undefined ? { width: picked.width } : {}),
33
+ ...(picked.height !== undefined ? { height: picked.height } : {}),
34
+ ...(picked.bytes !== undefined ? { bytes: picked.bytes } : {}),
35
+ ...(picked.sha256 ? { sha256: picked.sha256 } : {}),
36
+ ...(picked.omitted ? { omitted: picked.omitted } : {})
37
+ };
38
+ }
39
+ /**
40
+ * The declared facts of a check, read from its `atlas.ui-state` extension. Strings are trimmed and bounded, lists are
41
+ * distinct and bounded, and anything of another shape is dropped; a check without the extension has no facts.
42
+ */
43
+ export function uiStateFacts(check) {
44
+ const raw = check.extensions?.[UI_STATE_EXTENSION];
45
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
46
+ return {};
47
+ const given = raw;
48
+ const route = text(given.route), fixture = text(given.fixture), cohort = text(given.cohort), area = text(given.area);
49
+ const flags = list(given.flags), covers = list(given.covers);
50
+ return {
51
+ ...(route ? { route } : {}), ...(fixture ? { fixture } : {}), ...(cohort ? { cohort } : {}), ...(area ? { area } : {}),
52
+ ...(flags ? { flags } : {}), ...(covers ? { covers } : {}),
53
+ ...(given.inventory === true ? { inventory: true } : {})
54
+ };
55
+ }
56
+ /**
57
+ * What a check of a part of `kind` carries for the catalog: its declared facts and, in a `ui-state` part, its picture.
58
+ * `undefined` for any other kind, and for a UI check that carries neither, so ordinary rows stay as small as they were.
59
+ */
60
+ export function uiStateOf(kind, check) {
61
+ if (!isUiKind(kind))
62
+ return undefined;
63
+ const facts = uiStateFacts(check);
64
+ const screenshot = kind === UI_STATE_KIND ? screenshotOf(check) : undefined;
65
+ return screenshot || Object.keys(facts).length ? { ...facts, ...(screenshot ? { screenshot } : {}) } : undefined;
66
+ }