@crvouga/atlas 1.13.0 → 1.15.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 (65) hide show
  1. package/CHANGELOG.md +14 -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-detail.d.ts +19 -0
  7. package/dist/reports/src/evidence-detail.js +31 -4
  8. package/dist/reports/src/evidence-types.d.ts +5 -2
  9. package/dist/reports/src/layer-merge.d.ts +5 -1
  10. package/dist/reports/src/layer-merge.js +13 -5
  11. package/dist/reports/src/layer-store.d.ts +1 -1
  12. package/dist/reports/src/layer-store.js +4 -2
  13. package/dist/reports/src/layer-types.d.ts +2 -0
  14. package/dist/reports/src/report-index.js +9 -5
  15. package/dist/reports/src/run-compare.d.ts +182 -0
  16. package/dist/reports/src/run-compare.js +329 -0
  17. package/dist/reports/src/test-catalog.d.ts +8 -0
  18. package/dist/reports/src/test-catalog.js +14 -1
  19. package/dist/reports/src/ui-catalog-publish.d.ts +92 -0
  20. package/dist/reports/src/ui-catalog-publish.js +68 -0
  21. package/dist/reports/src/ui-catalog.d.ts +229 -0
  22. package/dist/reports/src/ui-catalog.js +273 -0
  23. package/dist/reports/src/ui-state.d.ts +70 -0
  24. package/dist/reports/src/ui-state.js +66 -0
  25. package/dist/schema/src/api.d.ts +354 -0
  26. package/dist/schema/src/api.js +78 -3
  27. package/dist/server/src/app.js +15 -0
  28. package/dist/server/src/openapi.js +22 -2
  29. package/docs/reference/http-api.md +46 -0
  30. package/docs/reference/playwright.md +63 -0
  31. package/docs/reference/reports.md +942 -4
  32. package/docs/reference/schema.md +33 -0
  33. package/docs/report-contract.md +2 -2
  34. package/docs/rich-evidence.md +5 -0
  35. package/docs/server.md +2 -0
  36. package/docs/ui-catalog.md +173 -0
  37. package/docs/ui.md +45 -1
  38. package/llms-full.txt +276 -3
  39. package/llms.txt +1 -0
  40. package/package.json +2 -1
  41. package/packages/schema/schemas/effective-delta.schema.json +75 -2
  42. package/packages/schema/schemas/effective-evidence.schema.json +75 -2
  43. package/packages/schema/schemas/evidence-page.schema.json +1 -1
  44. package/packages/schema/schemas/layer-rows.schema.json +74 -1
  45. package/packages/schema/schemas/report-run.schema.json +25 -3
  46. package/packages/schema/schemas/report-runs.schema.json +1 -1
  47. package/packages/schema/schemas/review-collection.schema.json +1 -1
  48. package/packages/schema/schemas/review-manifest.schema.json +1 -1
  49. package/packages/schema/schemas/test-catalog.schema.json +1 -1
  50. package/packages/schema/schemas/test-history.schema.json +2 -2
  51. package/packages/server/openapi.json +1559 -175
  52. package/packages/visualizer/src/app/AppShell.tsx +36 -30
  53. package/packages/visualizer/src/app/router.tsx +10 -1
  54. package/packages/visualizer/src/catalog/UiCatalogFilters.tsx +65 -0
  55. package/packages/visualizer/src/catalog/UiCatalogPage.tsx +342 -0
  56. package/packages/visualizer/src/catalog/provider.tsx +25 -0
  57. package/packages/visualizer/src/catalog/search.ts +68 -0
  58. package/packages/visualizer/src/catalog/words.ts +26 -0
  59. package/packages/visualizer/src/components/Header.tsx +17 -4
  60. package/packages/visualizer/src/explorer/ComparePair.tsx +597 -0
  61. package/packages/visualizer/src/explorer/CompareView.tsx +340 -0
  62. package/packages/visualizer/src/explorer/Explorer.tsx +6 -2
  63. package/packages/visualizer/src/explorer/RunsView.tsx +10 -0
  64. package/packages/visualizer/src/explorer/playlist.tsx +1 -1
  65. package/packages/visualizer/src/explorer/search.ts +15 -3
