@crvouga/atlas 1.20.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -1
- package/dist/core/src/cli.d.ts +2 -2
- package/dist/core/src/cli.js +7 -1
- package/dist/critics/src/catalog-client.d.ts +91 -0
- package/dist/critics/src/catalog-client.js +194 -0
- package/dist/critics/src/cli-calibrate.d.ts +4 -0
- package/dist/critics/src/cli-calibrate.js +211 -0
- package/dist/critics/src/cli-common.d.ts +108 -0
- package/dist/critics/src/cli-common.js +155 -0
- package/dist/critics/src/cli-review.d.ts +4 -0
- package/dist/critics/src/cli-review.js +174 -0
- package/dist/critics/src/cli-watch.d.ts +4 -0
- package/dist/critics/src/cli-watch.js +191 -0
- package/dist/critics/src/cli.d.ts +13 -0
- package/dist/critics/src/cli.js +490 -0
- package/dist/critics/src/index.d.ts +3 -0
- package/dist/critics/src/index.js +3 -0
- package/dist/critics/src/judging.d.ts +8 -0
- package/dist/critics/src/llm.d.ts +120 -0
- package/dist/critics/src/llm.js +266 -0
- package/dist/critics/src/marks.d.ts +74 -0
- package/dist/critics/src/marks.js +76 -0
- package/dist/critics/src/registry.d.ts +125 -0
- package/dist/critics/src/registry.js +203 -0
- package/dist/critics/src/spool.d.ts +32 -0
- package/dist/critics/src/spool.js +129 -0
- package/dist/critics/src/uiclip.d.ts +82 -0
- package/dist/critics/src/uiclip.js +167 -0
- package/dist/reports/src/compose.d.ts +23 -1
- package/dist/reports/src/compose.js +29 -19
- package/dist/reports/src/critique-review.d.ts +111 -0
- package/dist/reports/src/critique-review.js +123 -0
- package/dist/reports/src/critique-row.d.ts +24 -0
- package/dist/reports/src/critique-row.js +39 -0
- package/dist/reports/src/evidence-types.d.ts +5 -0
- package/dist/reports/src/layer-merge.js +5 -0
- package/dist/reports/src/report-cli.d.ts +13 -0
- package/dist/reports/src/report-cli.js +6 -4
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +12 -0
- package/dist/reports/src/ui-catalog-cli.d.ts +10 -0
- package/dist/reports/src/ui-catalog-cli.js +391 -0
- package/dist/reports/src/ui-catalog-diff.d.ts +104 -0
- package/dist/reports/src/ui-catalog-diff.js +130 -0
- package/dist/reports/src/ui-catalog-entries.d.ts +147 -0
- package/dist/reports/src/ui-catalog-entries.js +190 -0
- package/dist/reports/src/ui-catalog-export.d.ts +82 -0
- package/dist/reports/src/ui-catalog-export.js +221 -0
- package/dist/reports/src/ui-catalog-publish.js +2 -2
- package/dist/reports/src/ui-catalog.d.ts +2 -0
- package/dist/reports/src/ui-catalog.js +1 -1
- package/dist/reports/src/ui-state.d.ts +2 -0
- package/dist/reports/src/ui-state.js +2 -2
- package/dist/schema/src/api.d.ts +653 -1
- package/dist/schema/src/api.js +44 -0
- package/dist/schema/src/cli-results.d.ts +991 -1
- package/dist/schema/src/cli-results.js +265 -1
- package/dist/schema/src/critique.d.ts +31 -0
- package/dist/schema/src/critique.js +26 -0
- package/dist/schema/src/server-config.d.ts +3 -3
- package/dist/server/src/app.js +28 -1
- package/dist/server/src/openapi.js +19 -2
- package/docs/agents.md +16 -2
- package/docs/critics.md +273 -9
- package/docs/extending.md +1 -1
- package/docs/reference/cli.md +126 -1
- package/docs/reference/core.md +1 -1
- package/docs/reference/critics.md +1131 -29
- package/docs/reference/http-api.md +40 -0
- package/docs/reference/reports.md +855 -0
- package/docs/reference/schema.md +197 -1
- package/docs/report-contract.md +2 -1
- package/docs/server.md +2 -0
- package/docs/ui-catalog.md +110 -13
- package/docs/ui.md +69 -2
- package/llms-full.txt +636 -26
- package/llms.txt +1 -1
- package/package.json +9 -1
- package/packages/critics/README.md +1 -0
- package/packages/schema/schemas/critic-calibrate-result.schema.json +274 -0
- package/packages/schema/schemas/critic-doctor-result.schema.json +74 -0
- package/packages/schema/schemas/critic-list-result.schema.json +90 -0
- package/packages/schema/schemas/critic-review-result.schema.json +397 -0
- package/packages/schema/schemas/critic-run-result.schema.json +249 -0
- package/packages/schema/schemas/critic-setup-result.schema.json +34 -0
- package/packages/schema/schemas/critic-watch-result.schema.json +211 -0
- package/packages/schema/schemas/critique-mark.schema.json +81 -0
- package/packages/schema/schemas/effective-delta.schema.json +337 -0
- package/packages/schema/schemas/effective-evidence.schema.json +337 -0
- package/packages/schema/schemas/layer-rows.schema.json +337 -0
- package/packages/schema/schemas/ui-catalog-diff-result.schema.json +201 -0
- package/packages/schema/schemas/ui-catalog-export-result.schema.json +83 -0
- package/packages/schema/schemas/ui-catalog-publish-result.schema.json +116 -0
- package/packages/schema/schemas/ui-catalog-status.schema.json +197 -0
- package/packages/server/openapi.json +6755 -4738
- package/packages/visualizer/src/app/AppShell.tsx +3 -1
- package/packages/visualizer/src/app/router.tsx +20 -7
- package/packages/visualizer/src/catalog/Picture.tsx +32 -0
- package/packages/visualizer/src/catalog/UiCatalogCompare.tsx +289 -0
- package/packages/visualizer/src/catalog/UiCatalogPage.tsx +18 -28
- package/packages/visualizer/src/catalog/history.ts +61 -0
- package/packages/visualizer/src/catalog/search.ts +14 -3
- package/packages/visualizer/src/components/Header.tsx +16 -4
- package/packages/visualizer/src/critiques/CritiquesPage.tsx +402 -0
- package/packages/visualizer/src/critiques/FindingOverlay.tsx +50 -0
- package/packages/visualizer/src/critiques/ranking.ts +54 -0
- package/packages/visualizer/src/critiques/search.ts +41 -0
- package/packages/visualizer/src/evidence/applicability-view.tsx +11 -7
- package/packages/visualizer/src/evidence/behaviors-page.tsx +22 -15
- package/packages/visualizer/src/evidence/evidence-context.tsx +2 -1
- package/packages/visualizer/src/explorer/Explorer.tsx +5 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +4 -1
- package/packages/visualizer/src/explorer/parts.tsx +19 -1
- package/packages/visualizer/src/explorer/playlist.tsx +11 -7
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog comparison
|
|
3
|
+
*
|
|
4
|
+
* What changed in the UI catalog between two commits, or between two profiles, without claiming more than the evidence
|
|
5
|
+
* supports. Pure functions of {@link UiCatalog} documents (and of the layers they came from), safe in a browser; nothing here
|
|
6
|
+
* changes a status, a slot or a winner.
|
|
7
|
+
*
|
|
8
|
+
* - **Two commits.** The catalog of a commit is the catalog of the layers recorded at that commit ({@link uiCatalogAtCommit}).
|
|
9
|
+
* A state is paired with itself in the same profile. A layer replaces the older one of its slot, so the effective report no
|
|
10
|
+
* longer holds an earlier commit's captures: compare from the layers themselves ({@link readBackendLayers}), which keep them.
|
|
11
|
+
* - **Two profiles.** Within one catalog, each state's cell in one profile is set beside its cell in another
|
|
12
|
+
* ({@link diffUiProfiles}): desktop against mobile, light against dark.
|
|
13
|
+
* - **Pictures are compared by content.** Two captures have the same picture when their stored bytes have the same SHA-256.
|
|
14
|
+
* A different hash means different bytes, which is not a measure of how different the pixels are. When a side has no
|
|
15
|
+
* content hash (a picture given only by address, or left out), whether it changed is `unverified`, never "unchanged".
|
|
16
|
+
* - **A state only one side has** is `added` or `removed`. It may be new or renamed, or the other side's shard may not have
|
|
17
|
+
* run, and the comparison cannot tell which: it is not called a regression.
|
|
18
|
+
* - **A capture that did not pass is not a picture.** A cell that failed, is being taken or was skipped on a side is a change of
|
|
19
|
+
* `coverage`, never compared by picture.
|
|
20
|
+
*
|
|
21
|
+
* @see docs/ui-catalog.md
|
|
22
|
+
*/
|
|
23
|
+
import type { LayerEvidence } from './evidence-types.js';
|
|
24
|
+
import { type UiCatalog, type UiCatalogOptions, type UiCell, type UiImage, type UiProfile } from './ui-catalog.js';
|
|
25
|
+
/** The `schemaVersion` of the comparison document. */
|
|
26
|
+
export declare const UI_DIFF_VERSION = 1;
|
|
27
|
+
/**
|
|
28
|
+
* How one pair of cells stands. `added`: only the after side has the cell. `removed`: only the before side has it.
|
|
29
|
+
* `coverage`: both have it and it stands differently (captured against failed, for example). `picture`: both captured and
|
|
30
|
+
* the bytes differ. `unverified`: both captured, but a side has no content hash, so whether the picture changed is unknown.
|
|
31
|
+
* `unchanged`: the same coverage and, when captured, the same bytes.
|
|
32
|
+
*/
|
|
33
|
+
export type UiCellChange = 'added' | 'removed' | 'coverage' | 'picture' | 'unverified' | 'unchanged';
|
|
34
|
+
/** The values of {@link UiCellChange}, in the order a review reads them. */
|
|
35
|
+
export declare const UI_CELL_CHANGES: readonly ["coverage", "picture", "unverified", "added", "removed", "unchanged"];
|
|
36
|
+
/** One state in one profile on each side. */
|
|
37
|
+
export type UiDiffEntry = {
|
|
38
|
+
/** The state's id. */
|
|
39
|
+
readonly id: string;
|
|
40
|
+
/** Its title, from the after side when it has the state. */
|
|
41
|
+
readonly title: string;
|
|
42
|
+
/** The profile both sides are in (a comparison of commits), or null when the two sides are different profiles. */
|
|
43
|
+
readonly profile: UiProfile | null;
|
|
44
|
+
readonly before: UiCell | null;
|
|
45
|
+
readonly after: UiCell | null;
|
|
46
|
+
readonly change: UiCellChange;
|
|
47
|
+
};
|
|
48
|
+
/** A comparison, as `atlas ui-catalog diff --format json` prints it and the viewer shows it. */
|
|
49
|
+
export type UiCatalogDiff = {
|
|
50
|
+
readonly schemaVersion: typeof UI_DIFF_VERSION;
|
|
51
|
+
/** How many entries are in each {@link UiCellChange}. */
|
|
52
|
+
readonly summary: Readonly<Record<UiCellChange, number>> & {
|
|
53
|
+
readonly total: number;
|
|
54
|
+
};
|
|
55
|
+
/** Every pair, ordered by state id and then profile label. */
|
|
56
|
+
readonly entries: readonly UiDiffEntry[];
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* What identifies a picture's content: its recorded SHA-256, else the hash in a content-addressed bundle key
|
|
60
|
+
* (`attachments/<sha256>`), else `null`. A picture given only by address, or left out, has no identity here: its content is
|
|
61
|
+
* not Atlas's to vouch for.
|
|
62
|
+
*/
|
|
63
|
+
export declare function pictureIdentity(image: UiImage | null | undefined): string | null;
|
|
64
|
+
/**
|
|
65
|
+
* Sets the catalog of an earlier commit (`before`) against a later one (`after`): each state in each profile is paired with
|
|
66
|
+
* itself. A cell that is only a plan (`missing`) on one side is a change of coverage like any other. The order of the
|
|
67
|
+
* states in either catalog does not matter.
|
|
68
|
+
*/
|
|
69
|
+
export declare function diffUiCatalogs(before: UiCatalog, after: UiCatalog): UiCatalogDiff;
|
|
70
|
+
/**
|
|
71
|
+
* Sets one profile of a catalog (`from`, a {@link UiProfile.key}) against another (`to`): each state's cell in `from` beside its
|
|
72
|
+
* cell in `to`. A state that has a cell in only one of them is `added` or `removed`. Throws when the two are the same profile,
|
|
73
|
+
* because that compares a catalog with itself.
|
|
74
|
+
*/
|
|
75
|
+
export declare function diffUiProfiles(catalog: UiCatalog, from: string, to: string): UiCatalogDiff;
|
|
76
|
+
/** The entries of a comparison that changed, or all of them. A change the comparison cannot verify is a change worth looking at, so it is kept. */
|
|
77
|
+
export declare function changedUiEntries(diff: UiCatalogDiff): readonly UiDiffEntry[];
|
|
78
|
+
/** A commit that has layers of the catalog's producers. */
|
|
79
|
+
export type UiCommit = {
|
|
80
|
+
readonly sha: string;
|
|
81
|
+
/** The branch of the newest layer recorded at the commit, or null. */
|
|
82
|
+
readonly branch: string | null;
|
|
83
|
+
/** When the newest layer at the commit was recorded, epoch milliseconds. */
|
|
84
|
+
readonly recordedAtMs: number;
|
|
85
|
+
/** How many layers were recorded at the commit. */
|
|
86
|
+
readonly layers: number;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The commits layers were recorded at, newest first. A layer with no commit is left out: it cannot be compared by commit.
|
|
90
|
+
* Layers recorded from a checkout with uncommitted changes (`freshness` `modified`) are not the commit's own result and are
|
|
91
|
+
* left out too, as the gate does by default for comparisons.
|
|
92
|
+
*/
|
|
93
|
+
export declare function uiCatalogCommits(layers: readonly Pick<LayerEvidence['layer'], 'sha' | 'branch' | 'recordedAtMs' | 'freshness'>[]): UiCommit[];
|
|
94
|
+
/**
|
|
95
|
+
* The layers recorded at one commit (`sha` is the whole commit id, or a prefix of at least 7 hexadecimal digits that names
|
|
96
|
+
* exactly one commit among `layers`). Throws when a prefix names none, or more than one, so a comparison never silently uses
|
|
97
|
+
* the wrong commit.
|
|
98
|
+
*/
|
|
99
|
+
export declare function layersAtCommit(layers: readonly LayerEvidence[], sha: string): LayerEvidence[];
|
|
100
|
+
/**
|
|
101
|
+
* The UI catalog as it was recorded at one commit: only the layers recorded there take part. A state the commit's layers do
|
|
102
|
+
* not hold is absent, not failed. See {@link layersAtCommit} for how `sha` is matched.
|
|
103
|
+
*/
|
|
104
|
+
export declare function uiCatalogAtCommit(layers: readonly LayerEvidence[], sha: string, options?: UiCatalogOptions): UiCatalog;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { uiCatalog } from './ui-catalog.js';
|
|
2
|
+
/** The `schemaVersion` of the comparison document. */
|
|
3
|
+
export const UI_DIFF_VERSION = 1;
|
|
4
|
+
/** The values of {@link UiCellChange}, in the order a review reads them. */
|
|
5
|
+
export const UI_CELL_CHANGES = ['coverage', 'picture', 'unverified', 'added', 'removed', 'unchanged'];
|
|
6
|
+
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
7
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
8
|
+
/**
|
|
9
|
+
* What identifies a picture's content: its recorded SHA-256, else the hash in a content-addressed bundle key
|
|
10
|
+
* (`attachments/<sha256>`), else `null`. A picture given only by address, or left out, has no identity here: its content is
|
|
11
|
+
* not Atlas's to vouch for.
|
|
12
|
+
*/
|
|
13
|
+
export function pictureIdentity(image) {
|
|
14
|
+
if (!image || image.omitted)
|
|
15
|
+
return null;
|
|
16
|
+
const hash = image.sha256?.toLowerCase();
|
|
17
|
+
if (hash && SHA256.test(hash))
|
|
18
|
+
return hash;
|
|
19
|
+
const keyed = /^attachments\/([0-9a-fA-F]{64})$/.exec(image.bundleKey ?? '')?.[1];
|
|
20
|
+
return keyed ? keyed.toLowerCase() : null;
|
|
21
|
+
}
|
|
22
|
+
function changeOf(before, after) {
|
|
23
|
+
if (!before)
|
|
24
|
+
return 'added';
|
|
25
|
+
if (!after)
|
|
26
|
+
return 'removed';
|
|
27
|
+
if (before.coverage !== after.coverage)
|
|
28
|
+
return 'coverage';
|
|
29
|
+
if (before.coverage !== 'captured')
|
|
30
|
+
return 'unchanged';
|
|
31
|
+
const a = pictureIdentity(before.capture?.image), b = pictureIdentity(after.capture?.image);
|
|
32
|
+
if (a === null || b === null)
|
|
33
|
+
return 'unverified';
|
|
34
|
+
return a === b ? 'unchanged' : 'picture';
|
|
35
|
+
}
|
|
36
|
+
function summarize(entries) {
|
|
37
|
+
const counts = { coverage: 0, picture: 0, unverified: 0, added: 0, removed: 0, unchanged: 0 };
|
|
38
|
+
for (const entry of entries)
|
|
39
|
+
counts[entry.change] += 1;
|
|
40
|
+
return { ...counts, total: entries.length };
|
|
41
|
+
}
|
|
42
|
+
function ordered(entries) {
|
|
43
|
+
entries.sort((a, b) => compare(a.id, b.id) || compare(a.profile?.label ?? '', b.profile?.label ?? '') || compare(a.before?.profile.label ?? '', b.before?.profile.label ?? ''));
|
|
44
|
+
return { schemaVersion: UI_DIFF_VERSION, summary: summarize(entries), entries };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Sets the catalog of an earlier commit (`before`) against a later one (`after`): each state in each profile is paired with
|
|
48
|
+
* itself. A cell that is only a plan (`missing`) on one side is a change of coverage like any other. The order of the
|
|
49
|
+
* states in either catalog does not matter.
|
|
50
|
+
*/
|
|
51
|
+
export function diffUiCatalogs(before, after) {
|
|
52
|
+
const cells = (catalog) => new Map(catalog.states.flatMap(state => state.cells.map(cell => [`${state.id}\u0000${cell.profile.key}`, { state, cell }])));
|
|
53
|
+
const left = cells(before), right = cells(after);
|
|
54
|
+
const entries = [];
|
|
55
|
+
for (const key of new Set([...left.keys(), ...right.keys()])) {
|
|
56
|
+
const a = left.get(key), b = right.get(key);
|
|
57
|
+
const first = (b ?? a);
|
|
58
|
+
entries.push({ id: first.state.id, title: first.state.title, profile: first.cell.profile, before: a?.cell ?? null, after: b?.cell ?? null, change: changeOf(a?.cell ?? null, b?.cell ?? null) });
|
|
59
|
+
}
|
|
60
|
+
return ordered(entries);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Sets one profile of a catalog (`from`, a {@link UiProfile.key}) against another (`to`): each state's cell in `from` beside its
|
|
64
|
+
* cell in `to`. A state that has a cell in only one of them is `added` or `removed`. Throws when the two are the same profile,
|
|
65
|
+
* because that compares a catalog with itself.
|
|
66
|
+
*/
|
|
67
|
+
export function diffUiProfiles(catalog, from, to) {
|
|
68
|
+
if (from === to)
|
|
69
|
+
throw new Error('diffUiProfiles: choose two different profiles');
|
|
70
|
+
const entries = [];
|
|
71
|
+
for (const state of catalog.states) {
|
|
72
|
+
const a = state.cells.find(cell => cell.profile.key === from) ?? null;
|
|
73
|
+
const b = state.cells.find(cell => cell.profile.key === to) ?? null;
|
|
74
|
+
if (!a && !b)
|
|
75
|
+
continue;
|
|
76
|
+
entries.push({ id: state.id, title: state.title, profile: null, before: a, after: b, change: changeOf(a, b) });
|
|
77
|
+
}
|
|
78
|
+
return ordered(entries);
|
|
79
|
+
}
|
|
80
|
+
/** The entries of a comparison that changed, or all of them. A change the comparison cannot verify is a change worth looking at, so it is kept. */
|
|
81
|
+
export function changedUiEntries(diff) {
|
|
82
|
+
return diff.entries.filter(entry => entry.change !== 'unchanged');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The commits layers were recorded at, newest first. A layer with no commit is left out: it cannot be compared by commit.
|
|
86
|
+
* Layers recorded from a checkout with uncommitted changes (`freshness` `modified`) are not the commit's own result and are
|
|
87
|
+
* left out too, as the gate does by default for comparisons.
|
|
88
|
+
*/
|
|
89
|
+
export function uiCatalogCommits(layers) {
|
|
90
|
+
const commits = new Map();
|
|
91
|
+
for (const layer of layers) {
|
|
92
|
+
if (!layer.sha || layer.freshness === 'modified')
|
|
93
|
+
continue;
|
|
94
|
+
const held = commits.get(layer.sha);
|
|
95
|
+
if (!held)
|
|
96
|
+
commits.set(layer.sha, { sha: layer.sha, branch: layer.branch, recordedAtMs: layer.recordedAtMs, layers: 1 });
|
|
97
|
+
else {
|
|
98
|
+
held.layers += 1;
|
|
99
|
+
if (layer.recordedAtMs > held.recordedAtMs) {
|
|
100
|
+
held.recordedAtMs = layer.recordedAtMs;
|
|
101
|
+
held.branch = layer.branch;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...commits.values()].sort((a, b) => b.recordedAtMs - a.recordedAtMs || compare(a.sha, b.sha));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The layers recorded at one commit (`sha` is the whole commit id, or a prefix of at least 7 hexadecimal digits that names
|
|
109
|
+
* exactly one commit among `layers`). Throws when a prefix names none, or more than one, so a comparison never silently uses
|
|
110
|
+
* the wrong commit.
|
|
111
|
+
*/
|
|
112
|
+
export function layersAtCommit(layers, sha) {
|
|
113
|
+
const wanted = sha.toLowerCase();
|
|
114
|
+
if (!/^[0-9a-f]{7,64}$/.test(wanted))
|
|
115
|
+
throw new Error(`"${sha}" is not a commit: give at least 7 hexadecimal digits of its id`);
|
|
116
|
+
const shas = new Set(layers.flatMap(entry => (entry.layer.sha && entry.layer.freshness !== 'modified' && entry.layer.sha.toLowerCase().startsWith(wanted) ? [entry.layer.sha.toLowerCase()] : [])));
|
|
117
|
+
if (shas.size === 0)
|
|
118
|
+
throw new Error(`No layer was recorded at commit ${sha}`);
|
|
119
|
+
if (shas.size > 1)
|
|
120
|
+
throw new Error(`"${sha}" names more than one commit (${[...shas].map(name => name.slice(0, 12)).join(', ')}): give more digits`);
|
|
121
|
+
const [only] = shas;
|
|
122
|
+
return layers.filter(entry => entry.layer.sha?.toLowerCase() === only && entry.layer.freshness !== 'modified');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The UI catalog as it was recorded at one commit: only the layers recorded there take part. A state the commit's layers do
|
|
126
|
+
* not hold is absent, not failed. See {@link layersAtCommit} for how `sha` is matched.
|
|
127
|
+
*/
|
|
128
|
+
export function uiCatalogAtCommit(layers, sha, options = {}) {
|
|
129
|
+
return uiCatalog(layersAtCommit(layers, sha), options);
|
|
130
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog entries for critics
|
|
3
|
+
*
|
|
4
|
+
* The UI catalog seen as a flat, orderable list that a separate process can iterate: one **entry** per captured state in
|
|
5
|
+
* one profile that has a stored picture, with the hash of that picture and the critiques already made of it. A critic
|
|
6
|
+
* lists entries a page at a time with a stable cursor, asks only for those that changed since a report version it holds,
|
|
7
|
+
* asks only for those a given critic has not judged at the picture they have now, and fetches the picture itself by the
|
|
8
|
+
* `image.path` (served by the same safe attachment path as every other file). Nothing here is stored: entries are a function of the
|
|
9
|
+
* report, so the same layers in any order give the same list, and a missed event is covered by the next read.
|
|
10
|
+
*
|
|
11
|
+
* Critiques are opinions, never results: they ride along as evidence of an entry and change nothing about whether a
|
|
12
|
+
* report passes.
|
|
13
|
+
*
|
|
14
|
+
* @see docs/critics.md#reading-the-catalog
|
|
15
|
+
* @see docs/ui-catalog.md
|
|
16
|
+
*/
|
|
17
|
+
import type { Critique, CritiqueMark } from '../../schema/src/critique.js';
|
|
18
|
+
import type { EvidenceStatus } from './explorer-types.js';
|
|
19
|
+
import type { LayerEvidence } from './evidence-types.js';
|
|
20
|
+
import { type UiOrigin, type UiProfile } from './ui-catalog.js';
|
|
21
|
+
/** The `schemaVersion` of the entries document. */
|
|
22
|
+
export declare const CATALOG_ENTRIES_VERSION = 1;
|
|
23
|
+
/** The state kind of an entry whose producers declared none. It is unknown, not `screen`. */
|
|
24
|
+
export declare const UNKNOWN_STATE_KIND = "unknown";
|
|
25
|
+
/** The picture an entry was captured as: the hash critiques are anchored to, and where its stored bytes are. */
|
|
26
|
+
export type EntryImage = {
|
|
27
|
+
/** SHA-256 of the exact stored bytes, lowercase hex. */
|
|
28
|
+
readonly sha256: string;
|
|
29
|
+
/** Where the bytes are, relative to the reports API root: `/layers/<layer>/blobs/attachments/<sha256>`. */
|
|
30
|
+
readonly path: string;
|
|
31
|
+
readonly contentType?: string;
|
|
32
|
+
readonly width?: number;
|
|
33
|
+
readonly height?: number;
|
|
34
|
+
readonly bytes?: number;
|
|
35
|
+
};
|
|
36
|
+
/** A critique already made of an entry (any critic), and whether it judged the picture the entry has now. */
|
|
37
|
+
export type EntryCritique = {
|
|
38
|
+
readonly layer: string;
|
|
39
|
+
readonly producer: string;
|
|
40
|
+
readonly recordedAtMs: number;
|
|
41
|
+
/** The check's own status: whether the critic ran, never what it thought. */
|
|
42
|
+
readonly status: EvidenceStatus;
|
|
43
|
+
/** `current`: it judged this picture. `earlier-image`: the picture changed since, so the entry counts as not yet judged by that critic. */
|
|
44
|
+
readonly currency: 'current' | 'earlier-image';
|
|
45
|
+
readonly critique: Critique;
|
|
46
|
+
};
|
|
47
|
+
/** A person's accept or dismiss of a critic's opinion about an entry that wins its slot, and whether it was made about the picture the entry has now. */
|
|
48
|
+
export type EntryMark = {
|
|
49
|
+
readonly layer: string;
|
|
50
|
+
/** `critic.review.<reviewer>`. */
|
|
51
|
+
readonly producer: string;
|
|
52
|
+
readonly recordedAtMs: number;
|
|
53
|
+
/** `current`: made about this picture. `earlier-image`: the picture changed since, so the mark is shown as about an earlier picture and counts for nothing now. */
|
|
54
|
+
readonly currency: 'current' | 'earlier-image';
|
|
55
|
+
readonly mark: CritiqueMark;
|
|
56
|
+
};
|
|
57
|
+
/** One captured state in one profile, ready to be judged. */
|
|
58
|
+
export type CatalogEntry = {
|
|
59
|
+
/** Stable identity, unique in the catalog: the state id, then `@` and the profile when it has one. */
|
|
60
|
+
readonly key: string;
|
|
61
|
+
readonly stateId: string;
|
|
62
|
+
readonly title: string;
|
|
63
|
+
readonly route: string | null;
|
|
64
|
+
readonly area: string | null;
|
|
65
|
+
/** The kind of UI state (`screen`, `overlay`, `loading`, `error`, `empty`…), or `unknown`. */
|
|
66
|
+
readonly state: string;
|
|
67
|
+
readonly profile: UiProfile;
|
|
68
|
+
readonly image: EntryImage;
|
|
69
|
+
/** The layer, backend and provenance of the capture. */
|
|
70
|
+
readonly origin: UiOrigin;
|
|
71
|
+
/** Critiques of this entry that win their slot, newest first. */
|
|
72
|
+
readonly critiques: readonly EntryCritique[];
|
|
73
|
+
/** Reviewers' marks (accept or dismiss) of those critiques that win their slot, newest first. Evidence: they never decide a result. */
|
|
74
|
+
readonly marks: readonly EntryMark[];
|
|
75
|
+
};
|
|
76
|
+
/** The entries of a report, and how many captured cells could not be offered for judging. */
|
|
77
|
+
export type CatalogEntrySet = {
|
|
78
|
+
readonly entries: readonly CatalogEntry[];
|
|
79
|
+
/** Captured cells without a stored picture of a known hash (given only by address, left out, or not yet archived). They are not entries. */
|
|
80
|
+
readonly unjudgeable: number;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The key of the entry for a state in a profile. The state id alone when the profile says nothing; else
|
|
84
|
+
* `id@environment;name=value;…` with the parameters sorted and every part percent-encoded, so two profiles never share a key.
|
|
85
|
+
*/
|
|
86
|
+
export declare function catalogEntryKey(stateId: string, profile: Pick<UiProfile, 'environment' | 'parameters'>): string;
|
|
87
|
+
/**
|
|
88
|
+
* The entries of some layers, in key order. Only a `captured` cell with a picture stored in its layer (`image.path`) and a
|
|
89
|
+
* known hash is an entry. Critiques are matched to entries by `subject.entryKey`, take the winner of each of their slots by
|
|
90
|
+
* the report contract, and are marked `earlier-image` when they judged other bytes.
|
|
91
|
+
*/
|
|
92
|
+
export declare function catalogEntries(layers: readonly LayerEvidence[]): CatalogEntrySet;
|
|
93
|
+
/** Whether an entry has a current critique with a verdict from the critic (and, when given, its version). A failed critique is not a judgement: the next run retries it. */
|
|
94
|
+
export declare function isJudgedBy(entry: Pick<CatalogEntry, 'critiques'>, critic: {
|
|
95
|
+
readonly name: string;
|
|
96
|
+
readonly version?: string;
|
|
97
|
+
}): boolean;
|
|
98
|
+
/** What a critic narrows the entries by. Every field is optional; nothing asked is everything. Values within a field are alternatives, fields combine with AND. */
|
|
99
|
+
export type CatalogEntriesQuery = {
|
|
100
|
+
/** Words (all of them) found in the key, title, route or area. */
|
|
101
|
+
readonly q?: string;
|
|
102
|
+
readonly area?: readonly string[];
|
|
103
|
+
/** Profile keys, as `UiProfile.key`. */
|
|
104
|
+
readonly profile?: readonly string[];
|
|
105
|
+
/** State kinds: `screen`, `overlay`, `loading`… */
|
|
106
|
+
readonly state?: readonly string[];
|
|
107
|
+
readonly route?: readonly string[];
|
|
108
|
+
/** Only entries with no current critique with a verdict from this critic (`name`, or `name@version`). */
|
|
109
|
+
readonly unjudgedBy?: {
|
|
110
|
+
readonly name: string;
|
|
111
|
+
readonly version?: string;
|
|
112
|
+
};
|
|
113
|
+
/** Only entries added or changed since this report version. A version the server does not remember is answered with everything. */
|
|
114
|
+
readonly since?: string;
|
|
115
|
+
/** Resume after the entry this cursor names. Opaque: take it from the previous page. */
|
|
116
|
+
readonly cursor?: string;
|
|
117
|
+
/** Entries per page. Default 100, at most 1000. */
|
|
118
|
+
readonly limit?: number;
|
|
119
|
+
};
|
|
120
|
+
/** The most entries one page holds. */
|
|
121
|
+
export declare const MAX_ENTRIES_LIMIT = 1000;
|
|
122
|
+
/** Reads a query from URL search parameters; the comma-separated lists, `q`, `unjudgedBy=name@version`, `since`, `cursor` and `limit`. A `limit` that is not a whole number is left out. */
|
|
123
|
+
export declare function parseCatalogEntriesQuery(params: URLSearchParams): CatalogEntriesQuery;
|
|
124
|
+
/** A query as URL search parameters, the form {@link parseCatalogEntriesQuery} reads back. */
|
|
125
|
+
export declare function catalogEntriesParams(query: CatalogEntriesQuery): URLSearchParams;
|
|
126
|
+
/** The opaque cursor after an entry. */
|
|
127
|
+
export declare function encodeEntryCursor(key: string): string;
|
|
128
|
+
/** The key a cursor names, or `null` when it is not a cursor this reader made. */
|
|
129
|
+
export declare function decodeEntryCursor(cursor: string): string | null;
|
|
130
|
+
/** One page of entries and where to resume. */
|
|
131
|
+
export type CatalogEntryPage = {
|
|
132
|
+
/** The page, in key order. */
|
|
133
|
+
readonly entries: readonly CatalogEntry[];
|
|
134
|
+
/** Entries that match the query (all pages). */
|
|
135
|
+
readonly total: number;
|
|
136
|
+
/** The `cursor` of the next page, absent on the last. */
|
|
137
|
+
readonly nextCursor?: string;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* One page of the entries that match a query, in key order. The order is by key and the cursor names the last key of a page, so
|
|
141
|
+
* a critic that iterates sees each entry exactly once however the catalog grows meanwhile (a new entry that sorts before the
|
|
142
|
+
* cursor is found by the next `since` read). `changed` is the set of layer ids that changed since `query.since`
|
|
143
|
+
* (`ReportIndex.changedSince`): `null` means the version was not remembered, and then everything matches.
|
|
144
|
+
*
|
|
145
|
+
* @throws Error when `query.cursor` is not a cursor.
|
|
146
|
+
*/
|
|
147
|
+
export declare function pageCatalogEntries(all: readonly CatalogEntry[], query: CatalogEntriesQuery, changed?: ReadonlySet<string> | null): CatalogEntryPage;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { CRITIQUE_PART_KIND } from '../../schema/src/critique.js';
|
|
2
|
+
import { combineEvidence } from './layer-merge.js';
|
|
3
|
+
import { uiCatalog } from './ui-catalog.js';
|
|
4
|
+
/** The `schemaVersion` of the entries document. */
|
|
5
|
+
export const CATALOG_ENTRIES_VERSION = 1;
|
|
6
|
+
/** The state kind of an entry whose producers declared none. It is unknown, not `screen`. */
|
|
7
|
+
export const UNKNOWN_STATE_KIND = 'unknown';
|
|
8
|
+
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
9
|
+
/**
|
|
10
|
+
* The key of the entry for a state in a profile. The state id alone when the profile says nothing; else
|
|
11
|
+
* `id@environment;name=value;…` with the parameters sorted and every part percent-encoded, so two profiles never share a key.
|
|
12
|
+
*/
|
|
13
|
+
export function catalogEntryKey(stateId, profile) {
|
|
14
|
+
const parameters = Object.entries(profile.parameters).sort(([a], [b]) => compare(a, b)).map(([name, text]) => `${encodeURIComponent(name)}=${encodeURIComponent(text)}`);
|
|
15
|
+
if (!profile.environment && !parameters.length)
|
|
16
|
+
return stateId;
|
|
17
|
+
return `${stateId}@${[encodeURIComponent(profile.environment ?? ''), ...parameters].join(';')}`;
|
|
18
|
+
}
|
|
19
|
+
function hashOf(image) {
|
|
20
|
+
if (image.sha256 && /^[0-9a-f]{64}$/i.test(image.sha256))
|
|
21
|
+
return image.sha256.toLowerCase();
|
|
22
|
+
return /(?:^|\/)([0-9a-f]{64})$/i.exec(image.bundleKey ?? '')?.[1]?.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The entries of some layers, in key order. Only a `captured` cell with a picture stored in its layer (`image.path`) and a
|
|
26
|
+
* known hash is an entry. Critiques are matched to entries by `subject.entryKey`, take the winner of each of their slots by
|
|
27
|
+
* the report contract, and are marked `earlier-image` when they judged other bytes.
|
|
28
|
+
*/
|
|
29
|
+
export function catalogEntries(layers) {
|
|
30
|
+
const catalog = uiCatalog(layers);
|
|
31
|
+
const opinions = [];
|
|
32
|
+
for (const entry of layers) {
|
|
33
|
+
const rows = entry.rows.filter(row => row.kind === CRITIQUE_PART_KIND && (row.critique !== undefined || row.critiqueMark !== undefined));
|
|
34
|
+
if (rows.length)
|
|
35
|
+
opinions.push({ ...entry, rows });
|
|
36
|
+
}
|
|
37
|
+
const byEntry = new Map();
|
|
38
|
+
const marksByEntry = new Map();
|
|
39
|
+
for (const result of combineEvidence({ specs: [], sha: null, changedFiles: [] }, opinions).unassigned) {
|
|
40
|
+
const mark = result.row.critiqueMark;
|
|
41
|
+
if (mark) {
|
|
42
|
+
const marked = marksByEntry.get(mark.subject.entryKey) ?? [];
|
|
43
|
+
marked.push({ layer: result.layer, mark });
|
|
44
|
+
marksByEntry.set(mark.subject.entryKey, marked);
|
|
45
|
+
}
|
|
46
|
+
const critique = result.row.critique;
|
|
47
|
+
if (!critique)
|
|
48
|
+
continue;
|
|
49
|
+
const list = byEntry.get(critique.subject.entryKey) ?? [];
|
|
50
|
+
list.push({ layer: result.layer, status: result.row.status, critique });
|
|
51
|
+
byEntry.set(critique.subject.entryKey, list);
|
|
52
|
+
}
|
|
53
|
+
const entries = [];
|
|
54
|
+
let unjudgeable = 0;
|
|
55
|
+
for (const state of catalog.states) {
|
|
56
|
+
for (const cell of state.cells) {
|
|
57
|
+
if (cell.coverage !== 'captured' || !cell.capture)
|
|
58
|
+
continue;
|
|
59
|
+
const image = cell.capture.image;
|
|
60
|
+
const sha256 = image ? hashOf(image) : undefined;
|
|
61
|
+
if (!image || !image.path || !sha256) {
|
|
62
|
+
unjudgeable += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const key = catalogEntryKey(state.id, cell.profile);
|
|
66
|
+
const critiques = (byEntry.get(key) ?? [])
|
|
67
|
+
.map(found => ({
|
|
68
|
+
layer: found.layer.id, producer: found.layer.producer, recordedAtMs: found.layer.recordedAtMs, status: found.status,
|
|
69
|
+
currency: found.critique.subject.imageSha256 === sha256 ? 'current' : 'earlier-image', critique: found.critique
|
|
70
|
+
}))
|
|
71
|
+
.sort((a, b) => b.recordedAtMs - a.recordedAtMs || compare(a.layer, b.layer));
|
|
72
|
+
const marks = (marksByEntry.get(key) ?? [])
|
|
73
|
+
.map(found => ({ layer: found.layer.id, producer: found.layer.producer, recordedAtMs: found.layer.recordedAtMs, currency: found.mark.subject.imageSha256 === sha256 ? 'current' : 'earlier-image', mark: found.mark }))
|
|
74
|
+
.sort((a, b) => b.recordedAtMs - a.recordedAtMs || compare(a.layer, b.layer));
|
|
75
|
+
entries.push({
|
|
76
|
+
key, stateId: state.id, title: state.title, route: state.route, area: state.area, state: state.kind ?? UNKNOWN_STATE_KIND, profile: cell.profile,
|
|
77
|
+
image: { sha256, path: image.path, ...(image.contentType ? { contentType: image.contentType } : {}), ...(image.width !== undefined ? { width: image.width } : {}), ...(image.height !== undefined ? { height: image.height } : {}), ...(image.bytes !== undefined ? { bytes: image.bytes } : {}) },
|
|
78
|
+
origin: cell.capture.origin, critiques, marks
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
entries.sort((a, b) => compare(a.key, b.key));
|
|
83
|
+
return { entries, unjudgeable };
|
|
84
|
+
}
|
|
85
|
+
/** Whether an entry has a current critique with a verdict from the critic (and, when given, its version). A failed critique is not a judgement: the next run retries it. */
|
|
86
|
+
export function isJudgedBy(entry, critic) {
|
|
87
|
+
return entry.critiques.some(found => found.currency === 'current' && found.critique.critic.name === critic.name && (critic.version === undefined || found.critique.critic.version === critic.version) && found.critique.verdict !== undefined && !found.critique.failure);
|
|
88
|
+
}
|
|
89
|
+
const LIST_FIELDS = ['area', 'profile', 'state', 'route'];
|
|
90
|
+
const DEFAULT_LIMIT = 100;
|
|
91
|
+
/** The most entries one page holds. */
|
|
92
|
+
export const MAX_ENTRIES_LIMIT = 1000;
|
|
93
|
+
/** Reads a query from URL search parameters; the comma-separated lists, `q`, `unjudgedBy=name@version`, `since`, `cursor` and `limit`. A `limit` that is not a whole number is left out. */
|
|
94
|
+
export function parseCatalogEntriesQuery(params) {
|
|
95
|
+
const query = {};
|
|
96
|
+
const q = params.get('q')?.trim();
|
|
97
|
+
if (q)
|
|
98
|
+
query.q = q;
|
|
99
|
+
for (const name of LIST_FIELDS) {
|
|
100
|
+
const values = params.getAll(name).flatMap(item => item.split(',')).map(item => item.trim()).filter(Boolean);
|
|
101
|
+
if (values.length)
|
|
102
|
+
query[name] = values;
|
|
103
|
+
}
|
|
104
|
+
const by = params.get('unjudgedBy')?.trim();
|
|
105
|
+
if (by) {
|
|
106
|
+
const at = by.lastIndexOf('@');
|
|
107
|
+
query.unjudgedBy = at > 0 ? { name: by.slice(0, at), version: by.slice(at + 1) } : { name: by };
|
|
108
|
+
}
|
|
109
|
+
const since = params.get('since')?.trim();
|
|
110
|
+
if (since)
|
|
111
|
+
query.since = since;
|
|
112
|
+
const cursor = params.get('cursor')?.trim();
|
|
113
|
+
if (cursor)
|
|
114
|
+
query.cursor = cursor;
|
|
115
|
+
const limit = params.get('limit');
|
|
116
|
+
if (limit !== null && /^\d+$/.test(limit))
|
|
117
|
+
query.limit = Number(limit);
|
|
118
|
+
return query;
|
|
119
|
+
}
|
|
120
|
+
/** A query as URL search parameters, the form {@link parseCatalogEntriesQuery} reads back. */
|
|
121
|
+
export function catalogEntriesParams(query) {
|
|
122
|
+
const params = new URLSearchParams();
|
|
123
|
+
if (query.q)
|
|
124
|
+
params.set('q', query.q);
|
|
125
|
+
for (const name of LIST_FIELDS)
|
|
126
|
+
if (query[name]?.length)
|
|
127
|
+
params.set(name, query[name].join(','));
|
|
128
|
+
if (query.unjudgedBy)
|
|
129
|
+
params.set('unjudgedBy', query.unjudgedBy.version === undefined ? query.unjudgedBy.name : `${query.unjudgedBy.name}@${query.unjudgedBy.version}`);
|
|
130
|
+
if (query.since)
|
|
131
|
+
params.set('since', query.since);
|
|
132
|
+
if (query.cursor)
|
|
133
|
+
params.set('cursor', query.cursor);
|
|
134
|
+
if (query.limit !== undefined)
|
|
135
|
+
params.set('limit', String(query.limit));
|
|
136
|
+
return params;
|
|
137
|
+
}
|
|
138
|
+
/** The opaque cursor after an entry. */
|
|
139
|
+
export function encodeEntryCursor(key) {
|
|
140
|
+
let binary = '';
|
|
141
|
+
for (const byte of new TextEncoder().encode(key))
|
|
142
|
+
binary += String.fromCharCode(byte);
|
|
143
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
144
|
+
}
|
|
145
|
+
/** The key a cursor names, or `null` when it is not a cursor this reader made. */
|
|
146
|
+
export function decodeEntryCursor(cursor) {
|
|
147
|
+
if (!/^[A-Za-z0-9_-]+$/.test(cursor))
|
|
148
|
+
return null;
|
|
149
|
+
try {
|
|
150
|
+
const binary = atob(cursor.replaceAll('-', '+').replaceAll('_', '/'));
|
|
151
|
+
const key = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(binary, char => char.charCodeAt(0)));
|
|
152
|
+
return key !== '' && encodeEntryCursor(key) === cursor ? key : null;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* One page of the entries that match a query, in key order. The order is by key and the cursor names the last key of a page, so
|
|
160
|
+
* a critic that iterates sees each entry exactly once however the catalog grows meanwhile (a new entry that sorts before the
|
|
161
|
+
* cursor is found by the next `since` read). `changed` is the set of layer ids that changed since `query.since`
|
|
162
|
+
* (`ReportIndex.changedSince`): `null` means the version was not remembered, and then everything matches.
|
|
163
|
+
*
|
|
164
|
+
* @throws Error when `query.cursor` is not a cursor.
|
|
165
|
+
*/
|
|
166
|
+
export function pageCatalogEntries(all, query, changed = null) {
|
|
167
|
+
const terms = (query.q ?? '').toLowerCase().split(/\s+/).filter(Boolean);
|
|
168
|
+
const any = (wanted, has) => !wanted?.length || wanted.some(has);
|
|
169
|
+
const after = query.cursor === undefined ? null : decodeEntryCursor(query.cursor);
|
|
170
|
+
if (query.cursor !== undefined && after === null)
|
|
171
|
+
throw new Error('The cursor is not one this server made; start again without it.');
|
|
172
|
+
const limit = Math.min(MAX_ENTRIES_LIMIT, Math.max(1, query.limit ?? DEFAULT_LIMIT));
|
|
173
|
+
const matching = all.filter(entry => {
|
|
174
|
+
if (terms.length) {
|
|
175
|
+
const text = [entry.key, entry.title, entry.route, entry.area].filter(Boolean).join(' ').toLowerCase();
|
|
176
|
+
if (!terms.every(term => text.includes(term)))
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (!any(query.area, value => entry.area === value) || !any(query.profile, value => entry.profile.key === value) || !any(query.state, value => entry.state === value) || !any(query.route, value => entry.route === value))
|
|
180
|
+
return false;
|
|
181
|
+
if (query.unjudgedBy && isJudgedBy(entry, query.unjudgedBy))
|
|
182
|
+
return false;
|
|
183
|
+
if (query.since !== undefined && changed !== null && !changed.has(entry.origin.layer))
|
|
184
|
+
return false;
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
const rest = after === null ? matching : matching.filter(entry => entry.key > after);
|
|
188
|
+
const page = rest.slice(0, limit);
|
|
189
|
+
return { entries: page, total: matching.length, ...(rest.length > page.length ? { nextCursor: encodeEntryCursor(page[page.length - 1].key) } : {}) };
|
|
190
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module UI catalog single-file export
|
|
3
|
+
*
|
|
4
|
+
* The UI catalog as one HTML file that needs no server, no network and no build: open it from a disk, attach it to a ticket,
|
|
5
|
+
* host it anywhere that serves a file. Pictures are embedded as `data:` addresses, the manifest is embedded as JSON
|
|
6
|
+
* (`<script type="application/json" id="ui-catalog">`), and a few lines of script filter the states; with scripts off, every
|
|
7
|
+
* state is simply shown. It shows what the viewer's catalog shows, but it does not stream: it is the catalog at the moment it
|
|
8
|
+
* was made, and says when.
|
|
9
|
+
*
|
|
10
|
+
* Hosting platforms cap the size of one file, which is the reason a catalog is also a store and a viewer. The export keeps to a
|
|
11
|
+
* budget ({@link gatherUiPictures}): pictures past it are listed as left out with the reason, never silently dropped, and the
|
|
12
|
+
* manifest stays complete.
|
|
13
|
+
*
|
|
14
|
+
* Everything in the page is data from a test run. Text is escaped; only PNG, JPEG, GIF and WebP bytes whose first bytes say so
|
|
15
|
+
* are embedded (never SVG, never what a layer merely claims); a page address is linked only when it is `http` or `https`; the
|
|
16
|
+
* page carries a Content-Security-Policy that allows no network request at all.
|
|
17
|
+
*
|
|
18
|
+
* @see docs/ui-catalog.md
|
|
19
|
+
*/
|
|
20
|
+
import { type UiCapture, type UiCatalog } from './ui-catalog.js';
|
|
21
|
+
/** A picture ready to embed. */
|
|
22
|
+
export type UiExportPicture = {
|
|
23
|
+
readonly contentType: string;
|
|
24
|
+
readonly data: Uint8Array;
|
|
25
|
+
};
|
|
26
|
+
/** What {@link uiCatalogHtml} may be given besides the catalog. */
|
|
27
|
+
export type UiExportOptions = {
|
|
28
|
+
/** The page's title. Default `UI catalog`. */
|
|
29
|
+
readonly title?: string;
|
|
30
|
+
/** The pictures to embed, by {@link uiPictureKey}. A cell whose key is not here shows why it has no picture. */
|
|
31
|
+
readonly pictures?: ReadonlyMap<string, UiExportPicture>;
|
|
32
|
+
/** Why a picture that could not be embedded is not here, by {@link uiPictureKey}: shown in its place. */
|
|
33
|
+
readonly left?: ReadonlyMap<string, string>;
|
|
34
|
+
/** When the export was made, epoch milliseconds. It is stated on the page so nobody mistakes it for a live view. Default: now. */
|
|
35
|
+
readonly generatedAtMs?: number;
|
|
36
|
+
};
|
|
37
|
+
/** The key of a capture's picture in {@link UiExportOptions.pictures}: its layer and its bundle key, or `null` when the picture is not stored in a layer. */
|
|
38
|
+
export declare function uiPictureKey(capture: Pick<UiCapture, 'origin' | 'image'> | null | undefined): string | null;
|
|
39
|
+
/** One picture an export may embed. */
|
|
40
|
+
export type UiPictureWant = {
|
|
41
|
+
readonly key: string;
|
|
42
|
+
readonly layer: string;
|
|
43
|
+
readonly backend: string;
|
|
44
|
+
readonly bundleKey: string;
|
|
45
|
+
readonly bytes?: number;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The pictures of a catalog an export would embed, once each, in the order the page shows them. Only a cell that was captured, or
|
|
49
|
+
* that failed (a picture of what the page showed), and whose picture is stored in a layer, has one.
|
|
50
|
+
*/
|
|
51
|
+
export declare function uiPicturesWanted(catalog: UiCatalog): UiPictureWant[];
|
|
52
|
+
/** The media type a picture's first bytes say it has (`image/png`, `image/jpeg`, `image/gif`, `image/webp`), or `null` for anything else, SVG included. */
|
|
53
|
+
export declare function sniffImage(data: Uint8Array): string | null;
|
|
54
|
+
/** What {@link gatherUiPictures} found. */
|
|
55
|
+
export type UiPicturesGathered = {
|
|
56
|
+
readonly pictures: ReadonlyMap<string, UiExportPicture>;
|
|
57
|
+
/** Why each picture that is not in `pictures` is not. */
|
|
58
|
+
readonly left: ReadonlyMap<string, string>;
|
|
59
|
+
/** Bytes embedded. */
|
|
60
|
+
readonly bytes: number;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Reads the pictures an export wants through `read` (which returns the stored bytes of a picture, or `null` when the store
|
|
64
|
+
* no longer holds it) and keeps those that fit in `maxBytes` in total. A picture that is not stored, is not a PNG, JPEG, GIF or
|
|
65
|
+
* WebP, cannot be read, or would pass the budget is recorded in `left` with the reason; the pictures are taken in the page's
|
|
66
|
+
* order, so which are left out is deterministic. A `read` that throws is a picture left out, never a failed export.
|
|
67
|
+
*/
|
|
68
|
+
export declare function gatherUiPictures(wanted: readonly UiPictureWant[], read: (want: UiPictureWant) => Promise<Uint8Array | null>, options: {
|
|
69
|
+
readonly maxBytes: number;
|
|
70
|
+
}): Promise<UiPicturesGathered>;
|
|
71
|
+
/**
|
|
72
|
+
* The whole catalog as one self-contained HTML document. See the module description for what it contains and how it treats
|
|
73
|
+
* the data in it. Deterministic for the same catalog, pictures and `generatedAtMs`.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const wanted = uiPicturesWanted(catalog);
|
|
78
|
+
* const { pictures, left } = await gatherUiPictures(wanted, want => readPicture(want), { maxBytes: 32 * 1024 * 1024 });
|
|
79
|
+
* await writeFile('catalog.html', uiCatalogHtml(catalog, { pictures, left }));
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function uiCatalogHtml(catalog: UiCatalog, options?: UiExportOptions): string;
|