@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
package/CHANGELOG.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.13.0 (2026-10-29)
3
+ ## 1.14.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - add a UI catalog of captured states that streams, shards and is independent of the collector ([81b2938](https://github.com/crvouga/atlas/commit/81b29388b4cb77ca8c3763aac969d23436cd18dd))
8
+
9
+ ## 1.13.0 (2026-10-28)
4
10
 
5
11
  ### Features
6
12
 
@@ -6,8 +6,12 @@
6
6
  * the media tools (`captureScreen`, `ScreencastRecorder`, `ffmpeg`, `PHONE` and the pacing presets). Needs the
7
7
  * `playwright` package; clips and thumbnails need FFmpeg on the PATH.
8
8
  *
9
+ * Also `captureUiState`, the optional Playwright adapter of the UI catalog (it needs no Playwright import itself).
10
+ *
9
11
  * @see docs/statecharts.md
12
+ * @see docs/ui-catalog.md
10
13
  */
11
14
  export { playwrightDriver, type PlaywrightContext, type PlaywrightDriverOptions } from './driver.js';
12
15
  export { captureScreen, ScreencastRecorder, UserActions, PHONE, SHOWCASE_PACING, FAST_PACING, ffmpeg, type Pacing } from './media.js';
13
16
  export { recognize, checkSignals, screen, locate, describeSignal, testId, text, role, label, css, type Signal, type SignatureSpec } from './signals.js';
17
+ export { captureUiState, type CapturePage, type CaptureUiStateOptions } from './ui-catalog.js';
@@ -6,8 +6,12 @@
6
6
  * the media tools (`captureScreen`, `ScreencastRecorder`, `ffmpeg`, `PHONE` and the pacing presets). Needs the
7
7
  * `playwright` package; clips and thumbnails need FFmpeg on the PATH.
8
8
  *
9
+ * Also `captureUiState`, the optional Playwright adapter of the UI catalog (it needs no Playwright import itself).
10
+ *
9
11
  * @see docs/statecharts.md
12
+ * @see docs/ui-catalog.md
10
13
  */
11
14
  export { playwrightDriver } from './driver.js';
12
15
  export { captureScreen, ScreencastRecorder, UserActions, PHONE, SHOWCASE_PACING, FAST_PACING, ffmpeg } from './media.js';
13
16
  export { recognize, checkSignals, screen, locate, describeSignal, testId, text, role, label, css } from './signals.js';
