@crvouga/atlas 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -1
- package/dist/critics/src/contract.d.ts +113 -0
- package/dist/critics/src/contract.js +48 -0
- package/dist/critics/src/credentials.d.ts +72 -0
- package/dist/critics/src/credentials.js +136 -0
- package/dist/critics/src/critique.d.ts +128 -0
- package/dist/critics/src/critique.js +163 -0
- package/dist/critics/src/index.d.ts +21 -0
- package/dist/critics/src/index.js +21 -0
- package/dist/critics/src/judging.d.ts +153 -0
- package/dist/critics/src/judging.js +145 -0
- package/dist/critics/src/publish.d.ts +23 -0
- package/dist/critics/src/publish.js +21 -0
- package/dist/critics/src/runner.d.ts +104 -0
- package/dist/critics/src/runner.js +202 -0
- package/dist/critics/src/scope.d.ts +8 -0
- package/dist/critics/src/scope.js +26 -0
- package/dist/playwright/src/index.d.ts +4 -0
- package/dist/playwright/src/index.js +4 -0
- package/dist/playwright/src/ui-catalog.d.ts +51 -0
- package/dist/playwright/src/ui-catalog.js +44 -0
- package/dist/reports/src/compose.d.ts +2 -2
- package/dist/reports/src/compose.js +6 -5
- package/dist/reports/src/evidence-types.d.ts +3 -0
- package/dist/reports/src/evidence-view.d.ts +2 -1
- package/dist/reports/src/evidence-view.js +5 -2
- package/dist/reports/src/layer-merge.d.ts +16 -4
- package/dist/reports/src/layer-merge.js +23 -5
- package/dist/reports/src/report-index.js +9 -5
- package/dist/reports/src/report-status.d.ts +1 -1
- package/dist/reports/src/report-status.js +6 -4
- package/dist/reports/src/ui-catalog-publish.d.ts +92 -0
- package/dist/reports/src/ui-catalog-publish.js +68 -0
- package/dist/reports/src/ui-catalog.d.ts +229 -0
- package/dist/reports/src/ui-catalog.js +273 -0
- package/dist/reports/src/ui-state.d.ts +70 -0
- package/dist/reports/src/ui-state.js +66 -0
- package/dist/schema/src/api.d.ts +351 -0
- package/dist/schema/src/api.js +72 -1
- package/dist/schema/src/critique.d.ts +144 -0
- package/dist/schema/src/critique.js +96 -0
- package/dist/schema/src/index.d.ts +2 -1
- package/dist/schema/src/index.js +2 -1
- package/dist/schema/src/server-config.d.ts +7 -1
- package/dist/schema/src/server-config.js +6 -3
- package/dist/server/src/app.js +15 -0
- package/dist/server/src/auth.d.ts +1 -1
- package/dist/server/src/auth.js +2 -2
- package/dist/server/src/config.js +2 -2
- package/dist/server/src/openapi.d.ts +3 -3
- package/dist/server/src/openapi.js +29 -6
- package/dist/server/src/store-api.js +39 -1
- package/docs/agents.md +1 -0
- package/docs/critics.md +190 -0
- package/docs/extending.md +1 -0
- package/docs/reference/critics.md +840 -0
- package/docs/reference/http-api.md +52 -2
- package/docs/reference/playwright.md +63 -0
- package/docs/reference/reports.md +608 -8
- package/docs/reference/schema.md +152 -2
- package/docs/reference/server.md +3 -3
- package/docs/report-contract.md +6 -2
- package/docs/rich-evidence.md +5 -0
- package/docs/server.md +8 -4
- package/docs/ui-catalog.md +173 -0
- package/docs/ui.md +15 -0
- package/llms-full.txt +457 -8
- package/llms.txt +3 -0
- package/package.json +8 -2
- package/packages/critics/README.md +18 -0
- package/packages/schema/schemas/critique.schema.json +254 -0
- package/packages/schema/schemas/effective-delta.schema.json +73 -0
- package/packages/schema/schemas/effective-evidence.schema.json +73 -0
- package/packages/schema/schemas/layer-rows.schema.json +73 -0
- package/packages/schema/schemas/server-config.schema.json +2 -1
- package/packages/schema/schemas/server-info.schema.json +1 -0
- package/packages/server/openapi.json +1443 -80
- package/packages/visualizer/src/app/AppShell.tsx +36 -30
- package/packages/visualizer/src/app/router.tsx +10 -1
- package/packages/visualizer/src/catalog/UiCatalogFilters.tsx +65 -0
- package/packages/visualizer/src/catalog/UiCatalogPage.tsx +342 -0
- package/packages/visualizer/src/catalog/provider.tsx +25 -0
- package/packages/visualizer/src/catalog/search.ts +68 -0
- package/packages/visualizer/src/catalog/words.ts +26 -0
- package/packages/visualizer/src/components/Header.tsx +17 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module The critic runner
|
|
3
|
+
*
|
|
4
|
+
* Runs one critic over entries of the UI catalog and returns the critiques to publish. The runner, not the critic, owns
|
|
5
|
+
* everything around the judgement: skipping what was already judged (same critic, version, model, parameters and image
|
|
6
|
+
* hash), fetching images only when needed, a bounded number of concurrent calls, a per-call timeout, retries with
|
|
7
|
+
* jittered backoff that honour a provider's `Retry-After`, budgets (images, tokens, money) that stop the run cleanly,
|
|
8
|
+
* and credential redaction of every message it records. Failures are data: a crash, a timeout, a rate limit that never
|
|
9
|
+
* cleared and an unparseable answer each become a critique with a `failure` and a reason code, which the next run
|
|
10
|
+
* retries, and never a score.
|
|
11
|
+
*
|
|
12
|
+
* Results are returned in the order of the entries, so the same inputs publish the same layer. Nothing here reads a
|
|
13
|
+
* clock, the environment or the network on its own: images, time, sleeping and randomness are passed in.
|
|
14
|
+
*
|
|
15
|
+
* @see docs/critics.md#running-critics
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod/v4';
|
|
18
|
+
import { sha256Hex } from '../../reports/src/sigv4.js';
|
|
19
|
+
import { CriticError, InvalidOutputError, RateLimitedError } from './contract.js';
|
|
20
|
+
import { buildCritique, critiqueIdentity, identityOfCritique } from './critique.js';
|
|
21
|
+
import { createRedactor } from './credentials.js';
|
|
22
|
+
/** The shape a critic's answer must have. A score that is not a finite number, or a finding with a bad region, is `invalid-output`. */
|
|
23
|
+
const AnswerSchema = z.object({
|
|
24
|
+
verdict: z.looseObject({ score: z.number().finite().optional(), confidence: z.number().min(0).max(1).optional() }).optional(),
|
|
25
|
+
findings: z.array(z.looseObject({ confidence: z.number().min(0).max(1).optional() })).optional(),
|
|
26
|
+
usage: z.looseObject({ inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional(), latencyMs: z.number().nonnegative().optional(), costUsd: z.number().nonnegative().optional() }).optional()
|
|
27
|
+
}).refine(answer => answer.verdict !== undefined || (answer.findings !== undefined && answer.findings.length > 0), 'the answer has neither a verdict nor findings');
|
|
28
|
+
const defaultSleep = (ms, signal) => new Promise(resolve => {
|
|
29
|
+
const timer = setTimeout(done, ms);
|
|
30
|
+
function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }
|
|
31
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
32
|
+
});
|
|
33
|
+
const sha256Bytes = (bytes) => sha256Hex(bytes);
|
|
34
|
+
/** Races a critic's call against a timeout and the run's cancellation. Rejects with `CriticError('timeout')` on timeout. */
|
|
35
|
+
async function withTimeout(work, timeoutMs, outer) {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const onOuter = () => controller.abort(outer?.reason);
|
|
38
|
+
if (outer?.aborted)
|
|
39
|
+
controller.abort(outer.reason);
|
|
40
|
+
else
|
|
41
|
+
outer?.addEventListener('abort', onOuter, { once: true });
|
|
42
|
+
let timer;
|
|
43
|
+
try {
|
|
44
|
+
return await Promise.race([
|
|
45
|
+
work(controller.signal),
|
|
46
|
+
new Promise((_, reject) => {
|
|
47
|
+
// Settle first: aborting may make the critic's own call reject at once, and the reason must be the timeout.
|
|
48
|
+
timer = setTimeout(() => { reject(new CriticError('timeout', `The critic did not answer within ${timeoutMs} ms`)); controller.abort(new Error('timeout')); }, timeoutMs);
|
|
49
|
+
})
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
if (timer !== undefined)
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
outer?.removeEventListener('abort', onOuter);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Collects a sync or async iterable, keeping the first of each key. */
|
|
59
|
+
async function collect(entries) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const out = [];
|
|
62
|
+
for await (const entry of entries) {
|
|
63
|
+
if (seen.has(entry.key))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(entry.key);
|
|
66
|
+
out.push(entry);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs a critic over entries. Entries with a current critique of the same identity are skipped without fetching their
|
|
72
|
+
* image; the rest are judged with bounded concurrency, a timeout and retries. The run never throws for a critic's
|
|
73
|
+
* failure: it records it. It throws only for a wrong call (a non-positive `concurrency`, `maxAttempts` or `timeoutMs`).
|
|
74
|
+
*
|
|
75
|
+
* A run stops cleanly when a budget is reached or the signal aborts: calls in flight finish (or are aborted), what was
|
|
76
|
+
* finished is returned in `outcomes` to publish, and `stoppedBy` says why. With concurrency above 1 a budget can be
|
|
77
|
+
* overshot by the calls already in flight.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const result = await runCritic({ critic, entries, loadImage: entry => fetchImage(entry), existing, budget: { maxImages: 200 } });
|
|
81
|
+
* await publishChecks(store, critiqueLayer({ critic, outcomes: result.outcomes, recordedAtMs: Date.now() }));
|
|
82
|
+
*/
|
|
83
|
+
export async function runCritic(options) {
|
|
84
|
+
const concurrency = options.concurrency ?? 4;
|
|
85
|
+
const maxAttempts = options.maxAttempts ?? 4;
|
|
86
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
87
|
+
for (const [name, value] of [['concurrency', concurrency], ['maxAttempts', maxAttempts], ['timeoutMs', timeoutMs]]) {
|
|
88
|
+
if (!Number.isFinite(value) || value < 1)
|
|
89
|
+
throw new Error(`Invalid ${name}: ${value}`);
|
|
90
|
+
}
|
|
91
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
92
|
+
const random = options.random ?? Math.random;
|
|
93
|
+
const redact = createRedactor(options.secrets ?? []);
|
|
94
|
+
const { critic } = options;
|
|
95
|
+
const description = critic.describe();
|
|
96
|
+
const entries = await collect(options.entries);
|
|
97
|
+
const known = new Set();
|
|
98
|
+
if (!options.force)
|
|
99
|
+
for (const critique of options.existing ?? [])
|
|
100
|
+
if (critique.verdict !== undefined && !critique.failure)
|
|
101
|
+
known.add(identityOfCritique(critique));
|
|
102
|
+
const isKnown = (entry) => known.has(critiqueIdentity({ critic, model: description.model, parameters: description.parameters, imageSha256: entry.imageSha256 }));
|
|
103
|
+
const slots = entries.map(entry => (isKnown(entry) ? 'skipped' : undefined));
|
|
104
|
+
const identity = { name: critic.name, version: critic.version };
|
|
105
|
+
const toJudge = slots.filter(slot => slot === undefined).length;
|
|
106
|
+
if (options.dryRun) {
|
|
107
|
+
return { critic: identity, sendsImagesTo: description.sendsImagesTo, outcomes: [], skipped: entries.length - toJudge, judged: 0, failed: 0, waiting: toJudge, spent: { images: 0, tokens: 0, costUsd: 0 }, dryRun: true };
|
|
108
|
+
}
|
|
109
|
+
const budget = options.budget ?? {};
|
|
110
|
+
const spent = { images: 0, tokens: 0, costUsd: 0 };
|
|
111
|
+
let judged = 0, failed = 0, skipped = entries.length - toJudge, stoppedBy;
|
|
112
|
+
const exhausted = () => (budget.maxImages !== undefined && spent.images >= budget.maxImages) ||
|
|
113
|
+
(budget.maxTokens !== undefined && spent.tokens >= budget.maxTokens) ||
|
|
114
|
+
(budget.maxCostUsd !== undefined && spent.costUsd >= budget.maxCostUsd);
|
|
115
|
+
const progress = (entry, result) => options.onProgress?.({ entryKey: entry.key, result, judged, skipped, failed, waiting: slots.filter(slot => slot === undefined).length - inFlight });
|
|
116
|
+
let inFlight = 0;
|
|
117
|
+
const fail = (entry, reason, message) => ({
|
|
118
|
+
entry,
|
|
119
|
+
critique: buildCritique(critic, description, entry, { failure: { reason, message: redact(message).slice(0, 500) } })
|
|
120
|
+
});
|
|
121
|
+
/** Judges one entry; `null` when the run was cancelled meanwhile, because a cancelled call is not a failure of the critic. */
|
|
122
|
+
async function judge(entry) {
|
|
123
|
+
let image;
|
|
124
|
+
try {
|
|
125
|
+
image = await options.loadImage(entry);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return fail(entry, 'image-unavailable', error instanceof Error ? error.message : String(error));
|
|
129
|
+
}
|
|
130
|
+
if ((await sha256Bytes(image)) !== entry.imageSha256)
|
|
131
|
+
return fail(entry, 'image-mismatch', 'The image bytes are not the ones the catalog hashed; nothing was judged');
|
|
132
|
+
for (let attempt = 1;; attempt++) {
|
|
133
|
+
if (options.signal?.aborted)
|
|
134
|
+
return null;
|
|
135
|
+
try {
|
|
136
|
+
const answer = await withTimeout(signal => critic.evaluate({ image, imageSha256: entry.imageSha256, entry: { key: entry.key, title: entry.title, state: entry.state, ...(entry.route === undefined ? {} : { route: entry.route }), ...(entry.profile === undefined ? {} : { profile: entry.profile }) }, signal }), timeoutMs, options.signal);
|
|
137
|
+
const checked = AnswerSchema.safeParse(answer);
|
|
138
|
+
if (!checked.success)
|
|
139
|
+
throw new InvalidOutputError(`The answer is not a critique: ${checked.error.issues[0]?.message ?? 'unknown'}`);
|
|
140
|
+
const usage = answer.usage;
|
|
141
|
+
spent.tokens += (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
|
|
142
|
+
spent.costUsd += usage?.costUsd ?? 0;
|
|
143
|
+
return { entry, critique: buildCritique(critic, description, entry, answer) };
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (options.signal?.aborted)
|
|
147
|
+
return null;
|
|
148
|
+
if (error instanceof RateLimitedError && attempt < maxAttempts && !options.signal?.aborted) {
|
|
149
|
+
const base = options.backoff?.baseMs ?? 500, cap = options.backoff?.maxMs ?? 30_000;
|
|
150
|
+
const jittered = Math.floor(random() * Math.min(cap, base * 2 ** (attempt - 1)));
|
|
151
|
+
await sleep(Math.max(error.retryAfterMs ?? 0, jittered), options.signal);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (error instanceof CriticError)
|
|
155
|
+
return fail(entry, error.reason, error.message);
|
|
156
|
+
return fail(entry, 'error', error instanceof Error ? error.message : String(error));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
let next = 0;
|
|
161
|
+
async function worker() {
|
|
162
|
+
for (;;) {
|
|
163
|
+
if (options.signal?.aborted) {
|
|
164
|
+
stoppedBy ??= 'aborted';
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (exhausted()) {
|
|
168
|
+
stoppedBy ??= 'budget';
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let index = next;
|
|
172
|
+
while (index < entries.length && slots[index] !== undefined)
|
|
173
|
+
index++;
|
|
174
|
+
if (index >= entries.length)
|
|
175
|
+
return;
|
|
176
|
+
next = index + 1;
|
|
177
|
+
const entry = entries[index];
|
|
178
|
+
inFlight += 1;
|
|
179
|
+
spent.images += 1;
|
|
180
|
+
const outcome = await judge(entry);
|
|
181
|
+
inFlight -= 1;
|
|
182
|
+
if (!outcome) {
|
|
183
|
+
stoppedBy ??= 'aborted';
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
slots[index] = outcome;
|
|
187
|
+
if (outcome.critique.failure)
|
|
188
|
+
failed += 1;
|
|
189
|
+
else
|
|
190
|
+
judged += 1;
|
|
191
|
+
progress(entry, outcome.critique.failure ? 'failed' : 'judged');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
for (let index = 0; index < entries.length; index++)
|
|
195
|
+
if (slots[index] === 'skipped')
|
|
196
|
+
progress(entries[index], 'skipped');
|
|
197
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(toJudge, 1)) }, worker));
|
|
198
|
+
// A run that handled every entry did not stop early, whatever the budget now says.
|
|
199
|
+
const waiting = slots.filter(slot => slot === undefined).length;
|
|
200
|
+
const outcomes = slots.filter((slot) => slot !== undefined && slot !== 'skipped');
|
|
201
|
+
return { critic: identity, sendsImagesTo: description.sendsImagesTo, outcomes, skipped, judged, failed, waiting, ...(waiting > 0 && stoppedBy ? { stoppedBy } : {}), spent, dryRun: false };
|
|
202
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule a layer breaks for a critic-scoped credential, or `null` when it is within scope. The message says which rule
|
|
3
|
+
* and what was found, so the critic can fix it.
|
|
4
|
+
*/
|
|
5
|
+
export declare function criticScopeViolation(layer: {
|
|
6
|
+
readonly producer?: unknown;
|
|
7
|
+
readonly parts?: unknown;
|
|
8
|
+
}): string | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module The scope of a critic's credential
|
|
3
|
+
*
|
|
4
|
+
* A critic only needs to add opinions, so its credential may read and publish layers whose producer is `critic.<name>`
|
|
5
|
+
* and whose parts are all of kind `critique`, and nothing else. `criticScopeViolation` names the rule a layer breaks;
|
|
6
|
+
* the server applies it to every layer a critic-role token uploads, so a stolen critic token cannot forge test results.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/critics.md#authentication-and-authorization
|
|
9
|
+
*/
|
|
10
|
+
import { CRITIC_PRODUCER_PREFIX, CRITIQUE_PART_KIND } from '../../schema/src/critique.js';
|
|
11
|
+
/**
|
|
12
|
+
* The rule a layer breaks for a critic-scoped credential, or `null` when it is within scope. The message says which rule
|
|
13
|
+
* and what was found, so the critic can fix it.
|
|
14
|
+
*/
|
|
15
|
+
export function criticScopeViolation(layer) {
|
|
16
|
+
if (typeof layer.producer !== 'string' || !layer.producer.startsWith(CRITIC_PRODUCER_PREFIX) || layer.producer.length === CRITIC_PRODUCER_PREFIX.length) {
|
|
17
|
+
return `A critic token may publish only layers whose producer is "${CRITIC_PRODUCER_PREFIX}<name>"; this layer's producer is ${JSON.stringify(layer.producer ?? null)}`;
|
|
18
|
+
}
|
|
19
|
+
const parts = Array.isArray(layer.parts) ? layer.parts : [];
|
|
20
|
+
if (!parts.length)
|
|
21
|
+
return `A critic token may publish only layers of parts of kind "${CRITIQUE_PART_KIND}"; this layer has no parts`;
|
|
22
|
+
const other = parts.find(part => part?.kind !== CRITIQUE_PART_KIND);
|
|
23
|
+
if (other)
|
|
24
|
+
return `A critic token may publish only parts of kind "${CRITIQUE_PART_KIND}"; this layer has a part of kind ${JSON.stringify(other?.kind ?? null)}`;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -50,10 +50,10 @@ export type CombinedReport = {
|
|
|
50
50
|
readonly summary: ReportSummary;
|
|
51
51
|
};
|
|
52
52
|
/**
|
|
53
|
-
* Counts results by status. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
|
|
53
|
+
* Counts results by status. Opinion results (parts of kind `critique`) are never counted. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
|
|
54
54
|
* whenever any other result is present; if only diagnostics exist, they are counted so the report is not empty.
|
|
55
55
|
*/
|
|
56
|
-
export declare function countResults(
|
|
56
|
+
export declare function countResults(all: readonly EvidenceResult[]): StatusCounts & {
|
|
57
57
|
total: number;
|
|
58
58
|
};
|
|
59
59
|
/** Counts a combined report's winning results in total and per environment, and lists the specs that have no result at all. */
|
|
@@ -11,14 +11,15 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { eachLimited } from './completion-order.js';
|
|
13
13
|
import { catalogEntries, catalogLayers } from './layer-catalog.js';
|
|
14
|
-
import { combineEvidence, distinctHeaders, emptyCounts, isDiagnosticKind, layerEvidence } from './layer-merge.js';
|
|
14
|
+
import { combineEvidence, distinctHeaders, emptyCounts, isDiagnosticKind, isOpinionKind, layerEvidence } from './layer-merge.js';
|
|
15
15
|
import { RecordedSpecs } from './recorded-specs.js';
|
|
16
16
|
const BACKEND_ID = /^[a-z0-9-]+$/;
|
|
17
17
|
/**
|
|
18
|
-
* Counts results by status. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
|
|
18
|
+
* Counts results by status. Opinion results (parts of kind `critique`) are never counted. Diagnostic results (parts of kind `performance`, which only qualify a check) are left out
|
|
19
19
|
* whenever any other result is present; if only diagnostics exist, they are counted so the report is not empty.
|
|
20
20
|
*/
|
|
21
|
-
export function countResults(
|
|
21
|
+
export function countResults(all) {
|
|
22
|
+
const results = all.filter(result => !isOpinionKind(result.row.kind));
|
|
22
23
|
const outcomes = results.some(result => !isDiagnosticKind(result.row.kind)) ? results.filter(result => !isDiagnosticKind(result.row.kind)) : results;
|
|
23
24
|
const counts = emptyCounts();
|
|
24
25
|
for (const result of outcomes)
|
|
@@ -27,7 +28,7 @@ export function countResults(results) {
|
|
|
27
28
|
}
|
|
28
29
|
/** Counts a combined report's winning results in total and per environment, and lists the specs that have no result at all. */
|
|
29
30
|
export function summarize(combined) {
|
|
30
|
-
const results = [...combined.specs.flatMap(entry => entry.results), ...combined.unassigned];
|
|
31
|
+
const results = [...combined.specs.flatMap(entry => entry.results), ...combined.unassigned].filter(result => !isOpinionKind(result.row.kind));
|
|
31
32
|
const environments = new Map();
|
|
32
33
|
for (const result of results) {
|
|
33
34
|
const key = result.layer.environment ?? 'unspecified';
|
|
@@ -40,7 +41,7 @@ export function summarize(combined) {
|
|
|
40
41
|
return {
|
|
41
42
|
checks: countResults(results),
|
|
42
43
|
byEnvironment: Object.fromEntries([...environments].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([key, value]) => [key, countResults(value)])),
|
|
43
|
-
specsWithoutEvidence: combined.specs.filter(entry => entry.results.
|
|
44
|
+
specsWithoutEvidence: combined.specs.filter(entry => entry.results.every(result => isOpinionKind(result.row.kind))).map(entry => entry.spec.path)
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { EvidenceMeasurement, EvidenceStatus } from './explorer-types.js';
|
|
12
12
|
import type { EvidenceScope, LayerRun } from '../../schema/src/layer.js';
|
|
13
13
|
import type { LayerParameters } from './layer-types.js';
|
|
14
|
+
import type { UiStateFact } from './ui-state.js';
|
|
14
15
|
/** Where a recorded test's timeline and captured files are read from. */
|
|
15
16
|
export type RecordingAddress = {
|
|
16
17
|
readonly type: 'layer';
|
|
@@ -168,6 +169,8 @@ export type LayerRow = {
|
|
|
168
169
|
readonly durationMs: number | null;
|
|
169
170
|
/** What this result carries beyond its outcome. Absent when nothing. */
|
|
170
171
|
readonly evidence?: EvidenceSummary;
|
|
172
|
+
/** For a check of a `ui-state` or `ui-plan` part: its picture and declared facts, so a catalog is listed without another request (see `ui-state.ts`). Absent for every other kind. */
|
|
173
|
+
readonly state?: UiStateFact;
|
|
171
174
|
/** What became of this check's expected recording: `uploaded`, `referenced`, `skipped`, `unresolved` or `not-applicable`, with a reason when it is not uploaded. Absent when the check declared no expectation. Independent of `status`. */
|
|
172
175
|
readonly recording?: {
|
|
173
176
|
readonly state: string;
|
|
@@ -32,7 +32,8 @@ export type CheckSlot = {
|
|
|
32
32
|
/**
|
|
33
33
|
* Groups the combined results (the winners of the slots) by check, in the order the checks were first seen.
|
|
34
34
|
* A check's status is the worst of its non-diagnostic results; if it has only diagnostics (timings), those are its
|
|
35
|
-
* outcomes. The title and id shown are the newest result's.
|
|
35
|
+
* outcomes. The title and id shown are the newest result's. Opinions (parts of kind `critique`, see `isOpinionKind`) are
|
|
36
|
+
* not checks and are left out.
|
|
36
37
|
*/
|
|
37
38
|
export declare function checkSlots(results: readonly EvidenceResult[]): CheckSlot[];
|
|
38
39
|
/** A spec's rolled-up status: the worst of its checks, or `none` when nothing was recorded for it. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { emptyCounts, isDiagnosticKind, slotKey } from './layer-merge.js';
|
|
1
|
+
import { emptyCounts, isDiagnosticKind, isOpinionKind, slotKey } from './layer-merge.js';
|
|
2
2
|
const WORST = { passed: 0, 'not-run': 1, running: 2, failed: 3 };
|
|
3
3
|
function worst(statuses) {
|
|
4
4
|
let status = 'passed';
|
|
@@ -13,11 +13,14 @@ function newestFirst(a, b) {
|
|
|
13
13
|
/**
|
|
14
14
|
* Groups the combined results (the winners of the slots) by check, in the order the checks were first seen.
|
|
15
15
|
* A check's status is the worst of its non-diagnostic results; if it has only diagnostics (timings), those are its
|
|
16
|
-
* outcomes. The title and id shown are the newest result's.
|
|
16
|
+
* outcomes. The title and id shown are the newest result's. Opinions (parts of kind `critique`, see `isOpinionKind`) are
|
|
17
|
+
* not checks and are left out.
|
|
17
18
|
*/
|
|
18
19
|
export function checkSlots(results) {
|
|
19
20
|
const groups = new Map();
|
|
20
21
|
for (const result of results) {
|
|
22
|
+
if (isOpinionKind(result.row.kind))
|
|
23
|
+
continue;
|
|
21
24
|
const group = groups.get(result.slot);
|
|
22
25
|
if (group)
|
|
23
26
|
group.push(result);
|
|
@@ -34,15 +34,27 @@ 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
|
-
/**
|
|
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;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a part `kind` records an opinion rather than an outcome (today only `critique`, an automated reviewer's
|
|
45
|
+
* judgement of a captured UI). An opinion is never counted as a check, never gates, and is not folded into a spec's
|
|
46
|
+
* rollup, even when it is the only thing recorded: a report of opinions alone has no test results. The check's `status`
|
|
47
|
+
* of such a part says whether the reviewer ran, not whether anything passed.
|
|
48
|
+
*/
|
|
49
|
+
export declare function isOpinionKind(kind: string): boolean;
|
|
39
50
|
/** A fresh status tally with every status at zero. */
|
|
40
51
|
export declare function emptyCounts(): Record<EvidenceStatus, number>;
|
|
41
52
|
/**
|
|
42
|
-
* Counts rows by status, leaving out the
|
|
43
|
-
* When every row is diagnostic they are counted, so a
|
|
53
|
+
* Counts rows by status, leaving out the opinion rows (see `isOpinionKind`) always, and the diagnostic rows (see
|
|
54
|
+
* `isDiagnosticKind`) that only qualify an outcome. When every remaining row is diagnostic they are counted, so a
|
|
55
|
+
* layer of timings alone is not empty.
|
|
44
56
|
*/
|
|
45
|
-
export declare function outcomeCounts(
|
|
57
|
+
export declare function outcomeCounts(allRows: readonly LayerRow[]): StatusCounts;
|
|
46
58
|
/**
|
|
47
59
|
* Flattens a layer into what composition reads: a header (origin, normalised environment and parameters, counts,
|
|
48
60
|
* evidence summary), one row per check of every part, and the spec fingerprints it recorded.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { summarizeEvidenceIn, summarizeLayerEvidence } from './evidence-detail.js';
|
|
2
2
|
import { effectiveScope } from './evidence-scope.js';
|
|
3
3
|
import { layerPlace } from './layer-axes.js';
|
|
4
|
+
import { UI_PLAN_KIND, uiStateOf } from './ui-state.js';
|
|
4
5
|
/**
|
|
5
6
|
* A short, deterministic fingerprint of a spec's text, shaped `<length>:<hash>:<hash>` (the length in UTF-16 code
|
|
6
7
|
* units and two 32-bit hashes in hex). A layer records it per spec so a reader can warn when the spec changed
|
|
@@ -34,19 +35,34 @@ 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
|
-
/**
|
|
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;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether a part `kind` records an opinion rather than an outcome (today only `critique`, an automated reviewer's
|
|
48
|
+
* judgement of a captured UI). An opinion is never counted as a check, never gates, and is not folded into a spec's
|
|
49
|
+
* rollup, even when it is the only thing recorded: a report of opinions alone has no test results. The check's `status`
|
|
50
|
+
* of such a part says whether the reviewer ran, not whether anything passed.
|
|
51
|
+
*/
|
|
52
|
+
export function isOpinionKind(kind) {
|
|
53
|
+
return kind === 'critique';
|
|
40
54
|
}
|
|
41
55
|
/** A fresh status tally with every status at zero. */
|
|
42
56
|
export function emptyCounts() {
|
|
43
57
|
return { passed: 0, failed: 0, running: 0, 'not-run': 0 };
|
|
44
58
|
}
|
|
45
59
|
/**
|
|
46
|
-
* Counts rows by status, leaving out the
|
|
47
|
-
* When every row is diagnostic they are counted, so a
|
|
60
|
+
* Counts rows by status, leaving out the opinion rows (see `isOpinionKind`) always, and the diagnostic rows (see
|
|
61
|
+
* `isDiagnosticKind`) that only qualify an outcome. When every remaining row is diagnostic they are counted, so a
|
|
62
|
+
* layer of timings alone is not empty.
|
|
48
63
|
*/
|
|
49
|
-
export function outcomeCounts(
|
|
64
|
+
export function outcomeCounts(allRows) {
|
|
65
|
+
const rows = allRows.filter(row => !isOpinionKind(row.kind));
|
|
50
66
|
const outcomes = rows.some(row => !isDiagnosticKind(row.kind)) ? rows.filter(row => !isDiagnosticKind(row.kind)) : rows;
|
|
51
67
|
const counts = emptyCounts();
|
|
52
68
|
for (const row of outcomes)
|
|
@@ -82,6 +98,7 @@ export function layerEvidence(summary, backend) {
|
|
|
82
98
|
const test = tests.get(check.id);
|
|
83
99
|
const evidence = summarizeEvidenceIn(effectiveScope(part.scope, summary.scope), check);
|
|
84
100
|
const item = expectations?.get(check.id);
|
|
101
|
+
const state = uiStateOf(part.kind, check);
|
|
85
102
|
rows.push({
|
|
86
103
|
kind: part.kind,
|
|
87
104
|
id: check.id,
|
|
@@ -94,6 +111,7 @@ export function layerEvidence(summary, backend) {
|
|
|
94
111
|
variant: test ? { system: test.system, runtime: test.runtime, seed: test.seed } : {},
|
|
95
112
|
durationMs: test?.durationMs ?? check.durationMs ?? null,
|
|
96
113
|
...(evidence ? { evidence } : {}),
|
|
114
|
+
...(state ? { state } : {}),
|
|
97
115
|
...(item ? { recording: { state: item.state, ...(item.reason ? { reason: item.reason } : {}) } } : {}),
|
|
98
116
|
});
|
|
99
117
|
}
|
|
@@ -142,13 +142,15 @@ export class ReportIndex {
|
|
|
142
142
|
}
|
|
143
143
|
async #refresh() {
|
|
144
144
|
const before = this.#rev;
|
|
145
|
-
const fresh = [];
|
|
146
145
|
// The whole catalog on the first pass and now and then; otherwise only its newest end, where new layers sort.
|
|
147
146
|
const full = this.#passes++ % this.#fullListEvery === 0;
|
|
147
|
+
// Each backend is listed and read on its own, so a slow or unreachable one never holds back the layers of the others: the
|
|
148
|
+
// report fills in as each answers. Oldest first or newest first gives the same report; newest first shows recent work soonest.
|
|
148
149
|
await Promise.all(this.#backends.map(async (backend) => {
|
|
150
|
+
const fresh = [];
|
|
149
151
|
try {
|
|
150
152
|
const consider = (key) => { if (!this.#folded.has(`${backend.id}|${key}`) && !this.#recentlyFailed(`${backend.id}|${key}`))
|
|
151
|
-
fresh.push(
|
|
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;
|
|
@@ -25,7 +25,7 @@ export type StatusOptions = {
|
|
|
25
25
|
* checks (or none completed), failed checks, running checks, specs without results (only with `requireAllSpecs`),
|
|
26
26
|
* each required environment with no checks (the name is normalised, so `iOS` means `ios`), then every notice of the
|
|
27
27
|
* report (an unreadable layer or backend). `failing` lists each failed or running winner, in a fixed order that does
|
|
28
|
-
* not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded.
|
|
28
|
+
* not depend on read order; diagnostic `performance` checks are left out unless nothing else was recorded, and `critique` opinions are always left out.
|
|
29
29
|
* `recordings` reports recording completeness of the same results apart from `ok`: an incomplete or unknown
|
|
30
30
|
* completeness never changes the gate (use `atlas report publish --require-recordings` to fail on a missing video).
|
|
31
31
|
*
|