@@ -0,0 +1,229 @@
1
+ /**
2
+ * @module UI catalog
3
+ *
4
+ * A browsable inventory of a product's user interface, computed from layers and nothing else: each **state** (a screen, or
5
+ * one state of a screen) captured once per **profile** (viewport, color scheme, device), with the facts needed to find and
6
+ * trust it, and what should exist but has not been captured yet. `uiCatalog` is a pure function of the winning results of
7
+ * the layers it is given, so it inherits the report contract's laws: the same layers in any order, split any way across
8
+ * backends, shards and machines, give the same catalog; a layer changes only the cells it fills; an unreadable layer is
9
+ * absent, never captured. It imports no collection framework and knows nothing about how a picture was taken.
10
+ *
11
+ * How the convention maps onto the layer format is in {@link ./ui-state}: a state is a check of a `ui-state` part, a profile
12
+ * is the layer's `environment` and `parameters`, the expected set is a `ui-plan` part. The viewer computes the catalog from
13
+ * the report it already streams (so it fills in as results arrive), and the server answers the same document at
14
+ * `GET /api/reports/ui-catalog`.
15
+ *
16
+ * @see docs/ui-catalog.md
17
+ * @see docs/report-contract.md#how-layers-compose
18
+ */
19
+ import type { BackendInfo, LayerEvidence, LayerHeader } from './evidence-types.js';
20
+ import type { EvidenceStatus } from './explorer-types.js';
21
+ import { type UiScreenshot } from './ui-state.js';
22
+ /** The `schemaVersion` of the catalog document. */
23
+ export declare const UI_CATALOG_VERSION = 1;
24
+ /** One profile: the client and the choices a state was captured under. Its `key` identifies it across layers. */
25
+ export type UiProfile = {
26
+ /** Stable identity: the environment and the sorted parameters. */
27
+ readonly key: string;
28
+ /** The client (`desktop-web`, `ios`…), or null when the layer named none. */
29
+ readonly environment: string | null;
30
+ /** The layer's other choices, such as `{ viewport: '390x844', scheme: 'dark' }`. */
31
+ readonly parameters: Readonly<Record<string, string>>;
32
+ /** For a person: the client and each parameter's value. */
33
+ readonly label: string;
34
+ };
35
+ /**
36
+ * How one state stands in one profile. `captured`: a capture passed. `failed`: it was attempted and failed (its `detail`
37
+ * says why). `capturing`: it is being taken now. `skipped`: a producer recorded it as not run. `missing`: expected, and
38
+ * nothing has been recorded. Only `captured` is ever shown as captured; every other value means there is no picture to trust.
39
+ */
40
+ export type UiCoverage = 'captured' | 'failed' | 'capturing' | 'skipped' | 'missing';
41
+ /** The values of {@link UiCoverage}, in the order a person reads them. */
42
+ export declare const UI_COVERAGE: readonly ["captured", "failed", "capturing", "skipped", "missing"];
43
+ /** A picture, with the address a browser loads it from. */
44
+ export type UiImage = UiScreenshot & {
45
+ /** Where the stored bytes are, relative to the reports API root (`/layers/<layer>/blobs/attachments/<sha256>`). Absent for an address-only or omitted picture. */
46
+ readonly path?: string;
47
+ };
48
+ /** Where a capture came from: the layer, the backend that holds it, and the provenance the layer recorded. */
49
+ export type UiOrigin = {
50
+ readonly layer: string;
51
+ readonly backend: string;
52
+ readonly producer: string;
53
+ readonly recordedAtMs: number;
54
+ readonly sha: string | null;
55
+ readonly branch: string | null;
56
+ readonly runUrl: string | null;
57
+ readonly freshness?: 'unknown' | 'modified';
58
+ };
59
+ /** The winning capture of one cell. */
60
+ export type UiCapture = {
61
+ readonly status: EvidenceStatus;
62
+ /** Why it failed or was skipped; empty otherwise. */
63
+ readonly detail: string;
64
+ readonly durationMs: number | null;
65
+ /** The picture, or null when the capture has none (or it was left out: see `omitted`). */
66
+ readonly image: UiImage | null;
67
+ /** The check's labels: what describes the capture without splitting it (a `shard`, a `browser`). */
68
+ readonly labels: Readonly<Record<string, string>>;
69
+ readonly origin: UiOrigin;
70
+ /** How many other captures of this cell exist from other producers. The winner is the one the report contract picks. */
71
+ readonly alternatives: number;
72
+ };
73
+ /** One state in one profile. */
74
+ export type UiCell = {
75
+ readonly profile: UiProfile;
76
+ readonly coverage: UiCoverage;
77
+ /** Whether a plan expects this state in this profile: `declared`; `undeclared` when plans exist and none names it; `unknown` when no plan was published at all. */
78
+ readonly expected: 'declared' | 'undeclared' | 'unknown';
79
+ readonly capture: UiCapture | null;
80
+ /** The layer of the plan that expects it, when one does. */
81
+ readonly planned: {
82
+ readonly layer: string;
83
+ readonly backend: string;
84
+ } | null;
85
+ };
86
+ /** One state of the product's interface, across its profiles. */
87
+ export type UiCatalogState = {
88
+ /** The check id the producers gave it. */
89
+ readonly id: string;
90
+ readonly title: string;
91
+ readonly route: string | null;
92
+ readonly fixture: string | null;
93
+ readonly cohort: string | null;
94
+ readonly area: string | null;
95
+ readonly flags: readonly string[];
96
+ /** The surfaces it covers. */
97
+ readonly covers: readonly string[];
98
+ /** The producers that captured or planned it. */
99
+ readonly producers: readonly string[];
100
+ /** One cell per profile, ordered by the profile's label. */
101
+ readonly cells: readonly UiCell[];
102
+ };
103
+ /** One surface of the product: an inventory entry, or something a state says it covers. */
104
+ export type UiSurface = {
105
+ readonly id: string;
106
+ /** True when a `ui-plan` inventory entry names it: the product is known to have it. */
107
+ readonly inventoried: boolean;
108
+ /** States that cover it. */
109
+ readonly states: number;
110
+ /** Of those, states with at least one captured profile. */
111
+ readonly captured: number;
112
+ /** `covered`: some covering state is captured. `uncaptured`: states cover it and none is captured yet. `uncovered`: no state covers it. */
113
+ readonly status: 'covered' | 'uncaptured' | 'uncovered';
114
+ };
115
+ /** A value of a facet and how many states have it. */
116
+ export type UiFacetValue = {
117
+ readonly value: string;
118
+ readonly count: number;
119
+ readonly label?: string;
120
+ };
121
+ /** The values each filter offers, over every state of the catalog. */
122
+ export type UiCatalogFacets = {
123
+ readonly profiles: readonly UiFacetValue[];
124
+ readonly coverage: readonly UiFacetValue[];
125
+ readonly areas: readonly UiFacetValue[];
126
+ readonly routes: readonly UiFacetValue[];
127
+ readonly cohorts: readonly UiFacetValue[];
128
+ readonly flags: readonly UiFacetValue[];
129
+ readonly surfaces: readonly UiFacetValue[];
130
+ readonly producers: readonly UiFacetValue[];
131
+ };
132
+ /** The catalog in numbers. Cells count a state once per profile. */
133
+ export type UiCatalogSummary = {
134
+ readonly states: number;
135
+ readonly profiles: number;
136
+ readonly cells: number;
137
+ readonly coverage: Readonly<Record<UiCoverage, number>>;
138
+ /** Captured or failed cells no plan expects. Zero when no plan was published, because then nothing is known to be unexpected. */
139
+ readonly undeclared: number;
140
+ /** Whether any plan (an expected set) was published. Without one, `missing` can only ever be zero. */
141
+ readonly planned: boolean;
142
+ readonly surfaces: number;
143
+ readonly uncoveredSurfaces: number;
144
+ };
145
+ /** The catalog, as `GET /api/reports/ui-catalog` answers it (`version` is the report's, when the caller knows it). */
146
+ export type UiCatalog = {
147
+ readonly schemaVersion: typeof UI_CATALOG_VERSION;
148
+ readonly version?: string;
149
+ readonly summary: UiCatalogSummary;
150
+ readonly profiles: readonly UiProfile[];
151
+ readonly states: readonly UiCatalogState[];
152
+ readonly surfaces: readonly UiSurface[];
153
+ readonly facets: UiCatalogFacets;
154
+ /** What could not be read. A state that depended on it is not in the catalog as captured. Filled by the caller who read the backends. */
155
+ readonly sources: {
156
+ readonly layers: number;
157
+ readonly backends: readonly BackendInfo[];
158
+ readonly notices: readonly string[];
159
+ };
160
+ };
161
+ /** What a caller may say about how the layers were read. */
162
+ export type UiCatalogOptions = {
163
+ /** The report's version, repeated in the document. */
164
+ readonly version?: string;
165
+ /** The backends, with whether each could be read. */
166
+ readonly backends?: readonly BackendInfo[];
167
+ /** What could not be read. */
168
+ readonly notices?: readonly string[];
169
+ };
170
+ /** The profile of a layer: its environment and its other parameters. */
171
+ export declare function uiProfile(layer: Pick<LayerHeader, 'environment' | 'parameters'>): UiProfile;
172
+ /**
173
+ * The UI catalog of some layers (as the effective report holds them, or any set of {@link LayerEvidence}).
174
+ *
175
+ * Only rows of `ui-state` and `ui-plan` parts take part. The winner of every slot is decided by the report contract
176
+ * (`combineEvidence`), so the result does not depend on the order of `layers` or on how they are divided among backends. A
177
+ * 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
178
+ * capture passed; a plan that nothing has captured is `missing`. When no plan exists nothing is `missing` and nothing is
179
+ * `undeclared`: what is expected is then unknown, and the catalog says so (`summary.planned`).
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * const catalog = uiCatalog(effective.layers, { version: effective.version, backends: effective.backends });
184
+ * catalog.states.map(state => [state.id, state.cells.map(cell => cell.coverage)]);
185
+ * ```
186
+ */
187
+ export declare function uiCatalog(layers: readonly LayerEvidence[], options?: UiCatalogOptions): UiCatalog;
188
+ /** What a person narrows the catalog by. Every field is optional; nothing asked is everything. Values within a field are alternatives, fields combine with AND. */
189
+ export type UiCatalogQuery = {
190
+ /** Words (all of them) found in the id, title, route, fixture, cohort, area, flags, surfaces or producers. */
191
+ readonly q?: string;
192
+ readonly coverage?: readonly UiCoverage[];
193
+ /** Profile keys, as {@link UiProfile.key}. */
194
+ readonly profile?: readonly string[];
195
+ readonly area?: readonly string[];
196
+ readonly route?: readonly string[];
197
+ readonly cohort?: readonly string[];
198
+ readonly flag?: readonly string[];
199
+ /** Surfaces a state covers. */
200
+ readonly surface?: readonly string[];
201
+ readonly producer?: readonly string[];
202
+ };
203
+ /** Reads a query from URL search parameters (`q`, and the comma-separated {@link UiCatalogQuery} lists). Unknown coverage values are left out, never an error. */
204
+ export declare function parseUiCatalogQuery(params: URLSearchParams): UiCatalogQuery;
205
+ /** A query as URL search parameters, the form {@link parseUiCatalogQuery} reads back. */
206
+ export declare function uiCatalogParams(query: UiCatalogQuery): URLSearchParams;
207
+ /** Whether a query narrows anything. */
208
+ export declare function isUiCatalogFiltered(query: UiCatalogQuery): boolean;
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 declare function filterUiCatalog(catalog: UiCatalog, query: UiCatalogQuery): UiCatalog;
216
+ /** 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. */
217
+ export declare function pageUiCatalog(catalog: UiCatalog, page: {
218
+ readonly limit?: number;
219
+ readonly offset?: number;
220
+ }): UiCatalog & {
221
+ readonly total: number;
222
+ readonly offset: number;
223
+ readonly limit: number;
224
+ };
225
+ /**
226
+ * The catalog as Markdown for a person or an agent reading text: the summary in words, the surfaces that are not covered, and
227
+ * one line per state with its coverage in each profile. Only `captured` is called captured.
228
+ */
229
+ export declare function uiCatalogMarkdown(catalog: UiCatalog): string;
@@ -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
+ }