17
+ export { captureUiState } from './ui-catalog.js';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @module Playwright adapter for the UI catalog
3
+ *
4
+ * The optional Playwright side of the UI catalog: take a page to a state, wait until it is ready, photograph it, and hand the
5
+ * result to `uiStateLayer` (`@crvouga/atlas/reports/ui-catalog-publish`) as a capture. This is the only Playwright-specific code
6
+ * of the feature; reading, composing and exploring the catalog know nothing about it, and any other program that writes the
7
+ * same layer produces the same catalog. The page is typed by the three methods used, so no `playwright` import is needed here
8
+ * and a Playwright Test `page`, a `Page` of the driver, or a stub all fit.
9
+ *
10
+ * @see docs/ui-catalog.md
11
+ */
12
+ import type { UiStateCapture } from '../../reports/src/ui-catalog-publish.js';
13
+ import type { UiStateFacts } from '../../reports/src/ui-state.js';
14
+ /** The part of a Playwright `Page` that capturing uses. */
15
+ export type CapturePage = {
16
+ screenshot(options?: {
17
+ readonly fullPage?: boolean;
18
+ readonly animations?: 'disabled' | 'allow';
19
+ readonly type?: 'png';
20
+ }): Promise<Uint8Array>;
21
+ viewportSize(): {
22
+ readonly width: number;
23
+ readonly height: number;
24
+ } | null;
25
+ };
26
+ /** What to capture and how to know the page is ready. */
27
+ export type CaptureUiStateOptions = UiStateFacts & {
28
+ /** The state's stable id: how a later capture replaces this one. */
29
+ readonly id: string;
30
+ readonly title?: string;
31
+ /** Resolves when the page shows the state (a locator is visible, the network is idle). A rejection or a timeout makes the capture `failed`, with the reason as its detail. */
32
+ readonly ready?: (page: CapturePage) => Promise<void>;
33
+ /** How long to wait for `ready`; default 15 seconds. */
34
+ readonly readyTimeoutMs?: number;
35
+ /** Photograph the whole scrollable page rather than the viewport. */
36
+ readonly fullPage?: boolean;
37
+ /** Extra labels for the capture (`browser`, `os`), which never split it. */
38
+ readonly labels?: Readonly<Record<string, string>>;
39
+ };
40
+ /**
41
+ * Wait for the page to be ready, photograph it, and return the capture to publish. Never throws for the page's sake: a page
42
+ * that never became ready, or could not be photographed, is a `failed` capture that says why, so a broken state shows in the
43
+ * catalog instead of ending the run. A readiness failure still attaches a picture of what the page showed, for diagnosis.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const shot = await captureUiState(page, { id: 'checkout/payment', route: '/checkout', ready: p => expectVisible(p, '#total') });
48
+ * await publishChecks(store, uiStateLayer({ producer: 'playwright', recordedAtMs: Date.now(), environment: 'desktop-web', profile: { viewport: '1280x800' }, states: [shot] }));
49
+ * ```
50
+ */
51
+ export declare function captureUiState(page: CapturePage, options: CaptureUiStateOptions): Promise<UiStateCapture>;
@@ -0,0 +1,44 @@
1
+ async function within(work, ms, what) {
2
+ let timer;
3
+ try {
4
+ return await Promise.race([work, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} did not become true within ${ms} ms`)), ms); })]);
5
+ }
6
+ finally {
7
+ if (timer)
8
+ clearTimeout(timer);
9
+ }
10
+ }
11
+ /**
12
+ * Wait for the page to be ready, photograph it, and return the capture to publish. Never throws for the page's sake: a page
13
+ * that never became ready, or could not be photographed, is a `failed` capture that says why, so a broken state shows in the
14
+ * catalog instead of ending the run. A readiness failure still attaches a picture of what the page showed, for diagnosis.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const shot = await captureUiState(page, { id: 'checkout/payment', route: '/checkout', ready: p => expectVisible(p, '#total') });
19
+ * await publishChecks(store, uiStateLayer({ producer: 'playwright', recordedAtMs: Date.now(), environment: 'desktop-web', profile: { viewport: '1280x800' }, states: [shot] }));
20
+ * ```
21
+ */
22
+ export async function captureUiState(page, options) {
23
+ const { ready, readyTimeoutMs = 15_000, fullPage = false, labels, ...rest } = options;
24
+ const started = Date.now();
25
+ let status = 'passed';
26
+ let detail = '';
27
+ if (ready) {
28
+ try {
29
+ await within(ready(page), readyTimeoutMs, 'The ready check');
30
+ }
31
+ catch (error) {
32
+ status = 'failed';
33
+ detail = error instanceof Error ? error.message : String(error);
34
+ }
35
+ }
36
+ const size = page.viewportSize();
37
+ try {
38
+ const data = await page.screenshot({ fullPage, animations: 'disabled', type: 'png' });
39
+ return { ...rest, status, detail, durationMs: Date.now() - started, ...(labels ? { labels } : {}), screenshot: { data, contentType: 'image/png', ...(size && !fullPage ? { width: size.width, height: size.height } : {}) } };
40
+ }
41
+ catch (error) {
42
+ return { ...rest, status: 'failed', detail: [detail, `The picture could not be taken: ${error instanceof Error ? error.message : String(error)}`].filter(Boolean).join('. '), durationMs: Date.now() - started, ...(labels ? { labels } : {}) };
43
+ }
44
+ }
@@ -11,6 +11,7 @@
11
11
  import type { EvidenceMeasurement, EvidenceStatus } from './explorer-types.js';
12
12
  import type { EvidenceScope, LayerRun } from '../../schema/src/layer.js';
13
13
  import type { LayerParameters } from './layer-types.js';
14
+ import type { UiStateFact } from './ui-state.js';
14
15
  /** Where a recorded test's timeline and captured files are read from. */
15
16
  export type RecordingAddress = {
16
17
  readonly type: 'layer';
@@ -168,6 +169,8 @@ export type LayerRow = {
168
169
  readonly durationMs: number | null;
169
170
  /** What this result carries beyond its outcome. Absent when nothing. */
170
171
  readonly evidence?: EvidenceSummary;
172
+ /** For a check of a `ui-state` or `ui-plan` part: its picture and declared facts, so a catalog is listed without another request (see `ui-state.ts`). Absent for every other kind. */
173
+ readonly state?: UiStateFact;
171
174
  /** What became of this check's expected recording: `uploaded`, `referenced`, `skipped`, `unresolved` or `not-applicable`, with a reason when it is not uploaded. Absent when the check declared no expectation. Independent of `status`. */
172
175
  readonly recording?: {
173
176
  readonly state: string;
@@ -34,7 +34,11 @@ export declare function textFingerprint(text: string): string;
34
34
  export declare function specPath(path: string): string;
35
35
  /** Serialises parameters as JSON key/value pairs sorted by key, so equal parameter sets compare equal as strings. */
36
36
  export declare function parameterKey(parameters: LayerParameters): string;
37
- /** Whether a part `kind` only qualifies an outcome (today only `performance`) and so is not counted as a check when anything else is recorded. */
37
+ /**
38
+ * Whether a part `kind` only qualifies an outcome and so is not counted as a check when anything else is recorded: a
39
+ * `performance` timing, or a `ui-plan` entry (what a UI catalog expects, which a capture of the same state replaces in the
40
+ * tests' view). It never decides a slot: both still fill their own.
41
+ */
38
42
  export declare function isDiagnosticKind(kind: string): boolean;
39
43
  /**
40
44
  * Whether a part `kind` records an opinion rather than an outcome (today only `critique`, an automated reviewer's
@@ -1,6 +1,7 @@
1
1
  import { summarizeEvidenceIn, summarizeLayerEvidence } from './evidence-detail.js';
2
2
  import { effectiveScope } from './evidence-scope.js';
3
3
  import { layerPlace } from './layer-axes.js';
4
+ import { UI_PLAN_KIND, uiStateOf } from './ui-state.js';
4
5
  /**
5
6
  * A short, deterministic fingerprint of a spec's text, shaped `<length>:<hash>:<hash>` (the length in UTF-16 code
6
7
  * units and two 32-bit hashes in hex). A layer records it per spec so a reader can warn when the spec changed
@@ -34,9 +35,13 @@ export function specPath(path) {
34
35
  export function parameterKey(parameters) {
35
36
  return JSON.stringify(Object.entries(parameters).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
36
37
  }
37
- /** Whether a part `kind` only qualifies an outcome (today only `performance`) and so is not counted as a check when anything else is recorded. */
38
+ /**
39
+ * Whether a part `kind` only qualifies an outcome and so is not counted as a check when anything else is recorded: a
40
+ * `performance` timing, or a `ui-plan` entry (what a UI catalog expects, which a capture of the same state replaces in the
41
+ * tests' view). It never decides a slot: both still fill their own.
42
+ */
38
43
  export function isDiagnosticKind(kind) {
39
- return kind === 'performance';
44
+ return kind === 'performance' || kind === UI_PLAN_KIND;
40
45
  }
41
46
  /**
42
47
  * Whether a part `kind` records an opinion rather than an outcome (today only `critique`, an automated reviewer's
@@ -93,6 +98,7 @@ export function layerEvidence(summary, backend) {
93
98
  const test = tests.get(check.id);
94
99
  const evidence = summarizeEvidenceIn(effectiveScope(part.scope, summary.scope), check);
95
100
  const item = expectations?.get(check.id);
101
+ const state = uiStateOf(part.kind, check);
96
102
  rows.push({
97
103
  kind: part.kind,
98
104
  id: check.id,
@@ -105,6 +111,7 @@ export function layerEvidence(summary, backend) {
105
111
  variant: test ? { system: test.system, runtime: test.runtime, seed: test.seed } : {},
106
112
  durationMs: test?.durationMs ?? check.durationMs ?? null,
107
113
  ...(evidence ? { evidence } : {}),
114
+ ...(state ? { state } : {}),
108
115
  ...(item ? { recording: { state: item.state, ...(item.reason ? { reason: item.reason } : {}) } } : {}),
109
116
  });
110
117
  }
@@ -142,13 +142,15 @@ export class ReportIndex {
142
142
  }
143
143
  async #refresh() {
144
144
  const before = this.#rev;
145
- const fresh = [];
146
145
  // The whole catalog on the first pass and now and then; otherwise only its newest end, where new layers sort.
147
146
  const full = this.#passes++ % this.#fullListEvery === 0;
147
+ // Each backend is listed and read on its own, so a slow or unreachable one never holds back the layers of the others: the
148
+ // report fills in as each answers. Oldest first or newest first gives the same report; newest first shows recent work soonest.
148
149
  await Promise.all(this.#backends.map(async (backend) => {
150
+ const fresh = [];
149
151
  try {
150
152
  const consider = (key) => { if (!this.#folded.has(`${backend.id}|${key}`) && !this.#recentlyFailed(`${backend.id}|${key}`))
151
- fresh.push({ backend, key }); };
153
+ fresh.push(key); };
152
154
  if (!full && isPaged(backend.store)) {
153
155
  // Newest first: pages of keys we already know mean nothing newer is behind them.
154
156
  for (let after;;) {
@@ -175,10 +177,12 @@ export class ReportIndex {
175
177
  this.#available.set(backend.id, false);
176
178
  this.#failed.set(`${backend.id}|`, { at: this.#now(), message: `${backend.label ?? backend.id} is unavailable: ${error instanceof Error ? error.message : String(error)}` });
177
179
  }
180
+ fresh.sort();
181
+ await eachLimited(fresh, this.#concurrency, key => this.#load(backend, key));
182
+ // This backend has answered (or been named unavailable): the report is a report, if a partial one, and viewers are told now, not when the slowest backend has answered.
183
+ this.#refreshedAt ||= this.#now();
184
+ this.#emit();
178
185
  }));
179
- // Oldest first or newest first gives the same report; newest first shows recent work soonest.
180
- fresh.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.backend.id < b.backend.id ? -1 : 1));
181
- await eachLimited(fresh, this.#concurrency, ({ backend, key }) => this.#load(backend, key));
182
186
  this.#refreshedAt = this.#now();
183
187
  this.#emit();
184
188
  return this.#rev !== before;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @module Writing a UI catalog from any collector
3
+ *
4
+ * Two small builders that turn what a collector captured, or what a planner expects, into the input of `publishChecks`
5
+ * (and of `atlas report publish --checks`), following the convention in {@link ./ui-state}. They are plain data in and plain
6
+ * data out: no browser, no test framework, no file system. A Playwright test, a Cypress run, a mobile device lab, a Storybook
7
+ * crawler or a shell script that writes the same JSON all produce the same catalog.
8
+ *
9
+ * Shards need no coordination: each shard calls {@link uiStateLayer} for the states it captured (and optionally
10
+ * {@link uiPlanLayer} for the states it was given), with its own `shard` label, and publishes. A planner that knows the whole
11
+ * product publishes the plan once.
12
+ *
13
+ * @see docs/ui-catalog.md
14
+ */
15
+ import type { PublishChecksInput } from '../../schema/src/layer.js';
16
+ import { type UiStateFacts } from './ui-state.js';
17
+ /** A picture to store with a capture: its bytes (`data`, `base64`), or where it already is (`uri`). */
18
+ export type UiPicture = {
19
+ /** The file name; default `<id>.png`. */
20
+ readonly name?: string;
21
+ readonly data?: Uint8Array;
22
+ readonly base64?: string;
23
+ readonly uri?: string;
24
+ /** Default `image/png`. */
25
+ readonly contentType?: string;
26
+ readonly width?: number;
27
+ readonly height?: number;
28
+ };
29
+ /** One captured state. `id` must be stable across runs: it is how a new capture replaces an old one. */
30
+ export type UiStateCapture = UiStateFacts & {
31
+ readonly id: string;
32
+ /** For a person; default the id. */
33
+ readonly title?: string;
34
+ /** `passed` (default): captured. `failed`: the capture was attempted and did not work (say why in `detail`). `running`: being taken. `not-run`: skipped. */
35
+ readonly status?: 'passed' | 'failed' | 'running' | 'not-run';
36
+ /** Why it failed or was skipped: a readiness check that never became true, a console error. */
37
+ readonly detail?: string;
38
+ readonly screenshot?: UiPicture;
39
+ readonly durationMs?: number;
40
+ /** More facts about the capture that never split it (`browser`, `os`). */
41
+ readonly labels?: Readonly<Record<string, string>>;
42
+ readonly tags?: readonly string[];
43
+ };
44
+ /** One expected state, or (with `inventory`) one surface of the product's inventory. */
45
+ export type UiStatePlan = UiStateFacts & {
46
+ readonly id: string;
47
+ readonly title?: string;
48
+ };
49
+ /** What every layer of a catalog says about where and when it was recorded. */
50
+ export type UiLayerOrigin = {
51
+ /** The suite or tool that makes the layer. Part of the slot; a planner and a collector normally differ. */
52
+ readonly producer: string;
53
+ /** When the captures were taken (or the plan was made), epoch milliseconds. Take it from the run, not from the moment of publishing. */
54
+ readonly recordedAtMs: number;
55
+ /** The client: `desktop-web`, `mobile-web`, `ios`, `android`… Part of the slot. */
56
+ readonly environment?: string;
57
+ /** The rest of the profile, such as `{ viewport: '390x844', scheme: 'dark' }`. Each combination is its own result. Part of the slot. */
58
+ readonly profile?: Readonly<Record<string, string>>;
59
+ readonly sha?: string | null;
60
+ readonly branch?: string | null;
61
+ readonly runUrl?: string | null;
62
+ /** Which shard this is (`2/4`, `checkout`, a machine name). Shown with each capture; never part of the slot. */
63
+ readonly shard?: string;
64
+ /** The execution these layers belong to, so shards of one run stay one run. */
65
+ readonly run?: {
66
+ readonly id: string;
67
+ readonly title?: string;
68
+ };
69
+ };
70
+ /**
71
+ * The input of `publishChecks` for the states one collector (or one shard) captured under one profile. Throws when a state id
72
+ * repeats, because the second would silently replace the first. A state without a `status` is `passed`: it was captured.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * await publishChecks(store, uiStateLayer({
77
+ * producer: 'web-capture', recordedAtMs: Date.now(), environment: 'mobile-web', profile: { viewport: '390x844', scheme: 'dark' }, shard: '1/4',
78
+ * states: [{ id: 'checkout/payment', title: 'Payment', route: '/checkout', covers: ['payment-form'], screenshot: { data: png } }]
79
+ * }));
80
+ * ```
81
+ */
82
+ export declare function uiStateLayer(input: UiLayerOrigin & {
83
+ readonly states: readonly UiStateCapture[];
84
+ }): PublishChecksInput;
85
+ /**
86
+ * The input of `publishChecks` for what is expected: states that should be captured under this profile and, with `inventory`,
87
+ * surfaces the product is known to have. Every entry is `not-run`, so a plan never counts as a pass, and it fills a slot of
88
+ * its own (kind `ui-plan`), so a capture never erases it and it never hides a capture. Throws when an id repeats.
89
+ */
90
+ export declare function uiPlanLayer(input: UiLayerOrigin & {
91
+ readonly states: readonly UiStatePlan[];
92
+ }): PublishChecksInput;
@@ -0,0 +1,68 @@
1
+ import { UI_PLAN_KIND, UI_STATE_EXTENSION, UI_STATE_KIND } from './ui-state.js';
2
+ function common(origin, kind, title) {
3
+ return {
4
+ producer: origin.producer, kind, title, recordedAtMs: origin.recordedAtMs,
5
+ ...(origin.environment ? { environment: origin.environment } : {}),
6
+ parameters: { ...origin.profile },
7
+ ...(origin.sha !== undefined ? { sha: origin.sha } : {}), ...(origin.branch !== undefined ? { branch: origin.branch } : {}), ...(origin.runUrl !== undefined ? { runUrl: origin.runUrl } : {}),
8
+ ...(origin.run ? { run: origin.run } : {})
9
+ };
10
+ }
11
+ function facts(source) {
12
+ const { route, fixture, cohort, area, flags, covers, inventory } = source;
13
+ return { ...(route ? { route } : {}), ...(fixture ? { fixture } : {}), ...(cohort ? { cohort } : {}), ...(area ? { area } : {}), ...(flags?.length ? { flags } : {}), ...(covers?.length ? { covers } : {}), ...(inventory ? { inventory } : {}) };
14
+ }
15
+ /**
16
+ * The input of `publishChecks` for the states one collector (or one shard) captured under one profile. Throws when a state id
17
+ * repeats, because the second would silently replace the first. A state without a `status` is `passed`: it was captured.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * await publishChecks(store, uiStateLayer({
22
+ * producer: 'web-capture', recordedAtMs: Date.now(), environment: 'mobile-web', profile: { viewport: '390x844', scheme: 'dark' }, shard: '1/4',
23
+ * states: [{ id: 'checkout/payment', title: 'Payment', route: '/checkout', covers: ['payment-form'], screenshot: { data: png } }]
24
+ * }));
25
+ * ```
26
+ */
27
+ export function uiStateLayer(input) {
28
+ const seen = new Set();
29
+ const checks = input.states.map(state => {
30
+ if (seen.has(state.id))
31
+ throw new Error(`uiStateLayer: the state "${state.id}" appears twice; give each state of one profile its own id`);
32
+ seen.add(state.id);
33
+ const shot = state.screenshot;
34
+ const attachments = shot ? [{
35
+ name: shot.name ?? `${state.id.replace(/[^\w.-]+/g, '-')}.png`, role: 'screenshot', contentType: shot.contentType ?? 'image/png',
36
+ ...(shot.data ? { data: shot.data } : {}), ...(shot.base64 ? { base64: shot.base64 } : {}), ...(shot.uri ? { uri: shot.uri } : {}),
37
+ ...(shot.width !== undefined ? { width: shot.width } : {}), ...(shot.height !== undefined ? { height: shot.height } : {})
38
+ }] : undefined;
39
+ const labels = { ...state.labels, ...(input.shard ? { shard: input.shard } : {}) };
40
+ return {
41
+ id: state.id, title: state.title ?? state.id, status: state.status ?? 'passed', detail: state.detail ?? '',
42
+ ...(state.durationMs !== undefined ? { durationMs: state.durationMs } : {}),
43
+ ...(attachments ? { attachments } : {}),
44
+ ...(Object.keys(labels).length ? { labels } : {}), ...(state.tags?.length ? { tags: [...state.tags] } : {}),
45
+ extensions: { [UI_STATE_EXTENSION]: facts(state) }
46
+ };
47
+ });
48
+ return { ...common(input, UI_STATE_KIND, 'UI states'), checks };
49
+ }
50
+ /**
51
+ * The input of `publishChecks` for what is expected: states that should be captured under this profile and, with `inventory`,
52
+ * surfaces the product is known to have. Every entry is `not-run`, so a plan never counts as a pass, and it fills a slot of
53
+ * its own (kind `ui-plan`), so a capture never erases it and it never hides a capture. Throws when an id repeats.
54
+ */
55
+ export function uiPlanLayer(input) {
56
+ const seen = new Set();
57
+ const checks = input.states.map(state => {
58
+ if (seen.has(state.id))
59
+ throw new Error(`uiPlanLayer: "${state.id}" appears twice`);
60
+ seen.add(state.id);
61
+ return {
62
+ id: state.id, title: state.title ?? state.id, status: 'not-run', detail: state.inventory ? 'In the product inventory' : 'Expected, not captured yet',
63
+ ...(input.shard ? { labels: { shard: input.shard } } : {}),
64
+ extensions: { [UI_STATE_EXTENSION]: facts(state) }
65
+ };
66
+ });
67
+ return { ...common(input, UI_PLAN_KIND, 'UI states expected'), checks };
68
+ }
@@ -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;