@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
package/CHANGELOG.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog: @crvouga/atlas
2
2
 
3
- ## 1.13.0 (2026-10-29)
3
+ ## 1.15.0 (2026-10-29)
4
+
5
+ ### Features
6
+
7
+ - count a video whose bundle file was never stored as unavailable ([6e342e1](https://github.com/crvouga/atlas/commit/6e342e1c0994b78499e373496afa5035a387a43c))
8
+ - compare behavior promises, recordings and outcomes between two pinned runs ([9bc0056](https://github.com/crvouga/atlas/commit/9bc0056de84899801377c0cc1c3bb70c80e3820a))
9
+
10
+ ## 1.14.0 (2026-10-28)
11
+
12
+ ### Features
13
+
14
+ - add a UI catalog of captured states that streams, shards and is independent of the collector ([81b2938](https://github.com/crvouga/atlas/commit/81b29388b4cb77ca8c3763aac969d23436cd18dd))
15
+
16
+ ## 1.13.0 (2026-10-28)
4
17
 
5
18
  ### Features
6
19
 
@@ -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
+ }
@@ -46,11 +46,19 @@ export declare function summarizeEvidence(...holders: readonly EvidenceHolder[])
46
46
  * `effectiveScope`). Without `inherited` it is the same as `summarizeEvidence`.
47
47
  */
48
48
  export declare function summarizeEvidenceIn(inherited: EvidenceScope | undefined, ...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
49
+ /**
50
+ * Like {@link summarizeEvidenceIn}, knowing which bundle keys the layer names but does not hold (`unstored`, see
51
+ * `unstoredKeys`): a video whose only bytes are such a key is `available: false`, never a recording. Without
52
+ * `unstored` it is the same as `summarizeEvidenceIn`.
53
+ */
54
+ export declare function summarizeStoredEvidence(inherited: EvidenceScope | undefined, unstored: ReadonlySet<string> | undefined, ...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
49
55
  /**
50
56
  * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
51
57
  * part is owned by the `layer`, not by a check.
52
58
  */
53
59
  export declare function summarizeLayerEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
60
+ /** Like {@link summarizeLayerEvidence}, knowing which bundle keys the layer names but does not hold (see {@link summarizeStoredEvidence}). */
61
+ export declare function summarizeStoredLayerEvidence(unstored: ReadonlySet<string> | undefined, ...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
54
62
  /**
55
63
  * Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
56
64
  * name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
@@ -113,3 +121,14 @@ export declare function partsEvidence(layer: {
113
121
  }): PartEvidence[];
114
122
  /** Every distinct bundle key (`bundleKey`) that the attachments of a holder, its steps and its attempts point to, so a layer's files can be copied. Attachments held by `uri` or inline `text` have none. */
115
123
  export declare function bundleKeysOf(holder: EvidenceHolder): string[];
124
+ /**
125
+ * The bundle keys that attachments of a layer, its parts and their checks name, but that the layer's `bundle` index does
126
+ * not hold: files the producer referenced and never stored. Empty for a layer whose every file is there. This reads only
127
+ * the index, never a blob, so it is cheap enough to ask of every layer as it is read.
128
+ */
129
+ export declare function unstoredKeys(layer: EvidenceHolder & {
130
+ readonly parts: readonly (EvidenceHolder & {
131
+ readonly checks: readonly EvidenceHolder[];
132
+ })[];
133
+ readonly bundle: Readonly<Record<string, unknown>>;
134
+ }): string[];
@@ -26,6 +26,14 @@ export function summarizeEvidence(...holders) {
26
26
  export function summarizeEvidenceIn(inherited, ...holders) {
27
27
  return summarize(holders, 'check', inherited);
28
28
  }
29
+ /**
30
+ * Like {@link summarizeEvidenceIn}, knowing which bundle keys the layer names but does not hold (`unstored`, see
31
+ * `unstoredKeys`): a video whose only bytes are such a key is `available: false`, never a recording. Without
32
+ * `unstored` it is the same as `summarizeEvidenceIn`.
33
+ */
34
+ export function summarizeStoredEvidence(inherited, unstored, ...holders) {
35
+ return summarize(holders, 'check', inherited, false, unstored);
36
+ }
29
37
  /**
30
38
  * Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
31
39
  * part is owned by the `layer`, not by a check.
@@ -34,7 +42,11 @@ export function summarizeLayerEvidence(...holders) {
34
42
  // The first holder is the layer: its scope reaches the parts after it, which may declare their own.
35
43
  return summarize(holders, 'layer', undefined, true);
36
44
  }
37
- function summarize(holders, owner, inherited, firstIsParent = false) {
45
+ /** Like {@link summarizeLayerEvidence}, knowing which bundle keys the layer names but does not hold (see {@link summarizeStoredEvidence}). */
46
+ export function summarizeStoredLayerEvidence(unstored, ...holders) {
47
+ return summarize(holders, 'layer', undefined, true, unstored);
48
+ }
49
+ function summarize(holders, owner, inherited, firstIsParent = false, unstored) {
38
50
  if (!holders.some(holder => count(holder) > 0))
39
51
  return undefined;
40
52
  const roles = new Set();
@@ -50,7 +62,7 @@ function summarize(holders, owner, inherited, firstIsParent = false) {
50
62
  for (const tag of normalizeTags(item.tags))
51
63
  artifactTags.add(tag);
52
64
  roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
53
- const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes));
65
+ const video = videoOf(item, by, where, effectiveScope(item.scope, ...scopes), unstored);
54
66
  if (video)
55
67
  videos.push(video);
56
68
  }
@@ -98,10 +110,12 @@ export function isVideoAttachment(attachment) {
98
110
  export function attachmentIdentity(item) {
99
111
  return item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
100
112
  }
101
- function videoOf(item, owner, where, scope) {
113
+ function videoOf(item, owner, where, scope, unstored) {
102
114
  if (!isVideoAttachment(item))
103
115
  return undefined;
104
- const recording = attachmentIdentity(item);
116
+ // A bundle key the layer's bundle does not hold names bytes that are not there: only an address or inline text can still carry them, and the digest of lost bytes identifies nothing playable.
117
+ const lost = item.bundleKey !== undefined && unstored?.has(item.bundleKey) === true;
118
+ const recording = lost ? attachmentIdentity({ name: item.name, uri: item.uri, text: item.text }) : attachmentIdentity(item);
105
119
  const tags = normalizeTags(item.tags);
106
120
  return { name: item.name, owner, ...where, ...(tags.length ? { tags } : {}), ...(recording === undefined ? {} : { recording }), available: recording !== undefined, ...(scope ? { scope } : {}) };
107
121
  }
@@ -181,3 +195,16 @@ export function bundleKeysOf(holder) {
181
195
  }
182
196
  return [...keys];
183
197
  }
198
+ /**
199
+ * The bundle keys that attachments of a layer, its parts and their checks name, but that the layer's `bundle` index does
200
+ * not hold: files the producer referenced and never stored. Empty for a layer whose every file is there. This reads only
201
+ * the index, never a blob, so it is cheap enough to ask of every layer as it is read.
202
+ */
203
+ export function unstoredKeys(layer) {
204
+ const missing = new Set();
205
+ for (const holder of [layer, ...layer.parts, ...layer.parts.flatMap(part => part.checks)])
206
+ for (const key of bundleKeysOf(holder))
207
+ if (!Object.hasOwn(layer.bundle, key))
208
+ missing.add(key);
209
+ return [...missing];
210
+ }
@@ -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';
@@ -55,7 +56,7 @@ export type VideoRef = {
55
56
  readonly tags?: readonly string[];
56
57
  /** What identifies the bytes (digest, bundle key or address); references with the same one are the same recording. Absent when `available` is false. */
57
58
  readonly recording?: string;
58
- /** False when the reference names no bytes at all (no bundle key, address or inline text), so it can never be played. */
59
+ /** False when the reference names no bytes at all (no bundle key, address or inline text), or a bundle key that the layer's own bundle does not hold, so it can never be played. */
59
60
  readonly available: boolean;
60
61
  /** What this recording shows, as declared for it: its own `scope`, else its attempt's, check's, part's or layer's. Absent when none declared one: its scope is unknown, not live, physical or audible. */
61
62
  readonly scope?: EvidenceScope;
@@ -66,7 +67,7 @@ export type VideoCounts = {
66
67
  readonly references: number;
67
68
  /** Distinct playable recordings: identical bytes referenced twice are one. */
68
69
  readonly recordings: number;
69
- /** References that name no bytes. A report that predates availability metadata has none of these and none of the others. */
70
+ /** References that name no bytes, or bytes the layer does not hold. A report that predates availability metadata has none of these and none of the others. */
70
71
  readonly unavailable: number;
71
72
  };
72
73
  /**
@@ -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
- import { summarizeEvidenceIn, summarizeLayerEvidence } from './evidence-detail.js';
1
+ import { summarizeStoredEvidence, summarizeStoredLayerEvidence } 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
@@ -85,14 +90,16 @@ export function layerEvidence(summary, backend) {
85
90
  normalized.set(path, found = specPath(path));
86
91
  return found;
87
92
  };
93
+ const unstored = summary.unstored?.length ? new Set(summary.unstored) : undefined;
88
94
  const rows = [];
89
95
  const receipt = summary.recordings;
90
96
  const expectations = receipt ? new Map(receipt.items.map(item => [item.check, item])) : null;
91
97
  for (const part of summary.parts) {
92
98
  for (const check of part.checks) {
93
99
  const test = tests.get(check.id);
94
- const evidence = summarizeEvidenceIn(effectiveScope(part.scope, summary.scope), check);
100
+ const evidence = summarizeStoredEvidence(effectiveScope(part.scope, summary.scope), unstored, check);
95
101
  const item = expectations?.get(check.id);
102
+ const state = uiStateOf(part.kind, check);
96
103
  rows.push({
97
104
  kind: part.kind,
98
105
  id: check.id,
@@ -105,12 +112,13 @@ export function layerEvidence(summary, backend) {
105
112
  variant: test ? { system: test.system, runtime: test.runtime, seed: test.seed } : {},
106
113
  durationMs: test?.durationMs ?? check.durationMs ?? null,
107
114
  ...(evidence ? { evidence } : {}),
115
+ ...(state ? { state } : {}),
108
116
  ...(item ? { recording: { state: item.state, ...(item.reason ? { reason: item.reason } : {}) } } : {}),
109
117
  });
110
118
  }
111
119
  }
112
120
  const recording = summary.recording;
113
- const layerSummaryEvidence = summarizeLayerEvidence(summary, ...summary.parts);
121
+ const layerSummaryEvidence = summarizeStoredLayerEvidence(unstored, summary, ...summary.parts);
114
122
  const layer = {
115
123
  ...(summary.freshness ? { freshness: summary.freshness } : {}),
116
124
  ...(summary.run ? { run: summary.run } : {}),
@@ -48,5 +48,5 @@ export declare function publishLayerBundle(store: ObjectStore, summary: Omit<Lay
48
48
  export declare function readLayer(store: ObjectStore, id: string): Promise<ReportLayer | null>;
49
49
  /** Reads one captured file (a screenshot, a frame, a recorded report) of a layer, or `null` when the layer or the file is missing. Throws like `readLayer`. */
50
50
  export declare function readLayerFile(store: ObjectStore, id: string, file: string): Promise<BundleEntry | null>;
51
- /** Drops a layer's bundle index, leaving the summary composition reads. */
51
+ /** Drops a layer's bundle index, leaving the summary composition reads, plus the bundle keys its attachments name that the bundle does not hold (`unstored`), so a video that was never stored is not counted as a recording. */
52
52
  export declare function layerSummary(layer: ReportLayer): LayerSummary;
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { createBundleReader, writeBundle } from './bundle.js';
12
12
  import { readUtf8, writeUtf8 } from './object-io.js';
13
+ import { unstoredKeys } from './evidence-detail.js';
13
14
  import { LAYER_SCHEMA_VERSION, parseLayerDocument } from '../../schema/src/layer.js';
14
15
  /** The key prefix under which every layer's catalog entry is written. */
15
16
  export const LAYER_INDEX = 'layers/index/';
@@ -87,8 +88,9 @@ export async function readLayerFile(store, id, file) {
87
88
  const layer = await readLayer(store, id);
88
89
  return layer ? createBundleReader(store, layer.bundle)(file) : null;
89
90
  }
90
- /** Drops a layer's bundle index, leaving the summary composition reads. */
91
+ /** Drops a layer's bundle index, leaving the summary composition reads, plus the bundle keys its attachments name that the bundle does not hold (`unstored`), so a video that was never stored is not counted as a recording. */
91
92
  export function layerSummary(layer) {
92
93
  const { bundle: _, ...summary } = layer;
93
- return summary;
94
+ const unstored = unstoredKeys(layer);
95
+ return unstored.length ? { ...summary, unstored } : summary;
94
96
  }
@@ -52,6 +52,8 @@ export type LayerSummary = LayerExtras & {
52
52
  readonly sha: string;
53
53
  readonly source: string;
54
54
  } | null;
55
+ /** Bundle keys that attachments name but the layer's bundle does not hold, read from the layer's index when its summary was made (see `unstoredKeys`). Absent when there are none. In memory only: never part of the layer document. */
56
+ readonly unstored?: readonly string[];
55
57
  };
56
58
  /** A layer with the index that locates its captured files (screenshots, frames) in the store. */
57
59
  export type ReportLayer = LayerSummary & {
@@ -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,182 @@
1
+ import { type LatencyChange, type LatencyEntry, type LatencySummary } from './latency.js';
2
+ import type { ReliabilityDocument, ReliabilityRate, ReliabilityScope } from './reliability.js';
3
+ import type { RunDetail, RunResult, RunSummary } from './test-catalog.js';
4
+ /** Where a pair of results (or one result with no partner) stands, in the order a review reads them. */
5
+ export declare const COMPARE_GROUPS: readonly ["regressed", "recovered", "still-failing", "not-comparable", "unknown", "removed", "added", "unchanged"];
6
+ /** One of {@link COMPARE_GROUPS}. */
7
+ export type CompareGroup = (typeof COMPARE_GROUPS)[number];
8
+ /** What each group is called and what it does and does not say. */
9
+ export declare const COMPARE_GROUP_TEXT: Readonly<Record<CompareGroup, {
10
+ readonly label: string;
11
+ readonly meaning: string;
12
+ }>>;
13
+ /**
14
+ * Whether the promise (what the behavior says should happen) is the same on both sides: `same` (same wording and the
15
+ * same recorded spec revision), `changed` (the wording differs), `revision-differs` (same wording, another spec-file
16
+ * revision), or `unknown` (a revision was not recorded on a side, so nothing can be said).
17
+ */
18
+ export type PromiseState = 'same' | 'changed' | 'revision-differs' | 'unknown';
19
+ /** What is known about whether the promise changed, and why. */
20
+ export type PromiseReading = {
21
+ readonly state: PromiseState;
22
+ readonly baselineTitle: string | null;
23
+ readonly candidateTitle: string | null;
24
+ readonly baselineRevision: string | null;
25
+ readonly candidateRevision: string | null;
26
+ /** In words, for a person. */
27
+ readonly reasons: readonly string[];
28
+ };
29
+ /** The condition a result was recorded under: with the check, what makes it its own result. */
30
+ export type Condition = {
31
+ readonly kind: string | null;
32
+ readonly producer: string;
33
+ readonly environment: string | null;
34
+ readonly parameters: Readonly<Record<string, string>>;
35
+ };
36
+ /** The condition of a result. A server that does not send `kind` leaves it null: unknown, equal only to another unknown. */
37
+ export declare function conditionOf(result: Pick<RunResult, 'kind' | 'producer' | 'environment' | 'parameters'>): Condition;
38
+ /** A stable text for a condition, equal exactly when two conditions are. */
39
+ export declare function conditionId(condition: Condition): string;
40
+ /** How two conditions differ, in words; empty when they are the same. */
41
+ export declare function conditionDifferences(baseline: Condition, candidate: Condition): string[];
42
+ /** One behavior's results on the two sides. */
43
+ export type ComparedResult = {
44
+ /** Identifies the pair within one comparison: the check, and the condition of each side. Stable for the same two runs. */
45
+ readonly id: string;
46
+ /** The check's identity (`TestSummary.key`). */
47
+ readonly key: string;
48
+ /** The check's title: the candidate's when it has one, else the baseline's. */
49
+ readonly title: string;
50
+ readonly specPath: string | null;
51
+ readonly baseline: RunResult | null;
52
+ readonly candidate: RunResult | null;
53
+ readonly group: CompareGroup;
54
+ /** One sentence saying what happened, and what is not known. */
55
+ readonly summary: string;
56
+ readonly promise: PromiseReading;
57
+ /** How the two conditions differ, in words, when both sides have a result and they were recorded under different conditions. */
58
+ readonly conditions: readonly string[];
59
+ /** What a reader must not assume about this pair, in words. */
60
+ readonly caveats: readonly string[];
61
+ };
62
+ /** The whole comparison of two pinned runs. */
63
+ export type RunComparison = {
64
+ readonly baseline: RunSummary;
65
+ readonly candidate: RunSummary;
66
+ /** Every pair and every unpaired result, in the order of {@link COMPARE_GROUPS}, then spec and title. */
67
+ readonly results: readonly ComparedResult[];
68
+ readonly counts: Readonly<Record<CompareGroup, number>>;
69
+ /** What a reader must not assume about the two runs as a whole: the same run on both sides, different branches, a candidate older than the baseline. */
70
+ readonly notes: readonly string[];
71
+ };
72
+ /** Short form of a spec fingerprint, to show. */
73
+ export declare function shortRevision(revision: string | null | undefined): string;
74
+ /** The wording a run recorded for a check: what that run's layer called it, else the check's newest title. */
75
+ export declare function recordedTitle(result: Pick<RunResult, 'title' | 'recordedTitle'>): string;
76
+ /**
77
+ * Pairs the results of two runs and reads what changed. Results are paired by check identity and then by condition (see
78
+ * the module comment); titles never pair two results. Each side's results are those the run recorded last for each
79
+ * check and condition (`RunDetail.results`).
80
+ *
81
+ * @param baseline - The run to compare against.
82
+ * @param candidate - The run under review. Both are used as given: a newer run never replaces either.
83
+ * @returns Every result of either run, grouped by what changed, with the reasons.
84
+ * @example
85
+ * ```ts
86
+ * const comparison = compareRuns(await runDetail('run:ci-100'), await runDetail('run:ci-101'));
87
+ * comparison.results.filter(item => item.group === 'regressed');
88
+ * ```
89
+ */
90
+ export declare function compareRuns(baseline: RunDetail, candidate: RunDetail): RunComparison;
91
+ /** What one product-latency metric shows on the two sides. */
92
+ export type MetricComparison = {
93
+ readonly state: 'compared';
94
+ readonly name: string;
95
+ readonly baseline: LatencySummary;
96
+ readonly candidate: LatencySummary;
97
+ readonly change: LatencyChange;
98
+ readonly reasons: readonly string[];
99
+ } | {
100
+ readonly state: 'incompatible';
101
+ readonly name: string;
102
+ readonly baseline: LatencySummary;
103
+ readonly candidate: LatencySummary;
104
+ readonly change: LatencyChange | null;
105
+ readonly reasons: readonly string[];
106
+ } | {
107
+ readonly state: 'baseline-only' | 'candidate-only';
108
+ readonly name: string;
109
+ readonly summary: LatencySummary;
110
+ readonly reasons: readonly string[];
111
+ };
112
+ /**
113
+ * Compares the product latency recorded in two sets of results of one check, one per side. A metric is compared only
114
+ * when it is the same measurement (name, unit, boundaries) and both sides were measured on the same devices, in the
115
+ * same environments, with the same instrumentation and other conditions; otherwise it is `incompatible` and no change
116
+ * is computed, with the reasons. A metric only one side recorded is named as such. A side without any latency is unknown,
117
+ * not fast: it contributes no metric. `conditionsDiffer` names differences of the results' own conditions, which make
118
+ * every metric incompatible.
119
+ */
120
+ export declare function compareProductLatency(baseline: readonly LatencyEntry[], candidate: readonly LatencyEntry[], conditionsDiffer?: readonly string[]): MetricComparison[];
121
+ /** How reliable a check was on each side, and whether the two figures can be set against each other. */
122
+ export type ReliabilityComparison = {
123
+ /** True only when both figures are established, were counted in the same scope and against the same recorded spec revision. */
124
+ readonly comparable: boolean;
125
+ readonly baseline: ReliabilityRate;
126
+ readonly candidate: ReliabilityRate;
127
+ /** Candidate minus baseline, in percentage points, only when comparable. */
128
+ readonly points?: number;
129
+ /** `higher` or `lower` only when the two intervals do not overlap; `indistinguishable` when they do. Absent when not comparable. */
130
+ readonly direction?: 'higher' | 'lower' | 'indistinguishable';
131
+ /** Why no change is stated, or what qualifies one. */
132
+ readonly reasons: readonly string[];
133
+ };
134
+ /**
135
+ * The scope that counts the independent executions of one result's check up to the end of its run: the same
136
+ * environment, producer and parameters, and the spec revision that result was recorded against (executions against
137
+ * another revision are other promises). A result that recorded no revision is counted across revisions, which
138
+ * {@link compareReliability} will not compare. Use it for each side, with that side's result and run.
139
+ */
140
+ export declare function reliabilityScopeAt(result: Pick<RunResult, 'environment' | 'producer' | 'parameters' | 'specRevision'>, run: Pick<RunSummary, 'endedAtMs'>): ReliabilityScope;
141
+ /**
142
+ * Sets the reliability of a check up to the baseline run against its reliability up to the candidate run. A change is
143
+ * stated only when both samples are established (at least `minimumExecutions` independent executions), both counted the
144
+ * same recorded spec revision (see {@link reliabilityScopeAt}) and the conditions match; whether the difference exceeds the uncertainty is
145
+ * said by `direction`. Otherwise each figure stands alone and the reasons say why.
146
+ */
147
+ export declare function compareReliability(baseline: ReliabilityDocument, candidate: ReliabilityDocument, conditionsDiffer?: readonly string[]): ReliabilityComparison;
148
+ /** The part of a recorded step this module reads. */
149
+ export type StepLike = {
150
+ readonly name: string;
151
+ readonly offsetMs?: number | undefined;
152
+ };
153
+ /** One step, found on one or both sides. */
154
+ export type AlignedStep = {
155
+ readonly name: string;
156
+ /** Milliseconds after the check began, on each side; absent when that side did not record it or its time. */
157
+ readonly baselineOffsetMs?: number;
158
+ readonly candidateOffsetMs?: number;
159
+ readonly onBaseline: boolean;
160
+ readonly onCandidate: boolean;
161
+ };
162
+ /** How the recorded steps of two results line up. */
163
+ export type StepAlignment = {
164
+ /** `aligned`: some step is on both sides with a time on both (a recording can be sought to it); `unaligned`: otherwise. */
165
+ readonly state: 'aligned' | 'unaligned';
166
+ readonly steps: readonly AlignedStep[];
167
+ /** The steps with a time on both sides: where both recordings can be sought together. */
168
+ readonly seekable: readonly (AlignedStep & {
169
+ readonly baselineOffsetMs: number;
170
+ readonly candidateOffsetMs: number;
171
+ })[];
172
+ /** Whether the two lists of step names differ (the steps themselves are part of the promise). */
173
+ readonly differs: boolean;
174
+ /** In words, why the recordings are or are not aligned. */
175
+ readonly explanation: string;
176
+ };
177
+ /**
178
+ * Lines up the recorded steps of two results by name (the n-th step of a name on one side with the n-th of that name on
179
+ * the other), keeping each side's own time origin: an offset is milliseconds after that side's check began. Nothing is
180
+ * aligned by position or by fuzzy wording. When nothing lines up, `explanation` says why.
181
+ */
182
+ export declare function alignSteps(baseline: readonly StepLike[], candidate: readonly StepLike[]): StepAlignment;