@crvouga/atlas 1.21.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 +9 -1
- package/dist/core/src/cli.d.ts +2 -2
- package/dist/core/src/cli.js +4 -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/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-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +12 -0
- package/dist/reports/src/ui-catalog-cli.d.ts +1 -1
- package/dist/reports/src/ui-catalog-cli.js +3 -3
- 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-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 +674 -1
- package/dist/schema/src/cli-results.js +177 -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 +11 -2
- package/docs/critics.md +273 -9
- package/docs/extending.md +1 -1
- package/docs/reference/cli.md +73 -3
- 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 +463 -0
- package/docs/reference/schema.md +157 -1
- package/docs/report-contract.md +2 -1
- package/docs/server.md +2 -0
- package/docs/ui-catalog.md +4 -1
- package/docs/ui.md +63 -1
- package/llms-full.txt +469 -18
- 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/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/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/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,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
|
+
}
|
|
@@ -9,8 +9,8 @@ function common(origin, kind, title) {
|
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
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 } : {}) };
|
|
12
|
+
const { route, fixture, cohort, area, kind, flags, covers, inventory } = source;
|
|
13
|
+
return { ...(route ? { route } : {}), ...(fixture ? { fixture } : {}), ...(cohort ? { cohort } : {}), ...(area ? { area } : {}), ...(kind ? { kind } : {}), ...(flags?.length ? { flags } : {}), ...(covers?.length ? { covers } : {}), ...(inventory ? { inventory } : {}) };
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
16
|
* The input of `publishChecks` for the states one collector (or one shard) captured under one profile. Throws when a state id
|
|
@@ -92,6 +92,8 @@ export type UiCatalogState = {
|
|
|
92
92
|
readonly fixture: string | null;
|
|
93
93
|
readonly cohort: string | null;
|
|
94
94
|
readonly area: string | null;
|
|
95
|
+
/** The kind of UI state the producers declared (`screen`, `overlay`, `loading`…), or null when none did. */
|
|
96
|
+
readonly kind: string | null;
|
|
95
97
|
readonly flags: readonly string[];
|
|
96
98
|
/** The surfaces it covers. */
|
|
97
99
|
readonly covers: readonly string[];
|
|
@@ -119,7 +119,7 @@ export function uiCatalog(layers, options = {}) {
|
|
|
119
119
|
});
|
|
120
120
|
catalogStates.push({
|
|
121
121
|
id, title: ordered[0]?.row.title ?? id,
|
|
122
|
-
route: scalar(result => result.row.state?.route), fixture: scalar(result => result.row.state?.fixture), cohort: scalar(result => result.row.state?.cohort), area: scalar(result => result.row.state?.area),
|
|
122
|
+
route: scalar(result => result.row.state?.route), fixture: scalar(result => result.row.state?.fixture), cohort: scalar(result => result.row.state?.cohort), area: scalar(result => result.row.state?.area), kind: scalar(result => result.row.state?.kind),
|
|
123
123
|
flags: union(result => result.row.state?.flags), covers: union(result => result.row.state?.covers),
|
|
124
124
|
producers: [...new Set(ordered.map(result => result.layer.producer))].sort(compare), cells
|
|
125
125
|
});
|
|
@@ -50,6 +50,8 @@ export type UiStateFacts = {
|
|
|
50
50
|
readonly covers?: readonly string[];
|
|
51
51
|
/** A grouping a person navigates by (`checkout`). */
|
|
52
52
|
readonly area?: string;
|
|
53
|
+
/** The kind of UI state: `screen`, `overlay`, `loading`, `error`, `empty`, or any other word. Open vocabulary. Critics judge a state against others of its kind, so declare it; when it is absent the kind is unknown. */
|
|
54
|
+
readonly kind?: string;
|
|
53
55
|
/** On a `ui-plan` check only: this entry is a surface of the product's inventory, not a state. Nothing is captured for it. */
|
|
54
56
|
readonly inventory?: boolean;
|
|
55
57
|
};
|
|
@@ -45,10 +45,10 @@ export function uiStateFacts(check) {
|
|
|
45
45
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
46
46
|
return {};
|
|
47
47
|
const given = raw;
|
|
48
|
-
const route = text(given.route), fixture = text(given.fixture), cohort = text(given.cohort), area = text(given.area);
|
|
48
|
+
const route = text(given.route), fixture = text(given.fixture), cohort = text(given.cohort), area = text(given.area), kind = text(given.kind);
|
|
49
49
|
const flags = list(given.flags), covers = list(given.covers);
|
|
50
50
|
return {
|
|
51
|
-
...(route ? { route } : {}), ...(fixture ? { fixture } : {}), ...(cohort ? { cohort } : {}), ...(area ? { area } : {}),
|
|
51
|
+
...(route ? { route } : {}), ...(fixture ? { fixture } : {}), ...(cohort ? { cohort } : {}), ...(area ? { area } : {}), ...(kind ? { kind } : {}),
|
|
52
52
|
...(flags ? { flags } : {}), ...(covers ? { covers } : {}),
|
|
53
53
|
...(given.inventory === true ? { inventory: true } : {})
|
|
54
54
|
};
|