@keboola/validate-ui 0.4.0 → 0.5.2

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.
@@ -0,0 +1,1308 @@
1
+ import { chromium } from "playwright";
2
+ import { createReadStream } from "node:fs";
3
+ import { readFile, stat, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:http";
5
+ import { extname, join, normalize, resolve, sep } from "node:path";
6
+ import axe from "axe-core";
7
+ import { z } from "zod";
8
+ import Anthropic from "@anthropic-ai/sdk";
9
+ import pixelmatch from "pixelmatch";
10
+ import { PNG } from "pngjs";
11
+ import { brands, checkInteractiveContrast } from "@keboola/brand-registry";
12
+ //#region src/prepare-screenshot.ts
13
+ const disableAnimations = async (page) => {
14
+ await page.addStyleTag({ content: `*, *::before, *::after {
15
+ animation-duration: 0s !important;
16
+ animation-delay: 0s !important;
17
+ transition-duration: 0s !important;
18
+ transition-delay: 0s !important;
19
+ caret-color: transparent !important;
20
+ }` });
21
+ };
22
+ const parkMouseOffElements = async (page) => {
23
+ await page.mouse.move(0, 0);
24
+ };
25
+ const waitForFonts = async (page) => {
26
+ await page.evaluate(() => document.fonts.ready.then(() => void 0));
27
+ };
28
+ /**
29
+ * Settle the page before a screenshot: freeze animations first (so parking the
30
+ * mouse can't trigger a transition), park the cursor off any element, and wait
31
+ * for web fonts. Deterministic output makes visual diffs meaningful.
32
+ */
33
+ const prepareForScreenshot = async (page) => {
34
+ await disableAnimations(page);
35
+ await parkMouseOffElements(page);
36
+ await waitForFonts(page);
37
+ };
38
+ //#endregion
39
+ //#region src/capture.ts
40
+ const DESKTOP_VIEWPORT = {
41
+ label: "desktop",
42
+ width: 1280,
43
+ height: 800
44
+ };
45
+ const MOBILE_VIEWPORT = {
46
+ label: "mobile",
47
+ width: 390,
48
+ height: 844
49
+ };
50
+ const DEFAULT_VIEWPORTS = [DESKTOP_VIEWPORT, MOBILE_VIEWPORT];
51
+ const DEFAULT_TIMEOUT_MS$1 = 3e4;
52
+ const attachRecorders = (page) => {
53
+ const consoleEntries = [];
54
+ const networkEntries = [];
55
+ page.on("console", (message) => {
56
+ consoleEntries.push({
57
+ type: message.type(),
58
+ text: message.text(),
59
+ location: message.location().url || void 0
60
+ });
61
+ });
62
+ page.on("pageerror", (error) => {
63
+ consoleEntries.push({
64
+ type: "pageerror",
65
+ text: error.message
66
+ });
67
+ });
68
+ page.on("requestfailed", (request) => {
69
+ networkEntries.push({
70
+ url: request.url(),
71
+ method: request.method(),
72
+ status: 0,
73
+ failure: request.failure()?.errorText
74
+ });
75
+ });
76
+ page.on("response", (response) => {
77
+ networkEntries.push({
78
+ url: response.url(),
79
+ method: response.request().method(),
80
+ status: response.status()
81
+ });
82
+ });
83
+ return {
84
+ console: consoleEntries,
85
+ network: networkEntries
86
+ };
87
+ };
88
+ /**
89
+ * Render `url` in an already-open page at `viewport` and capture ground truth.
90
+ * Recorders are attached before navigation so nothing is missed. The caller
91
+ * owns the page lifecycle — this is the building block {@link capture} and the
92
+ * orchestrator reuse.
93
+ */
94
+ const capturePage = async (page, url, viewport, timeoutMs = DEFAULT_TIMEOUT_MS$1) => {
95
+ const recorders = attachRecorders(page);
96
+ await page.setViewportSize({
97
+ width: viewport.width,
98
+ height: viewport.height
99
+ });
100
+ await page.goto(url, {
101
+ waitUntil: "networkidle",
102
+ timeout: timeoutMs
103
+ });
104
+ await prepareForScreenshot(page);
105
+ return {
106
+ url,
107
+ viewport,
108
+ screenshot: await page.screenshot({ fullPage: true }),
109
+ dom: await page.content(),
110
+ renderedText: (await page.evaluate(() => document.body.innerText)).trim(),
111
+ console: recorders.console,
112
+ network: recorders.network
113
+ };
114
+ };
115
+ /**
116
+ * Launch a headless browser (unless one is supplied), render `url` at each
117
+ * viewport in a fresh context, and return one artifact per viewport.
118
+ */
119
+ const capture = async (url, options = {}) => {
120
+ const viewports = options.viewports ?? DEFAULT_VIEWPORTS;
121
+ const browser = options.browser ?? await chromium.launch();
122
+ const ownsBrowser = !options.browser;
123
+ try {
124
+ const artifacts = [];
125
+ for (const viewport of viewports) {
126
+ const context = await browser.newContext({ viewport: {
127
+ width: viewport.width,
128
+ height: viewport.height
129
+ } });
130
+ const page = await context.newPage();
131
+ try {
132
+ artifacts.push(await capturePage(page, url, viewport, options.timeoutMs));
133
+ } finally {
134
+ await context.close();
135
+ }
136
+ }
137
+ return artifacts;
138
+ } finally {
139
+ if (ownsBrowser) await browser.close();
140
+ }
141
+ };
142
+ //#endregion
143
+ //#region src/serve.ts
144
+ const CONTENT_TYPES = {
145
+ ".html": "text/html; charset=utf-8",
146
+ ".js": "text/javascript; charset=utf-8",
147
+ ".css": "text/css; charset=utf-8",
148
+ ".json": "application/json; charset=utf-8",
149
+ ".svg": "image/svg+xml",
150
+ ".png": "image/png",
151
+ ".jpg": "image/jpeg",
152
+ ".woff2": "font/woff2",
153
+ ".woff": "font/woff"
154
+ };
155
+ /**
156
+ * Serve a built SPA directory (e.g. `apps/boilerplate/dist`) on an ephemeral
157
+ * port with client-side-routing fallback to `index.html`, so a validator can
158
+ * render any client route without a dev server. Node stdlib only — no runtime
159
+ * dependency added.
160
+ */
161
+ const serveStatic = async (rootDir) => {
162
+ const indexPath = join(rootDir, "index.html");
163
+ const rootResolved = resolve(rootDir);
164
+ const server = createServer((request, response) => {
165
+ const send = (filePath, status = 200) => {
166
+ response.writeHead(status, { "content-type": CONTENT_TYPES[extname(filePath)] ?? "application/octet-stream" });
167
+ const stream = createReadStream(filePath);
168
+ stream.on("error", (error) => {
169
+ console.error(`serveStatic: failed to read ${filePath}:`, error);
170
+ if (!response.headersSent) response.writeHead(500);
171
+ response.end();
172
+ });
173
+ stream.pipe(response);
174
+ };
175
+ const notFound = () => {
176
+ response.writeHead(404);
177
+ response.end();
178
+ };
179
+ let rawPath;
180
+ try {
181
+ rawPath = decodeURIComponent((request.url ?? "/").split("?")[0] ?? "/");
182
+ } catch {
183
+ response.writeHead(400);
184
+ response.end();
185
+ return;
186
+ }
187
+ const relative = normalize(rawPath).replace(/^(\.\.[/\\])+/, "");
188
+ const candidate = join(rootDir, relative);
189
+ const candidateResolved = resolve(candidate);
190
+ if (candidateResolved !== rootResolved && !candidateResolved.startsWith(rootResolved + sep)) {
191
+ notFound();
192
+ return;
193
+ }
194
+ stat(candidate).then((info) => {
195
+ send(info.isDirectory() ? indexPath : candidate);
196
+ }).catch(() => {
197
+ if (extname(candidate) === "") {
198
+ send(indexPath);
199
+ return;
200
+ }
201
+ notFound();
202
+ });
203
+ });
204
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
205
+ const address = server.address();
206
+ if (address === null || typeof address === "string") throw new Error("serveStatic: failed to bind an ephemeral port");
207
+ return {
208
+ url: `http://127.0.0.1:${address.port}`,
209
+ close: () => new Promise((resolve, reject) => {
210
+ server.close((error) => error ? reject(error) : resolve());
211
+ })
212
+ };
213
+ };
214
+ //#endregion
215
+ //#region src/axes/clipped-text.ts
216
+ /**
217
+ * Elements whose content is cut off with no way for the user to reveal it.
218
+ *
219
+ * This exists because a page can be free of console errors, axe violations and layout
220
+ * overflow while still showing the user the wrong thing. The case that prompted it: an
221
+ * editable financial grid whose cells were narrower than their own padding, so a value of
222
+ * `800` rendered as `8`. Every other axis scored it 100.
223
+ *
224
+ * Deliberate truncation is not a finding. Text clipped with `text-overflow: ellipsis`
225
+ * announces itself, and a scrollable box (`overflow: auto | scroll`) can be scrolled to
226
+ * the rest. Only silent loss is reported — `overflow: hidden | clip` with no ellipsis, and
227
+ * `<input>`s whose value is wider than the box that holds it (a single-line input is never
228
+ * scrollable by eye; the user has to select into it to discover the rest). A `<textarea>`
229
+ * is judged like any other box: it renders a real scrollbar when its content overflows
230
+ * horizontally, so it is only a finding once something hides the overflow.
231
+ */
232
+ /** Ignore sub-pixel and rounding noise — real clipping is wider than a stray pixel. */
233
+ const CLIP_TOLERANCE_PX = 2;
234
+ /** Cap the reported list so one systematically broken component can't flood the report. */
235
+ const MAX_REPORTED = 10;
236
+ /**
237
+ * Runs in the page. Kept self-contained (no imports, no outer-scope references) because it
238
+ * is serialised into the browser context.
239
+ */
240
+ /* c8 ignore start -- executes in the browser, not under node coverage */
241
+ const findClippedElements = ({ tolerance, max }) => {
242
+ const describe = (el) => {
243
+ const tag = el.tagName.toLowerCase();
244
+ const label = el.getAttribute("aria-label");
245
+ if (label) return `${tag}[aria-label="${label}"]`;
246
+ if (el.id) return `${tag}#${el.id}`;
247
+ const name = el.getAttribute("name");
248
+ if (name) return `${tag}[name="${name}"]`;
249
+ return `${tag}${typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}` : ""}`;
250
+ };
251
+ const results = [];
252
+ const reported = /* @__PURE__ */ new Set();
253
+ const hasReportedAncestor = (el) => {
254
+ let parent = el.parentElement;
255
+ while (parent) {
256
+ if (reported.has(parent)) return true;
257
+ parent = parent.parentElement;
258
+ }
259
+ return false;
260
+ };
261
+ for (const el of document.querySelectorAll("*")) {
262
+ if (results.length >= max) break;
263
+ if (hasReportedAncestor(el)) continue;
264
+ const style = window.getComputedStyle(el);
265
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue;
266
+ if (el.clientWidth <= 1 || el.clientHeight <= 1) continue;
267
+ const overflowX = style.overflowX;
268
+ const isFormValue = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;
269
+ const isSingleLineInput = el instanceof HTMLInputElement;
270
+ if (!isSingleLineInput && overflowX !== "hidden" && overflowX !== "clip") continue;
271
+ if (!isSingleLineInput && style.textOverflow === "ellipsis") continue;
272
+ if (el.scrollWidth - el.clientWidth <= tolerance) continue;
273
+ const text = isFormValue ? el.value : (el.textContent ?? "").trim();
274
+ if (!text) continue;
275
+ reported.add(el);
276
+ results.push({
277
+ selector: describe(el),
278
+ text: text.length > 40 ? `${text.slice(0, 40)}…` : text,
279
+ visibleWidth: Math.round(el.clientWidth),
280
+ contentWidth: Math.round(el.scrollWidth),
281
+ isInput: isFormValue
282
+ });
283
+ }
284
+ return results;
285
+ };
286
+ /* c8 ignore stop */
287
+ /** Format one clipped element as a finding. */
288
+ const toFinding$1 = (clipped) => ({
289
+ message: `Content is cut off with no way to reveal it (${clipped.selector})`,
290
+ severity: "serious",
291
+ detail: `${clipped.isInput ? "Input value" : "Text"} "${clipped.text}" needs ${clipped.contentWidth}px but has ${clipped.visibleWidth}px. Widen the element, or make the truncation explicit with \`text-overflow: ellipsis\`.`
292
+ });
293
+ /**
294
+ * Report every element whose content is silently cut off. Returns no findings when the
295
+ * page cannot be inspected, so a caller can always spread the result.
296
+ */
297
+ const clippedTextFindings = async (page) => {
298
+ try {
299
+ return (await page.evaluate(findClippedElements, {
300
+ tolerance: CLIP_TOLERANCE_PX,
301
+ max: MAX_REPORTED
302
+ })).map(toFinding$1);
303
+ } catch (error) {
304
+ console.error("clipped-text: could not inspect the page", error);
305
+ return [];
306
+ }
307
+ };
308
+ //#endregion
309
+ //#region src/axes/accessibility.ts
310
+ const IMPACTS = [
311
+ "critical",
312
+ "serious",
313
+ "moderate",
314
+ "minor"
315
+ ];
316
+ const toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
317
+ /** First `wcag*` tag if present, else the first tag, else undefined. */
318
+ const wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
319
+ const toFinding = (violation) => {
320
+ const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
321
+ const wcag = wcagTag(violation.tags);
322
+ return {
323
+ message: `${violation.help || violation.description}${targets ? ` (${targets})` : ""}`,
324
+ severity: toSeverity(violation.impact),
325
+ ref: [wcag, violation.id].filter(Boolean).join(" · ")
326
+ };
327
+ };
328
+ /**
329
+ * A3 · Accessibility verdict — UT-4494.
330
+ *
331
+ * Injects axe-core into `context.page` and maps each violation to a Finding
332
+ * (WCAG tag + rule id as `ref`, axe impact as severity). Pass rule: a page
333
+ * passes when no violation has impact `critical` or `serious` — moderate/minor
334
+ * violations are reported but don't fail the axis. Falls back to a clear
335
+ * finding rather than throwing when no live page is provided.
336
+ */
337
+ /** Not-assessed outcome — a11y can't fail if it never ran. */
338
+ const notAssessed = (message) => ({
339
+ axis: "a11y",
340
+ pass: true,
341
+ findings: [{
342
+ message,
343
+ severity: "minor"
344
+ }]
345
+ });
346
+ const accessibilityAxis = {
347
+ name: "a11y",
348
+ run: async (context) => {
349
+ const { page } = context;
350
+ if (!page) return notAssessed("a11y skipped: no live page provided");
351
+ await page.addScriptTag({ content: axe.source });
352
+ const results = await page.evaluate(() => {
353
+ const injected = window.axe;
354
+ return injected ? injected.run(document) : null;
355
+ });
356
+ if (results === null) return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
357
+ const findings = [...results.violations.map(toFinding), ...await clippedTextFindings(page)];
358
+ return {
359
+ axis: "a11y",
360
+ pass: !findings.some((finding) => finding.severity === "critical" || finding.severity === "serious"),
361
+ findings
362
+ };
363
+ }
364
+ };
365
+ //#endregion
366
+ //#region src/axes/vlm-provider.ts
367
+ /** Concatenate a message's text blocks. */
368
+ const textFromMessage = (message) => message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
369
+ const makeProvider = (backend, clientOptions) => {
370
+ const client = new Anthropic(clientOptions);
371
+ return {
372
+ backend,
373
+ judge: async ({ screenshotBase64, prompt, model }) => {
374
+ const message = await client.messages.create({
375
+ model,
376
+ max_tokens: 1024,
377
+ messages: [{
378
+ role: "user",
379
+ content: [{
380
+ type: "image",
381
+ source: {
382
+ type: "base64",
383
+ media_type: "image/png",
384
+ data: screenshotBase64
385
+ }
386
+ }, {
387
+ type: "text",
388
+ text: prompt
389
+ }]
390
+ }]
391
+ });
392
+ return textFromMessage(message);
393
+ }
394
+ };
395
+ };
396
+ const nonEmpty = (value) => value !== void 0 && value !== "";
397
+ /**
398
+ * Pick a VLM backend from the environment, or `undefined` when none is
399
+ * configured (the axis then skips gracefully).
400
+ *
401
+ * Preference order mirrors how kai-agent routes Claude for internal/team use
402
+ * (`apps/kai-agent/src/services/entrypoint-builder.ts`): the Keboola LLM path
403
+ * points the Anthropic SDK at a Keboola-hosted proxy via `baseURL` and
404
+ * authenticates with a Keboola-minted token sent as `x-api-key` (the SDK's
405
+ * `apiKey`). kai-agent injects that pair through the SDK-native
406
+ * `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` env vars; we accept those, and
407
+ * prefer the explicit `VALIDATE_UI_LLM_BASE_URL` / `VALIDATE_UI_LLM_TOKEN`
408
+ * (with `KBC_TOKEN` as a Keboola-convention alias) when set. The proxy path is
409
+ * preferred because the team plan has no dedicated raw key.
410
+ *
411
+ * When no proxy `baseURL` is configured, a bare `ANTHROPIC_API_KEY` selects the
412
+ * raw Anthropic backend — the original behavior and the fallback.
413
+ *
414
+ * Both backends pass the resolved key (and, for the proxy, `baseURL`) into the
415
+ * `Anthropic` constructor explicitly, so resolution never reads ambient
416
+ * `process.env` behind the injected `env` arg.
417
+ */
418
+ const resolveVlmProvider = (env = process.env) => {
419
+ const baseUrl = env.VALIDATE_UI_LLM_BASE_URL ?? env.ANTHROPIC_BASE_URL;
420
+ const proxyToken = env.VALIDATE_UI_LLM_TOKEN ?? env.KBC_TOKEN ?? env.ANTHROPIC_API_KEY;
421
+ if (nonEmpty(baseUrl) && nonEmpty(proxyToken)) return makeProvider("keboola-llm", {
422
+ baseURL: baseUrl,
423
+ apiKey: proxyToken
424
+ });
425
+ if (nonEmpty(env.ANTHROPIC_API_KEY)) return makeProvider("raw-anthropic", { apiKey: env.ANTHROPIC_API_KEY });
426
+ };
427
+ //#endregion
428
+ //#region src/axes/brief-conformance.ts
429
+ /** Structured judgement the VLM must return, validated before interpretation. */
430
+ const JudgeResult = z.object({
431
+ matchesBrief: z.boolean(),
432
+ missingStates: z.array(z.string()),
433
+ reasons: z.array(z.string())
434
+ });
435
+ const MODEL = process.env.VALIDATE_UI_MODEL ?? "claude-sonnet-4-6";
436
+ const DOM_EXCERPT_LIMIT = 12e3;
437
+ /**
438
+ * Pure interpreter: turn a validated {@link JudgeResult} into a {@link Verdict}.
439
+ * A brief mismatch is serious; each missing state is a moderate gap.
440
+ */
441
+ const interpretJudgeResult = (result) => {
442
+ const findings = [];
443
+ if (!result.matchesBrief) findings.push({
444
+ message: `UI does not match the brief: ${result.reasons.join("; ")}`,
445
+ severity: "serious"
446
+ });
447
+ for (const state of result.missingStates) findings.push({
448
+ message: `missing state: ${state}`,
449
+ severity: "moderate"
450
+ });
451
+ return {
452
+ axis: "brief-conformance",
453
+ pass: result.matchesBrief && result.missingStates.length === 0,
454
+ findings
455
+ };
456
+ };
457
+ const skip$1 = (message) => ({
458
+ axis: "brief-conformance",
459
+ pass: true,
460
+ findings: [{
461
+ message,
462
+ severity: "minor"
463
+ }]
464
+ });
465
+ const RUBRIC = [
466
+ "You are judging whether a rendered UI conforms to the brief it was generated from.",
467
+ "Assess two things:",
468
+ "1. matchesBrief — does the screenshot + DOM implement what the brief describes?",
469
+ "2. missingStates — which UI states the brief implies are NOT represented.",
470
+ " Use short lowercase labels like \"empty\", \"loading\", \"error\".",
471
+ "Reply with ONLY a JSON object, no prose, matching exactly:",
472
+ "{ \"matchesBrief\": boolean, \"missingStates\": string[], \"reasons\": string[] }",
473
+ "reasons: brief explanations for any mismatch (empty array if it matches)."
474
+ ].join("\n");
475
+ const briefConformanceAxis = {
476
+ name: "brief-conformance",
477
+ run: async (context) => {
478
+ const brief = context.brief?.trim();
479
+ if (brief === void 0 || brief === "") return skip$1("no brief provided; conformance not assessed");
480
+ const provider = resolveVlmProvider();
481
+ if (provider === void 0) return skip$1("brief-conformance skipped: no VLM backend configured (set VALIDATE_UI_LLM_BASE_URL + VALIDATE_UI_LLM_TOKEN for the Keboola LLM path, or ANTHROPIC_API_KEY for a raw key)");
482
+ const { artifact } = context;
483
+ const screenshotBase64 = Buffer.from(artifact.screenshot).toString("base64");
484
+ const domExcerpt = artifact.dom.slice(0, DOM_EXCERPT_LIMIT);
485
+ const reply = await provider.judge({
486
+ model: MODEL,
487
+ screenshotBase64,
488
+ prompt: `${RUBRIC}\n\nBrief:\n${brief}\n\nDOM excerpt:\n${domExcerpt}`
489
+ });
490
+ try {
491
+ const parsed = JudgeResult.parse(JSON.parse(reply));
492
+ return interpretJudgeResult(parsed);
493
+ } catch (error) {
494
+ console.error("brief-conformance: failed to parse judge response", error);
495
+ return {
496
+ axis: "brief-conformance",
497
+ pass: false,
498
+ findings: [{
499
+ message: "could not parse judge response",
500
+ severity: "moderate"
501
+ }]
502
+ };
503
+ }
504
+ }
505
+ };
506
+ //#endregion
507
+ //#region src/compare/diff.ts
508
+ /** Match a `*`-glob (or exact string) against a region key. */
509
+ const matchesGlob = (pattern, value) => {
510
+ if (!pattern.includes("*")) return pattern === value;
511
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
512
+ return new RegExp(`^${escaped}$`).test(value);
513
+ };
514
+ /** Multiset difference: tokens present more often in `from` than in `to`. */
515
+ const multisetMissing = (from, to) => {
516
+ const remaining = /* @__PURE__ */ new Map();
517
+ for (const value of to) remaining.set(value, (remaining.get(value) ?? 0) + 1);
518
+ const missing = [];
519
+ for (const value of from) {
520
+ const count = remaining.get(value) ?? 0;
521
+ if (count > 0) remaining.set(value, count - 1);
522
+ else missing.push(value);
523
+ }
524
+ return missing;
525
+ };
526
+ /** Stable signature of a delta — identical across routes for the same shared element. */
527
+ const signature = (regionKey, tokens) => `${regionKey}::${[...tokens].sort().join("|")}`;
528
+ /** The rule that suppresses removing a whole region (region match, no value scope). */
529
+ const regionRemovalRule = (rules, key) => rules.find((rule) => rule.value === void 0 && rule.region !== void 0 && matchesGlob(rule.region, key));
530
+ /** Does a value rule apply to this (region, value) pair? */
531
+ const valueRuleMatches = (rule, key, value) => rule.value !== void 0 && matchesGlob(rule.value, value) && (rule.region === void 0 || matchesGlob(rule.region, key));
532
+ const removedFinding = (region, rule) => ({
533
+ regionKey: region.key,
534
+ kind: "region-removed",
535
+ valueSignature: signature(region.key, region.values),
536
+ message: rule === void 0 ? `region "${region.label}" was removed` : `region "${region.label}" removed as expected`,
537
+ severity: rule === void 0 ? "serious" : "minor",
538
+ ...rule !== void 0 && { suppressed: { reason: rule.reason } }
539
+ });
540
+ const degradedFinding = (region) => ({
541
+ regionKey: region.key,
542
+ kind: "region-degraded",
543
+ valueSignature: signature(region.key, [region.state]),
544
+ message: `region "${region.label}" degraded to ${region.state} state — not compared as value loss`,
545
+ severity: "minor"
546
+ });
547
+ const addedFinding = (region) => ({
548
+ regionKey: region.key,
549
+ kind: "region-added",
550
+ valueSignature: signature(region.key, []),
551
+ message: `region "${region.label}" is new`,
552
+ severity: "minor"
553
+ });
554
+ /**
555
+ * Diff one region present on both sides. Returns the value-missing findings
556
+ * (active and/or suppressed), or none when values are unchanged.
557
+ */
558
+ const diffRegionValues = (oldRegion, newRegion, rules) => {
559
+ const missing = multisetMissing(oldRegion.values, newRegion.values);
560
+ if (missing.length === 0) return [];
561
+ const active = [];
562
+ const suppressed = [];
563
+ const reasons = /* @__PURE__ */ new Set();
564
+ for (const value of missing) {
565
+ const rule = rules.find((r) => valueRuleMatches(r, newRegion.key, value));
566
+ if (rule === void 0) active.push(value);
567
+ else {
568
+ suppressed.push(value);
569
+ reasons.add(rule.reason);
570
+ }
571
+ }
572
+ const findings = [];
573
+ if (active.length > 0) findings.push({
574
+ regionKey: newRegion.key,
575
+ kind: "value-missing",
576
+ valueSignature: signature(newRegion.key, active),
577
+ message: `region "${newRegion.label}" is missing ${active.length} value(s)`,
578
+ severity: "moderate",
579
+ detail: active.join(", ")
580
+ });
581
+ if (suppressed.length > 0) findings.push({
582
+ regionKey: newRegion.key,
583
+ kind: "value-missing",
584
+ valueSignature: signature(newRegion.key, suppressed),
585
+ message: `region "${newRegion.label}" is missing ${suppressed.length} expected value(s)`,
586
+ severity: "minor",
587
+ detail: suppressed.join(", "),
588
+ suppressed: { reason: [...reasons].join("; ") }
589
+ });
590
+ return findings;
591
+ };
592
+ /**
593
+ * Semantic old-vs-new diff of two snapshots. Walks regions by key and localizes
594
+ * every delta to a region: a removed region is `serious` unless allowlisted; a
595
+ * region that degraded to an empty/error state is reported informationally and
596
+ * NOT diffed as value loss; surviving regions get a per-region value-multiset
597
+ * diff with allowlist suppression; new regions are non-regression notes. Pure —
598
+ * no browser, no I/O.
599
+ */
600
+ const diffSnapshots = (oldSnap, newSnap, config = {}) => {
601
+ const rules = config.expectedAbsent ?? [];
602
+ const newByKey = new Map(newSnap.regions.map((region) => [region.key, region]));
603
+ const oldKeys = new Set(oldSnap.regions.map((region) => region.key));
604
+ const findings = [];
605
+ for (const oldRegion of oldSnap.regions) {
606
+ const newRegion = newByKey.get(oldRegion.key);
607
+ if (newRegion === void 0) {
608
+ findings.push(removedFinding(oldRegion, regionRemovalRule(rules, oldRegion.key)));
609
+ continue;
610
+ }
611
+ if (oldRegion.state === "ok" && newRegion.state !== "ok") {
612
+ findings.push(degradedFinding(newRegion));
613
+ continue;
614
+ }
615
+ findings.push(...diffRegionValues(oldRegion, newRegion, rules));
616
+ }
617
+ for (const newRegion of newSnap.regions) if (!oldKeys.has(newRegion.key)) findings.push(addedFinding(newRegion));
618
+ return findings;
619
+ };
620
+ /** A compare run passes when no active (non-suppressed) delta is serious/critical. */
621
+ const comparePass = (findings) => !findings.some((f) => f.suppressed === void 0 && (f.severity === "serious" || f.severity === "critical"));
622
+ //#endregion
623
+ //#region src/compare/regions.ts
624
+ const CANDIDATE_SELECTOR = [
625
+ "[data-region]",
626
+ "main",
627
+ "nav",
628
+ "header",
629
+ "aside",
630
+ "footer",
631
+ "section[aria-label]",
632
+ "[role=main]",
633
+ "[role=navigation]",
634
+ "[role=banner]",
635
+ "[role=complementary]",
636
+ "[role=contentinfo]",
637
+ "[role=region]"
638
+ ].join(",");
639
+ const DEFAULT_EMPTY_MARKERS = [
640
+ "no data",
641
+ "no results",
642
+ "nothing to show",
643
+ "nothing here",
644
+ "get started"
645
+ ];
646
+ const DEFAULT_ERROR_MARKERS = [
647
+ "something went wrong",
648
+ "failed to load",
649
+ "could not load",
650
+ "unavailable",
651
+ "try again"
652
+ ];
653
+ /**
654
+ * Reduce the live page to its diffable regions, in the browser. An explicit
655
+ * `[data-region]` is an authoritative boundary (its subtree is one region);
656
+ * elsewhere boundaries are the outermost-empty ("leaf") landmarks/sections, so
657
+ * regions never overlap or double-count values. Each region's key is resolved by
658
+ * precedence
659
+ * (`data-region` → `data-testid` → role → `aria-label` → nearest heading), its
660
+ * state from `data-state`/text markers, and its values as normalized numeric
661
+ * tokens. Symmetric across old and new so the two sides diff cleanly.
662
+ */
663
+ const extractRegions = async (page, config = {}) => {
664
+ const emptyMarkers = [...DEFAULT_EMPTY_MARKERS, ...config.emptyStateMarkers ?? []];
665
+ const errorMarkers = [...DEFAULT_ERROR_MARKERS, ...config.errorStateMarkers ?? []];
666
+ return page.evaluate(({ selector, emptyMarkers, errorMarkers }) => {
667
+ const STATES = [
668
+ "ok",
669
+ "empty",
670
+ "error",
671
+ "loading"
672
+ ];
673
+ const isState = (value) => STATES.includes(value);
674
+ const slugify = (raw) => raw.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "region";
675
+ const roleOf = (el) => {
676
+ const explicit = el.getAttribute("role");
677
+ if (explicit !== null && explicit !== "") return explicit;
678
+ const tag = el.tagName.toLowerCase();
679
+ if (tag === "main") return "main";
680
+ if (tag === "nav") return "navigation";
681
+ if (tag === "aside") return "complementary";
682
+ if (tag === "header" || tag === "footer") {
683
+ if (el.closest("main,nav,section,article,aside") !== null) return void 0;
684
+ return tag === "header" ? "banner" : "contentinfo";
685
+ }
686
+ };
687
+ const headingOf = (el) => {
688
+ const text = el.querySelector("h1,h2,h3,h4,h5,h6")?.textContent?.trim();
689
+ return text !== void 0 && text !== "" ? text : void 0;
690
+ };
691
+ const keyOf = (el) => {
692
+ const region = el.getAttribute("data-region");
693
+ if (region !== null && region !== "") return slugify(region);
694
+ const testid = el.getAttribute("data-testid");
695
+ if (testid !== null && testid !== "") return slugify(testid);
696
+ const role = roleOf(el);
697
+ if (role !== void 0) return role;
698
+ const label = el.getAttribute("aria-label");
699
+ if (label !== null && label !== "") return slugify(label);
700
+ const heading = headingOf(el);
701
+ if (heading !== void 0) return slugify(heading);
702
+ return slugify(el.tagName);
703
+ };
704
+ const labelOf = (el, key) => {
705
+ const label = el.getAttribute("aria-label");
706
+ if (label !== null && label.trim() !== "") return label.trim();
707
+ const heading = headingOf(el);
708
+ if (heading !== void 0) return heading;
709
+ const testid = el.getAttribute("data-testid");
710
+ if (testid !== null && testid !== "") return testid;
711
+ return key;
712
+ };
713
+ const textOf = (el) => (el.innerText || el.textContent || "").trim();
714
+ const stateOf = (el, text) => {
715
+ const explicit = (el.getAttribute("data-state") ?? "").toLowerCase();
716
+ if (isState(explicit)) return explicit;
717
+ const lower = text.toLowerCase();
718
+ if (errorMarkers.some((marker) => lower.includes(marker))) return "error";
719
+ if (lower.includes("loading")) return "loading";
720
+ if (emptyMarkers.some((marker) => lower.includes(marker))) return "empty";
721
+ return "ok";
722
+ };
723
+ const valuesOf = (text) => (text.match(/-?\$?\s?\d[\d,]*(?:\.\d+)?%?/g) ?? []).map((token) => token.replace(/[$\s,]/g, "")).filter((token) => token !== "" && token !== "-");
724
+ const qualifies = (el) => {
725
+ const dataRegion = el.getAttribute("data-region");
726
+ if (dataRegion !== null && dataRegion !== "") {
727
+ const parent = el.parentElement;
728
+ return parent === null || parent.closest("[data-region]") === null;
729
+ }
730
+ return el.querySelector(selector) === null && el.closest("[data-region]") === null;
731
+ };
732
+ const regions = Array.from(document.querySelectorAll(selector)).filter(qualifies);
733
+ const elements = regions.length > 0 ? regions : [document.body];
734
+ const used = {};
735
+ const uniqueKey = (key) => {
736
+ const count = (used[key] ?? 0) + 1;
737
+ used[key] = count;
738
+ return count === 1 ? key : `${key}-${count}`;
739
+ };
740
+ return elements.map((el) => {
741
+ const text = textOf(el);
742
+ const key = uniqueKey(keyOf(el));
743
+ return {
744
+ key,
745
+ label: labelOf(el, key),
746
+ state: stateOf(el, text),
747
+ values: valuesOf(text)
748
+ };
749
+ });
750
+ }, {
751
+ selector: CANDIDATE_SELECTOR,
752
+ emptyMarkers,
753
+ errorMarkers
754
+ });
755
+ };
756
+ //#endregion
757
+ //#region src/compare/types.ts
758
+ /**
759
+ * Backend/render state of a region. A region that rendered its data is `ok`;
760
+ * `empty`/`error`/`loading` are non-data states — an old→new transition into one
761
+ * of them is a degrade, not a value regression (a backend was unavailable, or an
762
+ * intentional empty state is showing).
763
+ */
764
+ const REGION_STATES = [
765
+ "ok",
766
+ "empty",
767
+ "error",
768
+ "loading"
769
+ ];
770
+ /** One diffable region of a page — a landmark, labelled section, or component. */
771
+ const Region = z.object({
772
+ /** Stable id: data-region → data-testid → role → aria-label → heading slug. */
773
+ key: z.string(),
774
+ /** Human label for the report. */
775
+ label: z.string(),
776
+ state: z.enum(REGION_STATES),
777
+ /** Normalized numeric / currency / percent tokens found in the region. */
778
+ values: z.array(z.string())
779
+ });
780
+ /**
781
+ * A page reduced to its diffable regions — the currency both input paths (a live
782
+ * boot or a recorded JSON file) produce and the differ consumes. Pure JSON, so
783
+ * it is safe to serialize to disk and re-load; {@link loadSnapshot} validates it.
784
+ */
785
+ const CompareSnapshot = z.object({
786
+ /** Route the snapshot was captured at, e.g. `/financials`. */
787
+ route: z.string(),
788
+ regions: z.array(Region)
789
+ }).refine((snap) => new Set(snap.regions.map((region) => region.key)).size === snap.regions.length, { message: "region keys must be unique" });
790
+ /**
791
+ * One expected-absent rule. `region` (exact or `*`-glob against {@link Region.key})
792
+ * alone suppresses a whole `region-removed`; add `value` to suppress a specific
793
+ * missing value token (optionally scoped to a region). `reason` is required and
794
+ * surfaced in the report so suppression is auditable, never silent.
795
+ */
796
+ const ExpectedAbsentRule = z.object({
797
+ region: z.string().optional(),
798
+ value: z.string().optional(),
799
+ reason: z.string().min(1)
800
+ }).refine((rule) => rule.region !== void 0 || rule.value !== void 0, { message: "expectedAbsent rule needs at least one of `region` or `value`" });
801
+ /** Semantic-compare configuration — the allowlist plus extra state markers. */
802
+ const CompareConfig = z.object({
803
+ expectedAbsent: z.array(ExpectedAbsentRule).optional(),
804
+ emptyStateMarkers: z.array(z.string()).optional(),
805
+ errorStateMarkers: z.array(z.string()).optional()
806
+ });
807
+ //#endregion
808
+ //#region src/compare/snapshot.ts
809
+ /** Reduce a loaded live page to a {@link CompareSnapshot} for the given route. */
810
+ const captureSnapshot = async (page, route, config) => ({
811
+ route,
812
+ regions: await extractRegions(page, config)
813
+ });
814
+ /** Persist a snapshot as pretty JSON so an "old" side can be recorded once and re-diffed. */
815
+ const saveSnapshot = async (snapshot, path) => {
816
+ await writeFile(path, JSON.stringify(snapshot, null, 2));
817
+ };
818
+ /** Read and validate a recorded snapshot from disk. */
819
+ const loadSnapshot = async (path) => {
820
+ const raw = await readFile(path, "utf8");
821
+ return CompareSnapshot.parse(JSON.parse(raw));
822
+ };
823
+ //#endregion
824
+ //#region src/axes/compare.ts
825
+ const skip = (message) => ({
826
+ axis: "compare",
827
+ pass: true,
828
+ findings: [{
829
+ message,
830
+ severity: "minor"
831
+ }]
832
+ });
833
+ /**
834
+ * A5 · Semantic old-vs-new compare verdict — UT-4572.
835
+ *
836
+ * Activates only when `context.comparison` (an "old" {@link CompareSnapshot}) is
837
+ * supplied; otherwise it degrades to a passing skip, so ordinary single-page runs
838
+ * are unaffected. Extracts the "new" snapshot from the live `context.page`, then
839
+ * runs the pure {@link diffSnapshots}: deltas are localized per region, declared
840
+ * expected-absent deltas are suppressed (not dropped), and a region that degraded
841
+ * to an empty/error state is reported informationally rather than as value loss.
842
+ */
843
+ const compareAxis = {
844
+ name: "compare",
845
+ run: async (context) => {
846
+ const { comparison, compareConfig, page } = context;
847
+ if (comparison === void 0) return skip("no comparison snapshot provided; old-vs-new not assessed");
848
+ if (page === void 0) return skip("compare needs a live page to extract the new snapshot");
849
+ const newSnapshot = await captureSnapshot(page, comparison.route, compareConfig);
850
+ const findings = diffSnapshots(comparison, newSnapshot, compareConfig);
851
+ return {
852
+ axis: "compare",
853
+ pass: comparePass(findings),
854
+ findings
855
+ };
856
+ }
857
+ };
858
+ //#endregion
859
+ //#region src/axes/runtime-health.ts
860
+ const ERROR_MARKERS = [
861
+ "something went wrong",
862
+ "vite-error-overlay",
863
+ "vite-plugin-checker-error-overlay",
864
+ "react-error-overlay"
865
+ ];
866
+ const pageErrorFindings = (console) => console.filter((entry) => entry.type === "pageerror").map((entry) => ({
867
+ message: `Uncaught page error: ${entry.text}`,
868
+ severity: "critical",
869
+ detail: entry.location
870
+ }));
871
+ const RESOURCE_LOAD_ECHO = "failed to load resource";
872
+ const isResourceLoadEcho = (entry) => entry.text.toLowerCase().includes(RESOURCE_LOAD_ECHO);
873
+ /**
874
+ * Real app console errors only — the generic "Failed to load resource" echoes
875
+ * are dropped (the network finding for the same URL is more useful). Identical
876
+ * texts are deduped since one noisy error often logs on every render.
877
+ */
878
+ const consoleErrorFindings = (console) => {
879
+ const seen = /* @__PURE__ */ new Set();
880
+ const findings = [];
881
+ for (const entry of console) {
882
+ if (entry.type !== "error" || isResourceLoadEcho(entry) || seen.has(entry.text)) continue;
883
+ seen.add(entry.text);
884
+ findings.push({
885
+ message: `Console error: ${entry.text}`,
886
+ severity: "serious",
887
+ detail: entry.location
888
+ });
889
+ }
890
+ return findings;
891
+ };
892
+ /** Turn one network entry into a finding, or null for a healthy (<400) response. */
893
+ const toNetworkFinding = (entry) => {
894
+ if (entry.status === 0) return {
895
+ message: `Request failed: ${entry.method} ${entry.url}`,
896
+ severity: "serious",
897
+ detail: entry.failure,
898
+ ref: entry.url
899
+ };
900
+ if (entry.status >= 500) return {
901
+ message: `Server error ${entry.status}: ${entry.method} ${entry.url}`,
902
+ severity: "serious",
903
+ ref: entry.url
904
+ };
905
+ if (entry.status >= 400) return {
906
+ message: `Client error ${entry.status}: ${entry.method} ${entry.url}`,
907
+ severity: "moderate",
908
+ ref: entry.url
909
+ };
910
+ return null;
911
+ };
912
+ const SEVERITY_RANK = {
913
+ critical: 3,
914
+ serious: 2,
915
+ moderate: 1,
916
+ minor: 0
917
+ };
918
+ /**
919
+ * One finding per URL. A single failing resource surfaces as a `requestfailed`,
920
+ * a 4xx/5xx response, and a console echo — all the same root cause. Collapse
921
+ * them by URL, keeping the most severe (a request-failure or explicit HTTP
922
+ * status over the generic browser echo).
923
+ */
924
+ const networkFindings = (network) => {
925
+ const byUrl = /* @__PURE__ */ new Map();
926
+ for (const entry of network) {
927
+ const finding = toNetworkFinding(entry);
928
+ if (finding === null) continue;
929
+ const existing = byUrl.get(entry.url);
930
+ if (existing === void 0 || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[existing.severity]) byUrl.set(entry.url, finding);
931
+ }
932
+ return [...byUrl.values()];
933
+ };
934
+ /** An error overlay / boundary in the DOM means a crashed render, not real content. */
935
+ const errorMarkerFinding = (dom) => {
936
+ const lower = dom.toLowerCase();
937
+ const marker = ERROR_MARKERS.find((m) => lower.includes(m));
938
+ return marker === void 0 ? null : {
939
+ message: "Error boundary / error overlay rendered instead of the app",
940
+ severity: "critical",
941
+ detail: marker
942
+ };
943
+ };
944
+ /**
945
+ * White-screen check on the reliable live-DOM signal (`renderedText`). Skipped
946
+ * when absent — parsing the HTML string for "emptiness" gives false positives.
947
+ */
948
+ const whiteScreenFinding = (renderedText) => renderedText !== void 0 && renderedText.length === 0 ? {
949
+ message: "White screen: page rendered no visible text",
950
+ severity: "critical"
951
+ } : null;
952
+ /**
953
+ * A2 · Runtime-health verdict — UT-4493.
954
+ *
955
+ * Pure over `context.artifact` (no live page). Collects uncaught page errors,
956
+ * console errors, failed/4xx/5xx requests, and white-screen / error-overlay
957
+ * signals into `findings`.
958
+ */
959
+ const runtimeHealthAxis = {
960
+ name: "runtime-health",
961
+ run: (context) => {
962
+ const { artifact } = context;
963
+ const findings = [
964
+ ...pageErrorFindings(artifact.console),
965
+ ...consoleErrorFindings(artifact.console),
966
+ ...networkFindings(artifact.network)
967
+ ];
968
+ const errorMarker = errorMarkerFinding(artifact.dom);
969
+ if (errorMarker !== null) findings.push(errorMarker);
970
+ const whiteScreen = whiteScreenFinding(artifact.renderedText);
971
+ if (whiteScreen !== null) findings.push(whiteScreen);
972
+ const fatal = ["critical", "serious"];
973
+ const pass = !findings.some((f) => fatal.includes(f.severity));
974
+ return Promise.resolve({
975
+ axis: "runtime-health",
976
+ pass,
977
+ findings
978
+ });
979
+ }
980
+ };
981
+ //#endregion
982
+ //#region src/axes/visual-brand.ts
983
+ const DRIFT_THRESHOLD = .01;
984
+ const RESKIN_THRESHOLD = .005;
985
+ const DRIFT_PIXEL_THRESHOLD = .1;
986
+ const RESKIN_PIXEL_THRESHOLD = .02;
987
+ const DEFAULT_ALT_BRAND = "example-customer";
988
+ /** Decode two PNGs and return their pixel mismatch ratio (0 = identical, 1 = every pixel differs). */
989
+ const pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
990
+ const imgA = PNG.sync.read(Buffer.from(a));
991
+ const imgB = PNG.sync.read(Buffer.from(b));
992
+ if (imgA.width !== imgB.width || imgA.height !== imgB.height) return {
993
+ ratio: 1,
994
+ sizeMismatch: true
995
+ };
996
+ const total = imgA.width * imgA.height;
997
+ if (total === 0) return {
998
+ ratio: 0,
999
+ sizeMismatch: false
1000
+ };
1001
+ return {
1002
+ ratio: pixelmatch(imgA.data, imgB.data, void 0, imgA.width, imgA.height, { threshold: pixelThreshold }) / total,
1003
+ sizeMismatch: false
1004
+ };
1005
+ };
1006
+ const baselineFinding = (screenshot, baseline) => {
1007
+ const { ratio, sizeMismatch } = pngMismatchRatio(screenshot, baseline);
1008
+ if (sizeMismatch) return {
1009
+ message: "screenshot dimensions differ from baseline",
1010
+ severity: "moderate"
1011
+ };
1012
+ if (ratio > DRIFT_THRESHOLD) return {
1013
+ message: `visual drift ${(ratio * 100).toFixed(1)}% vs baseline`,
1014
+ severity: "moderate",
1015
+ detail: `${(ratio * 100).toFixed(2)}% of pixels changed`
1016
+ };
1017
+ return null;
1018
+ };
1019
+ /**
1020
+ * Whether the alt-brand capture actually activated that brand.
1021
+ *
1022
+ * `captureUnderBrand` requests a brand with `?brand=<id>`, which only works if the app
1023
+ * reads that param and re-resolves its `ThemeProvider`. Most apps deliberately do not:
1024
+ * the brand is build configuration, not a runtime switch, and shipping a query-param
1025
+ * brand switcher into every generated app is not something a scaffold should impose.
1026
+ *
1027
+ * Without this check, such an app captures the same brand twice, and the identical
1028
+ * screenshots are reported as "chrome did not re-skin" — a false failure for code that is
1029
+ * entirely correct. The brand is nominated via `data-brand` on the root element (written
1030
+ * by `@keboola/design`'s ThemeProvider), so absence of the requested id means the request
1031
+ * was ignored, not that the chrome failed to re-skin.
1032
+ */
1033
+ const altBrandWasApplied = (altDom, brand) => altDom.includes(`data-brand="${brand}"`);
1034
+ const altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
1035
+ if (!altBrandWasApplied(altDom, brand)) return {
1036
+ message: `alt-brand re-skin not assessed: the app did not apply '${brand}' from ?brand=`,
1037
+ severity: "minor",
1038
+ detail: "Brand is usually build configuration rather than a runtime switch. To have this axis assess re-skinning, register the alternate brand and resolve the active id from the `brand` query parameter."
1039
+ };
1040
+ const { ratio } = pngMismatchRatio(screenshot, altScreenshot, RESKIN_PIXEL_THRESHOLD);
1041
+ if (ratio < RESKIN_THRESHOLD) return {
1042
+ message: `chrome did not re-skin under alternate brand '${brand}'`,
1043
+ severity: "serious",
1044
+ detail: `${(ratio * 100).toFixed(2)}% of pixels changed`
1045
+ };
1046
+ return null;
1047
+ };
1048
+ /**
1049
+ * Non-fatal `moderate` findings for a brand's interactive foreground↔background
1050
+ * pairings that fall below WCAG-AA (button/link labels on branded fills). Surfaces
1051
+ * the guarantee `@keboola/brand-registry` computes so an AA-failing token is a
1052
+ * build-time warning here, not a runtime axe surprise. Empty for an AA-safe brand.
1053
+ */
1054
+ const interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).map((warning) => ({
1055
+ message: `brand '${brand.id}' ${warning.message}`,
1056
+ severity: "moderate",
1057
+ detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
1058
+ }));
1059
+ const overflowFinding = async (page) => {
1060
+ if (await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)) return {
1061
+ message: "horizontal layout overflow",
1062
+ severity: "moderate"
1063
+ };
1064
+ return null;
1065
+ };
1066
+ /**
1067
+ * A4 · Visual & brand-correctness verdict — UT-4495.
1068
+ *
1069
+ * Baseline diff of `context.artifact.screenshot` vs `context.baseline`
1070
+ * (pixelmatch + pngjs), plus the alt-brand fitness function via
1071
+ * `context.captureUnderBrand`: the chrome must visibly re-skin under an
1072
+ * alternate brand. Flags layout overflow. Each check degrades to no finding when
1073
+ * its input is absent. (Asserting categorical colors — Badge/Alert/ModalIcon —
1074
+ * stay fixed pixel-wise is future work; see `altBrandFinding`.)
1075
+ */
1076
+ const visualBrandAxis = {
1077
+ name: "visual-brand",
1078
+ run: async (context) => {
1079
+ const { artifact, baseline, captureUnderBrand, page } = context;
1080
+ const findings = [];
1081
+ if (baseline !== void 0) {
1082
+ const finding = baselineFinding(artifact.screenshot, baseline);
1083
+ if (finding !== null) findings.push(finding);
1084
+ }
1085
+ if (captureUnderBrand !== void 0) {
1086
+ const altArtifact = await captureUnderBrand(DEFAULT_ALT_BRAND);
1087
+ const finding = altBrandFinding(artifact.screenshot, altArtifact.screenshot, DEFAULT_ALT_BRAND, altArtifact.dom);
1088
+ if (finding !== null) findings.push(finding);
1089
+ const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
1090
+ if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
1091
+ }
1092
+ if (page !== void 0) {
1093
+ const finding = await overflowFinding(page);
1094
+ if (finding !== null) findings.push(finding);
1095
+ }
1096
+ return {
1097
+ axis: "visual-brand",
1098
+ pass: !findings.some((f) => f.severity === "critical" || f.severity === "serious"),
1099
+ findings
1100
+ };
1101
+ }
1102
+ };
1103
+ //#endregion
1104
+ //#region src/axes/index.ts
1105
+ /**
1106
+ * The registered axes, in report order. `compare` runs last and only activates
1107
+ * when a comparison snapshot is supplied, so ordinary single-page runs are
1108
+ * unaffected.
1109
+ */
1110
+ const ALL_AXES = [
1111
+ runtimeHealthAxis,
1112
+ accessibilityAxis,
1113
+ visualBrandAxis,
1114
+ briefConformanceAxis,
1115
+ compareAxis
1116
+ ];
1117
+ //#endregion
1118
+ //#region src/aggregate.ts
1119
+ /**
1120
+ * Run each axis against the shared context, isolating failures: an axis that
1121
+ * throws becomes a failing verdict instead of aborting the whole run. The error
1122
+ * is logged (never swallowed) and surfaced as a critical finding.
1123
+ */
1124
+ const runAxes = async (axes, context) => {
1125
+ const verdicts = [];
1126
+ for (const axis of axes) try {
1127
+ verdicts.push(await axis.run(context));
1128
+ } catch (error) {
1129
+ const message = error instanceof Error ? error.message : String(error);
1130
+ console.error(`validate-ui: axis "${axis.name}" threw:`, error);
1131
+ verdicts.push({
1132
+ axis: axis.name,
1133
+ pass: false,
1134
+ findings: [{
1135
+ message: `Axis crashed: ${message}`,
1136
+ severity: "critical"
1137
+ }]
1138
+ });
1139
+ }
1140
+ return verdicts;
1141
+ };
1142
+ /** A run passes only when every axis passed. */
1143
+ const aggregatePass = (verdicts) => verdicts.every((v) => v.pass);
1144
+ const withBrandParam = (url, brand) => {
1145
+ const parsed = new URL(url);
1146
+ parsed.searchParams.set("brand", brand);
1147
+ return parsed.toString();
1148
+ };
1149
+ /**
1150
+ * The closed loop: render `url`, capture ground truth, and run every axis over
1151
+ * it, returning one machine-readable {@link AggregateVerdict}. Keeps the page
1152
+ * open for axes that need a live DOM (a11y, visual) and provides
1153
+ * `captureUnderBrand` for the alt-brand fitness function.
1154
+ */
1155
+ const validate = async (options) => {
1156
+ const viewport = options.viewport ?? DESKTOP_VIEWPORT;
1157
+ const axes = options.axes ?? ALL_AXES;
1158
+ const browser = options.browser ?? await chromium.launch();
1159
+ const ownsBrowser = !options.browser;
1160
+ const context = await browser.newContext({ viewport: {
1161
+ width: viewport.width,
1162
+ height: viewport.height
1163
+ } });
1164
+ const page = await context.newPage();
1165
+ try {
1166
+ const axisContext = {
1167
+ artifact: await capturePage(page, options.url, viewport, options.timeoutMs),
1168
+ page,
1169
+ browser,
1170
+ brief: options.brief,
1171
+ baseline: options.baseline,
1172
+ comparison: options.comparison,
1173
+ compareConfig: options.compareConfig,
1174
+ captureUnderBrand: async (brand) => {
1175
+ const brandContext = await browser.newContext({ viewport: {
1176
+ width: viewport.width,
1177
+ height: viewport.height
1178
+ } });
1179
+ const brandPage = await brandContext.newPage();
1180
+ try {
1181
+ return await capturePage(brandPage, withBrandParam(options.url, brand), viewport, options.timeoutMs);
1182
+ } finally {
1183
+ await brandContext.close();
1184
+ }
1185
+ }
1186
+ };
1187
+ const verdicts = await runAxes(axes, axisContext);
1188
+ return {
1189
+ url: options.url,
1190
+ pass: aggregatePass(verdicts),
1191
+ verdicts
1192
+ };
1193
+ } finally {
1194
+ await context.close();
1195
+ if (ownsBrowser) await browser.close();
1196
+ }
1197
+ };
1198
+ //#endregion
1199
+ //#region src/compare/dedup.ts
1200
+ const groupKey = (finding) => `${finding.kind}::${finding.valueSignature}`;
1201
+ /**
1202
+ * Collapse deltas that recur across routes into a single `shared-chrome` finding.
1203
+ * A value flagged on a shared header/daily-brief element would otherwise be
1204
+ * multiplied across every route (the prototype's `8.4/7.7/-8%/67%` on all 10
1205
+ * pages); here it is reported once, tagged with the routes it spans, and dropped
1206
+ * from each route's own list. Pure — no browser, no I/O.
1207
+ */
1208
+ const dedupeSharedChrome = (perRoute) => {
1209
+ const routesByGroup = /* @__PURE__ */ new Map();
1210
+ for (const { route, findings } of perRoute) for (const finding of findings) {
1211
+ const key = groupKey(finding);
1212
+ const routes = routesByGroup.get(key) ?? /* @__PURE__ */ new Set();
1213
+ routes.add(route);
1214
+ routesByGroup.set(key, routes);
1215
+ }
1216
+ const sharedGroups = new Set([...routesByGroup].filter(([, routes]) => routes.size >= 2).map(([key]) => key));
1217
+ const sharedChrome = [];
1218
+ const seenShared = /* @__PURE__ */ new Set();
1219
+ const trimmedPerRoute = perRoute.map(({ route, findings }) => ({
1220
+ route,
1221
+ findings: findings.filter((finding) => !sharedGroups.has(groupKey(finding)))
1222
+ }));
1223
+ for (const { findings } of perRoute) for (const finding of findings) {
1224
+ const key = groupKey(finding);
1225
+ if (!sharedGroups.has(key) || seenShared.has(key)) continue;
1226
+ seenShared.add(key);
1227
+ const routes = [...routesByGroup.get(key) ?? []].sort();
1228
+ sharedChrome.push({
1229
+ ...finding,
1230
+ message: `${finding.message} (shared across ${routes.length} routes: ${routes.join(", ")})`
1231
+ });
1232
+ }
1233
+ return {
1234
+ sharedChrome,
1235
+ perRoute: trimmedPerRoute
1236
+ };
1237
+ };
1238
+ //#endregion
1239
+ //#region src/compare/runner.ts
1240
+ const DEFAULT_TIMEOUT_MS = 3e4;
1241
+ const joinUrl = (base, route) => `${base.replace(/\/$/, "")}/${route.replace(/^\//, "")}`;
1242
+ /** Recorded old snapshot filename for a route: `/a/b` → `a_b.json`, `/` → `index.json`. */
1243
+ const routeSnapshotFile = (route) => `${route.replace(/^\//, "").replace(/\/+$/, "").replace(/\//g, "_") || "index"}.json`;
1244
+ /**
1245
+ * Multi-route semantic compare. Boots each route on the new side (and the old
1246
+ * side too, unless recorded snapshots are supplied), diffs per route, then
1247
+ * collapses deltas that recur across routes into a single shared-chrome entry so
1248
+ * one shared element is not reported as N regressions. Exactly one of
1249
+ * `oldBaseUrl` / `oldSnapshotDir` must be given.
1250
+ */
1251
+ const compareRoutes = async (options) => {
1252
+ if (options.oldBaseUrl === void 0 === (options.oldSnapshotDir === void 0)) throw new Error("compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir");
1253
+ const viewport = options.viewport ?? DESKTOP_VIEWPORT;
1254
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1255
+ const browser = options.browser ?? await chromium.launch();
1256
+ const ownsBrowser = options.browser === void 0;
1257
+ const { oldBaseUrl, oldSnapshotDir } = options;
1258
+ const snapshotUrl = async (url, route) => {
1259
+ const context = await browser.newContext({ viewport: {
1260
+ width: viewport.width,
1261
+ height: viewport.height
1262
+ } });
1263
+ const page = await context.newPage();
1264
+ try {
1265
+ await page.goto(url, {
1266
+ waitUntil: "networkidle",
1267
+ timeout: timeoutMs
1268
+ });
1269
+ await prepareForScreenshot(page);
1270
+ return await captureSnapshot(page, route, options.config);
1271
+ } finally {
1272
+ await context.close();
1273
+ }
1274
+ };
1275
+ const oldSnapshotFor = (route) => {
1276
+ if (oldBaseUrl !== void 0) return snapshotUrl(joinUrl(oldBaseUrl, route), route);
1277
+ if (oldSnapshotDir !== void 0) return loadSnapshot(join(oldSnapshotDir, routeSnapshotFile(route)));
1278
+ throw new Error("compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir");
1279
+ };
1280
+ try {
1281
+ const perRoute = [];
1282
+ for (const route of options.routes) {
1283
+ const oldSnapshot = await oldSnapshotFor(route);
1284
+ const newSnapshot = await snapshotUrl(joinUrl(options.newBaseUrl, route), route);
1285
+ perRoute.push({
1286
+ route,
1287
+ findings: diffSnapshots(oldSnapshot, newSnapshot, options.config)
1288
+ });
1289
+ }
1290
+ const { sharedChrome, perRoute: trimmed } = dedupeSharedChrome(perRoute);
1291
+ const routes = trimmed.map(({ route, findings }) => ({
1292
+ route,
1293
+ findings,
1294
+ pass: comparePass(findings)
1295
+ }));
1296
+ return {
1297
+ routes,
1298
+ sharedChrome,
1299
+ pass: comparePass(sharedChrome) && routes.every((route) => route.pass)
1300
+ };
1301
+ } finally {
1302
+ if (ownsBrowser) await browser.close();
1303
+ }
1304
+ };
1305
+ //#endregion
1306
+ export { prepareForScreenshot as A, accessibilityAxis as C, MOBILE_VIEWPORT as D, DESKTOP_VIEWPORT as E, capture as O, briefConformanceAxis as S, DEFAULT_VIEWPORTS as T, REGION_STATES as _, runAxes as a, comparePass as b, visualBrandAxis as c, captureSnapshot as d, loadSnapshot as f, ExpectedAbsentRule as g, CompareSnapshot as h, aggregatePass as i, capturePage as k, runtimeHealthAxis as l, CompareConfig as m, routeSnapshotFile as n, validate as o, saveSnapshot as p, dedupeSharedChrome as r, ALL_AXES as s, compareRoutes as t, compareAxis as u, Region as v, serveStatic as w, diffSnapshots as x, extractRegions as y };
1307
+
1308
+ //# sourceMappingURL=runner-ZOonToZg.js.map