@keboola/validate-ui 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/cli.cjs +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -53
- package/dist/index.d.ts +29 -53
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/{runner-Dhb1FmeB.cjs → runner-BwEjSueH.cjs} +52 -92
- package/dist/runner-BwEjSueH.cjs.map +1 -0
- package/dist/{runner-D5dD5L7c.js → runner-DH04MitR.js} +52 -92
- package/dist/runner-DH04MitR.js.map +1 -0
- package/package.json +5 -4
- package/dist/runner-D5dD5L7c.js.map +0 -1
- package/dist/runner-Dhb1FmeB.cjs.map +0 -1
|
@@ -275,27 +275,15 @@ const serveStatic = async (rootDir) => {
|
|
|
275
275
|
/**
|
|
276
276
|
* Elements whose content is cut off with no way for the user to reveal it.
|
|
277
277
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
* `800` rendered as `8`. Every other axis scored it 100.
|
|
282
|
-
*
|
|
283
|
-
* Deliberate truncation is not a finding. Text clipped with `text-overflow: ellipsis`
|
|
284
|
-
* announces itself, and a scrollable box (`overflow: auto | scroll`) can be scrolled to
|
|
285
|
-
* the rest. Only silent loss is reported — `overflow: hidden | clip` with no ellipsis, and
|
|
286
|
-
* `<input>`s whose value is wider than the box that holds it (a single-line input is never
|
|
287
|
-
* scrollable by eye; the user has to select into it to discover the rest). A `<textarea>`
|
|
288
|
-
* is judged like any other box: it renders a real scrollbar when its content overflows
|
|
289
|
-
* horizontally, so it is only a finding once something hides the overflow.
|
|
278
|
+
* - Not reported: `text-overflow: ellipsis`, scrollable boxes (`overflow: auto | scroll`).
|
|
279
|
+
* - Reported: `overflow: hidden | clip` without ellipsis, and `<input>`s wider than their box.
|
|
280
|
+
* - A `<textarea>` scrolls, so it is judged like any other box.
|
|
290
281
|
*/
|
|
291
|
-
/**
|
|
282
|
+
/** Absorbs sub-pixel and rounding noise. */
|
|
292
283
|
const CLIP_TOLERANCE_PX = 2;
|
|
293
|
-
/**
|
|
284
|
+
/** Keeps one broken component from flooding the report. */
|
|
294
285
|
const MAX_REPORTED = 10;
|
|
295
|
-
/**
|
|
296
|
-
* Runs in the page. Kept self-contained (no imports, no outer-scope references) because it
|
|
297
|
-
* is serialised into the browser context.
|
|
298
|
-
*/
|
|
286
|
+
/** Runs in the page: serialised, so no imports or outer-scope references. */
|
|
299
287
|
/* c8 ignore start -- executes in the browser, not under node coverage */
|
|
300
288
|
const findClippedElements = ({ tolerance, max }) => {
|
|
301
289
|
const describe = (el) => {
|
|
@@ -343,16 +331,12 @@ const findClippedElements = ({ tolerance, max }) => {
|
|
|
343
331
|
return results;
|
|
344
332
|
};
|
|
345
333
|
/* c8 ignore stop */
|
|
346
|
-
/** Format one clipped element as a finding. */
|
|
347
334
|
const toFinding$1 = (clipped) => ({
|
|
348
335
|
message: `Content is cut off with no way to reveal it (${clipped.selector})`,
|
|
349
336
|
severity: "serious",
|
|
350
337
|
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\`.`
|
|
351
338
|
});
|
|
352
|
-
/**
|
|
353
|
-
* Report every element whose content is silently cut off. Returns no findings when the
|
|
354
|
-
* page cannot be inspected, so a caller can always spread the result.
|
|
355
|
-
*/
|
|
339
|
+
/** Report every element whose content is silently cut off; `[]` when the page can't be inspected. */
|
|
356
340
|
const clippedTextFindings = async (page) => {
|
|
357
341
|
try {
|
|
358
342
|
return (await page.evaluate(findClippedElements, {
|
|
@@ -423,7 +407,6 @@ const accessibilityAxis = {
|
|
|
423
407
|
};
|
|
424
408
|
//#endregion
|
|
425
409
|
//#region src/axes/vlm-provider.ts
|
|
426
|
-
/** Concatenate a message's text blocks. */
|
|
427
410
|
const textFromMessage = (message) => message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
428
411
|
const makeProvider = (backend, clientOptions) => {
|
|
429
412
|
const client = new Anthropic(clientOptions);
|
|
@@ -454,25 +437,12 @@ const makeProvider = (backend, clientOptions) => {
|
|
|
454
437
|
};
|
|
455
438
|
const nonEmpty = (value) => value !== void 0 && value !== "";
|
|
456
439
|
/**
|
|
457
|
-
* Pick a VLM backend from the environment
|
|
458
|
-
* configured (the axis then skips gracefully).
|
|
459
|
-
*
|
|
460
|
-
* Preference order mirrors how kai-agent routes Claude for internal/team use
|
|
461
|
-
* (`apps/kai-agent/src/services/entrypoint-builder.ts`): the Keboola LLM path
|
|
462
|
-
* points the Anthropic SDK at a Keboola-hosted proxy via `baseURL` and
|
|
463
|
-
* authenticates with a Keboola-minted token sent as `x-api-key` (the SDK's
|
|
464
|
-
* `apiKey`). kai-agent injects that pair through the SDK-native
|
|
465
|
-
* `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` env vars; we accept those, and
|
|
466
|
-
* prefer the explicit `VALIDATE_UI_LLM_BASE_URL` / `VALIDATE_UI_LLM_TOKEN`
|
|
467
|
-
* (with `KBC_TOKEN` as a Keboola-convention alias) when set. The proxy path is
|
|
468
|
-
* preferred because the team plan has no dedicated raw key.
|
|
440
|
+
* Pick a VLM backend from the environment; `undefined` makes the axis skip.
|
|
469
441
|
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* `Anthropic` constructor explicitly, so resolution never reads ambient
|
|
475
|
-
* `process.env` behind the injected `env` arg.
|
|
442
|
+
* 1. Keboola LLM proxy (`baseURL` + Keboola token as `apiKey`), as kai-agent's
|
|
443
|
+
* `entrypoint-builder.ts` does — the team plan has no raw key.
|
|
444
|
+
* 2. Otherwise a bare `ANTHROPIC_API_KEY` selects raw Anthropic.
|
|
445
|
+
* - Reads only the injected `env`, never ambient `process.env`.
|
|
476
446
|
*/
|
|
477
447
|
const resolveVlmProvider = (env = process.env) => {
|
|
478
448
|
const baseUrl = env.VALIDATE_UI_LLM_BASE_URL ?? env.ANTHROPIC_BASE_URL;
|
|
@@ -710,14 +680,10 @@ const DEFAULT_ERROR_MARKERS = [
|
|
|
710
680
|
"try again"
|
|
711
681
|
];
|
|
712
682
|
/**
|
|
713
|
-
* Reduce the live page to its diffable regions, in the browser.
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
* precedence
|
|
718
|
-
* (`data-region` → `data-testid` → role → `aria-label` → nearest heading), its
|
|
719
|
-
* state from `data-state`/text markers, and its values as normalized numeric
|
|
720
|
-
* tokens. Symmetric across old and new so the two sides diff cleanly.
|
|
683
|
+
* Reduce the live page to its diffable regions, in the browser.
|
|
684
|
+
*
|
|
685
|
+
* - Regions never overlap, so no value is counted twice.
|
|
686
|
+
* - Must stay symmetric across old and new so the two sides diff cleanly.
|
|
721
687
|
*/
|
|
722
688
|
const extractRegions = async (page, config = {}) => {
|
|
723
689
|
const emptyMarkers = [...DEFAULT_EMPTY_MARKERS, ...config.emptyStateMarkers ?? []];
|
|
@@ -890,14 +856,10 @@ const skip = (message) => ({
|
|
|
890
856
|
}]
|
|
891
857
|
});
|
|
892
858
|
/**
|
|
893
|
-
* A5 · Semantic old-vs-new compare verdict
|
|
859
|
+
* A5 · Semantic old-vs-new compare verdict.
|
|
894
860
|
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
* are unaffected. Extracts the "new" snapshot from the live `context.page`, then
|
|
898
|
-
* runs the pure {@link diffSnapshots}: deltas are localized per region, declared
|
|
899
|
-
* expected-absent deltas are suppressed (not dropped), and a region that degraded
|
|
900
|
-
* to an empty/error state is reported informationally rather than as value loss.
|
|
861
|
+
* - Passing skip unless `context.comparison` (an "old" {@link CompareSnapshot}) is supplied.
|
|
862
|
+
* - Diffs it against a snapshot of the live `context.page` via {@link diffSnapshots}.
|
|
901
863
|
*/
|
|
902
864
|
const compareAxis = {
|
|
903
865
|
name: "compare",
|
|
@@ -916,12 +878,18 @@ const compareAxis = {
|
|
|
916
878
|
};
|
|
917
879
|
//#endregion
|
|
918
880
|
//#region src/axes/runtime-health.ts
|
|
919
|
-
const
|
|
920
|
-
"something went wrong",
|
|
881
|
+
const OVERLAY_MARKERS = [
|
|
921
882
|
"vite-error-overlay",
|
|
922
883
|
"vite-plugin-checker-error-overlay",
|
|
923
884
|
"react-error-overlay"
|
|
924
885
|
];
|
|
886
|
+
const ERROR_TEXT_MARKERS = ["something went wrong"];
|
|
887
|
+
/**
|
|
888
|
+
* The serialized DOM minus content that is never rendered as page content —
|
|
889
|
+
* `<script>`, `<style>`, `<template>`, `<noscript>` bodies and comments — so a
|
|
890
|
+
* marker named inside them is not mistaken for a rendered overlay.
|
|
891
|
+
*/
|
|
892
|
+
const stripNonRendered = (dom) => dom.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|template|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
|
|
925
893
|
const pageErrorFindings = (console) => console.filter((entry) => entry.type === "pageerror").map((entry) => ({
|
|
926
894
|
message: `Uncaught page error: ${entry.text}`,
|
|
927
895
|
severity: "critical",
|
|
@@ -990,10 +958,17 @@ const networkFindings = (network) => {
|
|
|
990
958
|
}
|
|
991
959
|
return [...byUrl.values()];
|
|
992
960
|
};
|
|
993
|
-
/**
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
961
|
+
/**
|
|
962
|
+
* An error overlay in the DOM, or error-boundary copy in the rendered text,
|
|
963
|
+
* means a crashed render rather than real content. Overlays are matched on the
|
|
964
|
+
* DOM with script/style bodies removed; error copy on `renderedText` (the live
|
|
965
|
+
* `innerText`, which also skips hidden elements), falling back to the stripped
|
|
966
|
+
* DOM only when an artifact has no live text.
|
|
967
|
+
*/
|
|
968
|
+
const errorMarkerFinding = (dom, renderedText) => {
|
|
969
|
+
const markup = stripNonRendered(dom).toLowerCase();
|
|
970
|
+
const visible = (renderedText ?? markup).toLowerCase();
|
|
971
|
+
const marker = OVERLAY_MARKERS.find((m) => markup.includes(m)) ?? ERROR_TEXT_MARKERS.find((m) => visible.includes(m));
|
|
997
972
|
return marker === void 0 ? null : {
|
|
998
973
|
message: "Error boundary / error overlay rendered instead of the app",
|
|
999
974
|
severity: "critical",
|
|
@@ -1024,7 +999,7 @@ const runtimeHealthAxis = {
|
|
|
1024
999
|
...consoleErrorFindings(artifact.console),
|
|
1025
1000
|
...networkFindings(artifact.network)
|
|
1026
1001
|
];
|
|
1027
|
-
const errorMarker = errorMarkerFinding(artifact.dom);
|
|
1002
|
+
const errorMarker = errorMarkerFinding(artifact.dom, artifact.renderedText);
|
|
1028
1003
|
if (errorMarker !== null) findings.push(errorMarker);
|
|
1029
1004
|
const whiteScreen = whiteScreenFinding(artifact.renderedText);
|
|
1030
1005
|
if (whiteScreen !== null) findings.push(whiteScreen);
|
|
@@ -1044,7 +1019,7 @@ const RESKIN_THRESHOLD = .005;
|
|
|
1044
1019
|
const DRIFT_PIXEL_THRESHOLD = .1;
|
|
1045
1020
|
const RESKIN_PIXEL_THRESHOLD = .02;
|
|
1046
1021
|
const DEFAULT_ALT_BRAND = "example-customer";
|
|
1047
|
-
/**
|
|
1022
|
+
/** Pixel mismatch ratio of two PNGs (0 = identical, 1 = every pixel differs). */
|
|
1048
1023
|
const pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
|
|
1049
1024
|
const imgA = PNG.sync.read(Buffer.from(a));
|
|
1050
1025
|
const imgB = PNG.sync.read(Buffer.from(b));
|
|
@@ -1078,16 +1053,9 @@ const baselineFinding = (screenshot, baseline) => {
|
|
|
1078
1053
|
/**
|
|
1079
1054
|
* Whether the alt-brand capture actually activated that brand.
|
|
1080
1055
|
*
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
* brand switcher into every generated app is not something a scaffold should impose.
|
|
1085
|
-
*
|
|
1086
|
-
* Without this check, such an app captures the same brand twice, and the identical
|
|
1087
|
-
* screenshots are reported as "chrome did not re-skin" — a false failure for code that is
|
|
1088
|
-
* entirely correct. The brand is nominated via `data-brand` on the root element (written
|
|
1089
|
-
* by `@keboola/design`'s ThemeProvider), so absence of the requested id means the request
|
|
1090
|
-
* was ignored, not that the chrome failed to re-skin.
|
|
1056
|
+
* - Most apps ignore `?brand=<id>`: brand is build configuration, not a runtime switch.
|
|
1057
|
+
* - `@keboola/design`'s ThemeProvider writes `data-brand`; its absence means "not assessed",
|
|
1058
|
+
* not "did not re-skin".
|
|
1091
1059
|
*/
|
|
1092
1060
|
const altBrandWasApplied = (altDom, brand) => altDom.includes(`data-brand="${brand}"`);
|
|
1093
1061
|
const altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
|
|
@@ -1105,10 +1073,9 @@ const altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
|
|
|
1105
1073
|
return null;
|
|
1106
1074
|
};
|
|
1107
1075
|
/**
|
|
1108
|
-
* Non-fatal `moderate` findings for a brand's interactive
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
* build-time warning here, not a runtime axe surprise. Empty for an AA-safe brand.
|
|
1076
|
+
* Non-fatal `moderate` findings for a brand's interactive pairings below WCAG-AA.
|
|
1077
|
+
*
|
|
1078
|
+
* - Surfaces `@keboola/brand-registry`'s check before axe would hit it at runtime.
|
|
1112
1079
|
*/
|
|
1113
1080
|
const interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).map((warning) => ({
|
|
1114
1081
|
message: `brand '${brand.id}' ${warning.message}`,
|
|
@@ -1118,10 +1085,8 @@ const interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).m
|
|
|
1118
1085
|
/**
|
|
1119
1086
|
* Horizontal overflow, weighed by the width it was measured at.
|
|
1120
1087
|
*
|
|
1121
|
-
* -
|
|
1122
|
-
*
|
|
1123
|
-
* phone-first rule is only a rule if a gate can go red on it.
|
|
1124
|
-
* - At desktop widths it stays `moderate`, unchanged.
|
|
1088
|
+
* - Phone viewport: `critical` — blocking is what makes phone-first a rule.
|
|
1089
|
+
* - Desktop: `moderate`.
|
|
1125
1090
|
*/
|
|
1126
1091
|
const overflowFinding = async (page, viewport) => {
|
|
1127
1092
|
if (!await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)) return null;
|
|
@@ -1135,15 +1100,10 @@ const overflowFinding = async (page, viewport) => {
|
|
|
1135
1100
|
};
|
|
1136
1101
|
};
|
|
1137
1102
|
/**
|
|
1138
|
-
* A4 · Visual & brand-correctness verdict
|
|
1103
|
+
* A4 · Visual & brand-correctness verdict.
|
|
1139
1104
|
*
|
|
1140
|
-
* Baseline diff
|
|
1141
|
-
*
|
|
1142
|
-
* `context.captureUnderBrand`: the chrome must visibly re-skin under an
|
|
1143
|
-
* alternate brand. Flags layout overflow, blocking at phone widths. Each check
|
|
1144
|
-
* degrades to no finding when its input is absent. (Asserting categorical colors
|
|
1145
|
-
* — Badge/Alert/ModalIcon — stay fixed pixel-wise is future work; see
|
|
1146
|
-
* `altBrandFinding`.)
|
|
1105
|
+
* - Baseline pixel diff; alt brand must visibly re-skin the chrome; layout overflow.
|
|
1106
|
+
* - Each check yields no finding when its input is absent.
|
|
1147
1107
|
*/
|
|
1148
1108
|
const visualBrandAxis = {
|
|
1149
1109
|
name: "visual-brand",
|
|
@@ -1377,4 +1337,4 @@ const compareRoutes = async (options) => {
|
|
|
1377
1337
|
//#endregion
|
|
1378
1338
|
export { DEFAULT_VIEWPORTS as A, accessibilityAxis as C, VIEWPORT_PRESETS as D, PHONE_VIEWPORT as E, prepareForScreenshot as F, MOBILE_VIEWPORT as M, capture as N, isPhoneViewport as O, capturePage as P, briefConformanceAxis as S, PHONE_MAX_WIDTH 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, DESKTOP_VIEWPORT as j, parseViewport 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 };
|
|
1379
1339
|
|
|
1380
|
-
//# sourceMappingURL=runner-
|
|
1340
|
+
//# sourceMappingURL=runner-DH04MitR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner-DH04MitR.js","names":["DEFAULT_TIMEOUT_MS","toFinding","skip"],"sources":["../src/prepare-screenshot.ts","../src/capture.ts","../src/viewport.ts","../src/serve.ts","../src/axes/clipped-text.ts","../src/axes/accessibility.ts","../src/axes/vlm-provider.ts","../src/axes/brief-conformance.ts","../src/compare/diff.ts","../src/compare/regions.ts","../src/compare/types.ts","../src/compare/snapshot.ts","../src/axes/compare.ts","../src/axes/runtime-health.ts","../src/axes/visual-brand.ts","../src/axes/index.ts","../src/aggregate.ts","../src/compare/dedup.ts","../src/compare/runner.ts"],"sourcesContent":["import type { Page } from 'playwright';\n\n// Ported from @keboola/e2e-testing/src/visual-helpers.ts. That package is\n// workspace-internal (private), so validate-ui reimplements the small\n// deterministic-screenshot routine rather than depending on it.\n\nconst disableAnimations = async (page: Page): Promise<void> => {\n await page.addStyleTag({\n content: `*, *::before, *::after {\n animation-duration: 0s !important;\n animation-delay: 0s !important;\n transition-duration: 0s !important;\n transition-delay: 0s !important;\n caret-color: transparent !important;\n }`,\n });\n};\n\nconst parkMouseOffElements = async (page: Page): Promise<void> => {\n await page.mouse.move(0, 0);\n};\n\nconst waitForFonts = async (page: Page): Promise<void> => {\n await page.evaluate(() => document.fonts.ready.then(() => undefined));\n};\n\n/**\n * Settle the page before a screenshot: freeze animations first (so parking the\n * mouse can't trigger a transition), park the cursor off any element, and wait\n * for web fonts. Deterministic output makes visual diffs meaningful.\n */\nexport const prepareForScreenshot = async (page: Page): Promise<void> => {\n await disableAnimations(page);\n await parkMouseOffElements(page);\n await waitForFonts(page);\n};\n","import { type Browser, type BrowserContext, chromium, type Page } from 'playwright';\n\nimport { prepareForScreenshot } from './prepare-screenshot';\nimport type { CaptureArtifact, ConsoleEntry, NetworkEntry, Viewport } from './types';\n\nexport const DESKTOP_VIEWPORT: Viewport = { label: 'desktop', width: 1280, height: 800 };\nexport const MOBILE_VIEWPORT: Viewport = { label: 'mobile', width: 390, height: 844 };\n\nexport const DEFAULT_VIEWPORTS: Viewport[] = [DESKTOP_VIEWPORT, MOBILE_VIEWPORT];\n\nconst DEFAULT_TIMEOUT_MS = 30_000;\n\nconst attachRecorders = (page: Page): { console: ConsoleEntry[]; network: NetworkEntry[] } => {\n const consoleEntries: ConsoleEntry[] = [];\n const networkEntries: NetworkEntry[] = [];\n\n page.on('console', (message) => {\n consoleEntries.push({\n type: message.type(),\n text: message.text(),\n location: message.location().url || undefined,\n });\n });\n page.on('pageerror', (error) => {\n consoleEntries.push({ type: 'pageerror', text: error.message });\n });\n page.on('requestfailed', (request) => {\n networkEntries.push({\n url: request.url(),\n method: request.method(),\n status: 0,\n failure: request.failure()?.errorText,\n });\n });\n page.on('response', (response) => {\n networkEntries.push({\n url: response.url(),\n method: response.request().method(),\n status: response.status(),\n });\n });\n\n return { console: consoleEntries, network: networkEntries };\n};\n\n/**\n * Render `url` in an already-open page at `viewport` and capture ground truth.\n * Recorders are attached before navigation so nothing is missed. The caller\n * owns the page lifecycle — this is the building block {@link capture} and the\n * orchestrator reuse.\n */\nexport const capturePage = async (\n page: Page,\n url: string,\n viewport: Viewport,\n timeoutMs: number = DEFAULT_TIMEOUT_MS,\n): Promise<CaptureArtifact> => {\n const recorders = attachRecorders(page);\n await page.setViewportSize({ width: viewport.width, height: viewport.height });\n await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });\n await prepareForScreenshot(page);\n const screenshot = await page.screenshot({ fullPage: true });\n const dom = await page.content();\n const renderedText = (await page.evaluate(() => document.body.innerText)).trim();\n return {\n url,\n viewport,\n screenshot,\n dom,\n renderedText,\n console: recorders.console,\n network: recorders.network,\n };\n};\n\nexport type CaptureOptions = {\n viewports?: Viewport[];\n /** Reuse an existing browser instead of launching (and closing) one. */\n browser?: Browser;\n timeoutMs?: number;\n};\n\n/**\n * Launch a headless browser (unless one is supplied), render `url` at each\n * viewport in a fresh context, and return one artifact per viewport.\n */\nexport const capture = async (\n url: string,\n options: CaptureOptions = {},\n): Promise<CaptureArtifact[]> => {\n const viewports = options.viewports ?? DEFAULT_VIEWPORTS;\n const browser = options.browser ?? (await chromium.launch());\n const ownsBrowser = !options.browser;\n try {\n const artifacts: CaptureArtifact[] = [];\n for (const viewport of viewports) {\n const context: BrowserContext = await browser.newContext({\n viewport: { width: viewport.width, height: viewport.height },\n });\n const page = await context.newPage();\n try {\n artifacts.push(await capturePage(page, url, viewport, options.timeoutMs));\n } finally {\n await context.close();\n }\n }\n return artifacts;\n } finally {\n if (ownsBrowser) await browser.close();\n }\n};\n","import { DESKTOP_VIEWPORT, MOBILE_VIEWPORT } from './capture';\nimport type { Viewport } from './types';\n\n/**\n * The phone-first baseline every Keboola screen is designed against.\n *\n * - 375×812 is the narrowest width still in meaningful use; a layout that survives it\n * survives every wider phone.\n * - Distinct from {@link MOBILE_VIEWPORT} (390×844), which is one of `capture`'s two\n * default capture sizes rather than a bar anything is held to.\n */\nexport const PHONE_VIEWPORT: Viewport = { label: 'phone', width: 375, height: 812 };\n\n/** Names `--viewport` accepts in place of a `WIDTHxHEIGHT` pair. */\nexport const VIEWPORT_PRESETS: Readonly<Record<string, Viewport>> = {\n desktop: DESKTOP_VIEWPORT,\n mobile: MOBILE_VIEWPORT,\n phone: PHONE_VIEWPORT,\n};\n\n/**\n * Widths below this count as a phone.\n *\n * - 768 is the tablet breakpoint: at or above it a page has room for a desktop-shaped\n * layout, below it the content has one column to live in.\n * - Drives severity, not layout — see {@link isPhoneViewport}.\n */\nexport const PHONE_MAX_WIDTH = 768;\n\n/**\n * Whether a viewport is phone-class.\n *\n * - Horizontal overflow here is a broken page, not a cosmetic slip: the reader has to\n * scroll sideways to read a sentence. Axes weigh findings accordingly.\n */\nexport const isPhoneViewport = (viewport: Viewport): boolean => viewport.width < PHONE_MAX_WIDTH;\n\nconst DIMENSIONS = /^(\\d+)x(\\d+)$/;\n\n/**\n * Read a `--viewport` value: a preset name, or `WIDTHxHEIGHT` in CSS pixels.\n *\n * - Throws on anything else rather than falling back to a default — a typo'd size would\n * otherwise silently evaluate the viewport the caller was trying to move off.\n * - Zero in either dimension is rejected for the same reason; playwright accepts it and\n * renders nothing.\n */\nexport const parseViewport = (spec: string): Viewport => {\n const trimmed = spec.trim();\n const preset = VIEWPORT_PRESETS[trimmed.toLowerCase()];\n if (preset !== undefined) return preset;\n\n const match = DIMENSIONS.exec(trimmed);\n const width = Number(match?.[1]);\n const height = Number(match?.[2]);\n if (match === null || width === 0 || height === 0) {\n throw new Error(\n `--viewport must be WIDTHxHEIGHT (e.g. 375x812) or one of ${Object.keys(VIEWPORT_PRESETS).join(', ')}, got \"${spec}\"`,\n );\n }\n return { label: `${width}x${height}`, width, height };\n};\n","import { createReadStream } from 'node:fs';\nimport { stat } from 'node:fs/promises';\nimport { createServer, type Server } from 'node:http';\nimport { extname, join, normalize, resolve, sep } from 'node:path';\n\nconst CONTENT_TYPES: Record<string, string> = {\n '.html': 'text/html; charset=utf-8',\n '.js': 'text/javascript; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.json': 'application/json; charset=utf-8',\n '.svg': 'image/svg+xml',\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.woff2': 'font/woff2',\n '.woff': 'font/woff',\n};\n\nexport type StaticServer = {\n url: string;\n close: () => Promise<void>;\n};\n\n/**\n * Serve a built SPA directory (e.g. `apps/boilerplate/dist`) on an ephemeral\n * port with client-side-routing fallback to `index.html`, so a validator can\n * render any client route without a dev server. Node stdlib only — no runtime\n * dependency added.\n */\nexport const serveStatic = async (rootDir: string): Promise<StaticServer> => {\n const indexPath = join(rootDir, 'index.html');\n\n const rootResolved = resolve(rootDir);\n\n const server: Server = createServer((request, response) => {\n const send = (filePath: string, status = 200): void => {\n response.writeHead(status, {\n 'content-type': CONTENT_TYPES[extname(filePath)] ?? 'application/octet-stream',\n });\n const stream = createReadStream(filePath);\n stream.on('error', (error) => {\n console.error(`serveStatic: failed to read ${filePath}:`, error);\n if (!response.headersSent) response.writeHead(500);\n response.end();\n });\n stream.pipe(response);\n };\n const notFound = (): void => {\n response.writeHead(404);\n response.end();\n };\n\n let rawPath: string;\n try {\n rawPath = decodeURIComponent((request.url ?? '/').split('?')[0] ?? '/');\n } catch {\n // Malformed percent-encoding — reject rather than crash the request handler.\n response.writeHead(400);\n response.end();\n return;\n }\n\n const relative = normalize(rawPath).replace(/^(\\.\\.[/\\\\])+/, '');\n const candidate = join(rootDir, relative);\n const candidateResolved = resolve(candidate);\n // Enforce (not just argue) that the resolved path stays inside the root.\n if (candidateResolved !== rootResolved && !candidateResolved.startsWith(rootResolved + sep)) {\n notFound();\n return;\n }\n\n stat(candidate)\n .then((info) => {\n send(info.isDirectory() ? indexPath : candidate);\n })\n .catch(() => {\n // Missing path: a client route (no extension) gets the SPA shell; a missing\n // asset (has an extension) is a real 404 — so the network recorder sees it\n // instead of a 200 index.html masking a broken build.\n if (extname(candidate) === '') {\n send(indexPath);\n return;\n }\n notFound();\n });\n });\n\n await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));\n const address = server.address();\n if (address === null || typeof address === 'string') {\n throw new Error('serveStatic: failed to bind an ephemeral port');\n }\n\n return {\n url: `http://127.0.0.1:${address.port}`,\n close: () =>\n new Promise<void>((resolve, reject) => {\n server.close((error) => (error ? reject(error) : resolve()));\n }),\n };\n};\n","import type { Page } from 'playwright';\n\nimport type { Finding } from '../types';\n\n/**\n * Elements whose content is cut off with no way for the user to reveal it.\n *\n * - Not reported: `text-overflow: ellipsis`, scrollable boxes (`overflow: auto | scroll`).\n * - Reported: `overflow: hidden | clip` without ellipsis, and `<input>`s wider than their box.\n * - A `<textarea>` scrolls, so it is judged like any other box.\n */\n\n/** Absorbs sub-pixel and rounding noise. */\nconst CLIP_TOLERANCE_PX = 2;\n\n/** Keeps one broken component from flooding the report. */\nconst MAX_REPORTED = 10;\n\nexport type ClippedElement = {\n selector: string;\n text: string;\n visibleWidth: number;\n contentWidth: number;\n isInput: boolean;\n};\n\n/** Runs in the page: serialised, so no imports or outer-scope references. */\n/* c8 ignore start -- executes in the browser, not under node coverage */\nconst findClippedElements = ({\n tolerance,\n max,\n}: {\n tolerance: number;\n max: number;\n}): ClippedElement[] => {\n // Accessible name / id first: utility classes identify every grid cell identically.\n const describe = (el: Element): string => {\n const tag = el.tagName.toLowerCase();\n const label = el.getAttribute('aria-label');\n if (label) return `${tag}[aria-label=\"${label}\"]`;\n if (el.id) return `${tag}#${el.id}`;\n const name = el.getAttribute('name');\n if (name) return `${tag}[name=\"${name}\"]`;\n const cls =\n typeof el.className === 'string' && el.className.trim()\n ? `.${el.className.trim().split(/\\s+/).slice(0, 2).join('.')}`\n : '';\n return `${tag}${cls}`;\n };\n\n const results: ClippedElement[] = [];\n // Document order visits ancestors first; a reported ancestor's descendants are duplicates.\n const reported = new Set<Element>();\n\n const hasReportedAncestor = (el: Element): boolean => {\n let parent = el.parentElement;\n while (parent) {\n if (reported.has(parent)) return true;\n parent = parent.parentElement;\n }\n return false;\n };\n\n for (const el of document.querySelectorAll<HTMLElement>('*')) {\n if (results.length >= max) break;\n if (hasReportedAncestor(el)) continue;\n\n const style = window.getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {\n continue;\n }\n\n // Skips visually-hidden (`sr-only`) text: a clipped 1px box by design.\n if (el.clientWidth <= 1 || el.clientHeight <= 1) continue;\n\n const overflowX = style.overflowX;\n // - Only `<input>` skips the scroll / ellipsis tests; a `<textarea>` scrolls.\n // - Both read `.value`: a textarea's `textContent` is its default value.\n const isFormValue = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;\n const isSingleLineInput = el instanceof HTMLInputElement;\n\n // A scrollable box is not clipped — the content is reachable.\n if (!isSingleLineInput && overflowX !== 'hidden' && overflowX !== 'clip') continue;\n // Ellipsis is an explicit, visible \"there is more\" affordance.\n if (!isSingleLineInput && style.textOverflow === 'ellipsis') continue;\n\n const overflowBy = el.scrollWidth - el.clientWidth;\n if (overflowBy <= tolerance) continue;\n\n const text = isFormValue\n ? (el as HTMLInputElement | HTMLTextAreaElement).value\n : (el.textContent ?? '').trim();\n if (!text) continue;\n\n reported.add(el);\n results.push({\n selector: describe(el),\n text: text.length > 40 ? `${text.slice(0, 40)}…` : text,\n visibleWidth: Math.round(el.clientWidth),\n contentWidth: Math.round(el.scrollWidth),\n isInput: isFormValue,\n });\n }\n\n return results;\n};\n/* c8 ignore stop */\n\nconst toFinding = (clipped: ClippedElement): Finding => ({\n message: `Content is cut off with no way to reveal it (${clipped.selector})`,\n severity: 'serious',\n detail:\n `${clipped.isInput ? 'Input value' : 'Text'} \"${clipped.text}\" needs ` +\n `${clipped.contentWidth}px but has ${clipped.visibleWidth}px. ` +\n 'Widen the element, or make the truncation explicit with `text-overflow: ellipsis`.',\n});\n\n/** Report every element whose content is silently cut off; `[]` when the page can't be inspected. */\nexport const clippedTextFindings = async (page: Page): Promise<Finding[]> => {\n try {\n const clipped = await page.evaluate(findClippedElements, {\n tolerance: CLIP_TOLERANCE_PX,\n max: MAX_REPORTED,\n });\n\n return clipped.map(toFinding);\n } catch (error) {\n // Additive to axe: a lost page must not take the a11y axis down.\n console.error('clipped-text: could not inspect the page', error);\n return [];\n }\n};\n","import axe from 'axe-core';\n\nimport type { Axis, Finding, Severity, Verdict } from '../types';\n\nimport { clippedTextFindings } from './clipped-text';\n\n/** Minimal shape of an axe result node — only the fields we read. */\ntype AxeNode = { target: string[] };\n\n/** Minimal shape of an axe violation — only the fields we read. */\ntype AxeViolation = {\n id: string;\n impact: Severity | null;\n help: string;\n description: string;\n tags: string[];\n nodes: AxeNode[];\n};\n\n/** Minimal shape of the axe.run result at the page-evaluate boundary. */\ntype AxeResults = { violations: AxeViolation[] };\n\n/** axe.run injected onto the page global by evaluating `axe.source`. */\ntype AxeGlobal = { run: (context: Document) => Promise<AxeResults> };\n\nconst IMPACTS: readonly Severity[] = ['critical', 'serious', 'moderate', 'minor'];\n\nconst toSeverity = (impact: Severity | null): Severity =>\n impact && IMPACTS.includes(impact) ? impact : 'minor';\n\n/** First `wcag*` tag if present, else the first tag, else undefined. */\nconst wcagTag = (tags: string[]): string | undefined =>\n tags.find((tag) => tag.startsWith('wcag')) ?? tags[0];\n\nconst toFinding = (violation: AxeViolation): Finding => {\n const targets = violation.nodes\n .map((node) => node.target.join(' '))\n .filter((target) => target.length > 0)\n .join(', ');\n const wcag = wcagTag(violation.tags);\n return {\n message: `${violation.help || violation.description}${targets ? ` (${targets})` : ''}`,\n severity: toSeverity(violation.impact),\n ref: [wcag, violation.id].filter(Boolean).join(' · '),\n };\n};\n\n/**\n * A3 · Accessibility verdict — UT-4494.\n *\n * Injects axe-core into `context.page` and maps each violation to a Finding\n * (WCAG tag + rule id as `ref`, axe impact as severity). Pass rule: a page\n * passes when no violation has impact `critical` or `serious` — moderate/minor\n * violations are reported but don't fail the axis. Falls back to a clear\n * finding rather than throwing when no live page is provided.\n */\n/** Not-assessed outcome — a11y can't fail if it never ran. */\nconst notAssessed = (message: string): Verdict => ({\n axis: 'a11y',\n pass: true,\n findings: [{ message, severity: 'minor' }],\n});\n\nexport const accessibilityAxis: Axis = {\n name: 'a11y',\n run: async (context) => {\n const { page } = context;\n // Degrade to a non-fatal skip (like the other axes) rather than failing the run.\n if (!page) return notAssessed('a11y skipped: no live page provided');\n\n // `page.evaluate`, not `addScriptTag`. The latter appends an inline `<script>`, which a target\n // page's `script-src` can block — and Playwright rejects on that, so the guard below never sees\n // it and the axis crashes. Evaluation goes through CDP, which a page's CSP does not govern.\n await page.evaluate(axe.source);\n // Injection can still leave window.axe undefined — detect that in-page and surface a clear\n // finding instead of a \"Cannot read properties of undefined\" crash.\n const results = await page.evaluate<AxeResults | null>(() => {\n const injected = (window as unknown as { axe?: AxeGlobal }).axe;\n return injected ? injected.run(document) : null;\n });\n if (results === null) {\n return notAssessed('a11y not assessed: axe-core failed to inject (page CSP?)');\n }\n\n // axe has no rule for content that is silently cut off, but unreadable content is an\n // a11y problem by any reasonable reading — and it is invisible to every other axis.\n const findings = [...results.violations.map(toFinding), ...(await clippedTextFindings(page))];\n const pass = !findings.some(\n (finding) => finding.severity === 'critical' || finding.severity === 'serious',\n );\n\n return { axis: 'a11y', pass, findings };\n },\n};\n","import Anthropic from '@anthropic-ai/sdk';\n\n/** Sends a screenshot + prompt to a vision model; backends differ only in auth and routing. */\nexport type VlmProvider = {\n /** Human-readable backend id, surfaced in skip/diagnostic findings. */\n readonly backend: 'raw-anthropic' | 'keboola-llm';\n judge: (input: { screenshotBase64: string; prompt: string; model: string }) => Promise<string>;\n};\n\nconst textFromMessage = (message: Anthropic.Message): string =>\n message.content\n .filter((block): block is Anthropic.TextBlock => block.type === 'text')\n .map((block) => block.text)\n .join('');\n\nconst makeProvider = (\n backend: VlmProvider['backend'],\n clientOptions: { apiKey: string; baseURL?: string },\n): VlmProvider => {\n const client = new Anthropic(clientOptions);\n return {\n backend,\n judge: async ({ screenshotBase64, prompt, model }) => {\n const message = await client.messages.create({\n model,\n max_tokens: 1024,\n messages: [\n {\n role: 'user',\n content: [\n {\n type: 'image',\n source: { type: 'base64', media_type: 'image/png', data: screenshotBase64 },\n },\n { type: 'text', text: prompt },\n ],\n },\n ],\n });\n return textFromMessage(message);\n },\n };\n};\n\nconst nonEmpty = (value: string | undefined): value is string =>\n value !== undefined && value !== '';\n\n/**\n * Pick a VLM backend from the environment; `undefined` makes the axis skip.\n *\n * 1. Keboola LLM proxy (`baseURL` + Keboola token as `apiKey`), as kai-agent's\n * `entrypoint-builder.ts` does — the team plan has no raw key.\n * 2. Otherwise a bare `ANTHROPIC_API_KEY` selects raw Anthropic.\n * - Reads only the injected `env`, never ambient `process.env`.\n */\nexport const resolveVlmProvider = (\n env: NodeJS.ProcessEnv = process.env,\n): VlmProvider | undefined => {\n const baseUrl = env.VALIDATE_UI_LLM_BASE_URL ?? env.ANTHROPIC_BASE_URL;\n const proxyToken = env.VALIDATE_UI_LLM_TOKEN ?? env.KBC_TOKEN ?? env.ANTHROPIC_API_KEY;\n\n if (nonEmpty(baseUrl) && nonEmpty(proxyToken)) {\n return makeProvider('keboola-llm', { baseURL: baseUrl, apiKey: proxyToken });\n }\n\n if (nonEmpty(env.ANTHROPIC_API_KEY)) {\n return makeProvider('raw-anthropic', { apiKey: env.ANTHROPIC_API_KEY });\n }\n\n return undefined;\n};\n","import { z } from 'zod';\n\nimport type { Axis, AxisContext, Finding, Verdict } from '../types';\n\nimport { resolveVlmProvider } from './vlm-provider';\n\n/** Structured judgement the VLM must return, validated before interpretation. */\nexport const JudgeResult = z.object({\n matchesBrief: z.boolean(),\n missingStates: z.array(z.string()),\n reasons: z.array(z.string()),\n});\nexport type JudgeResult = z.infer<typeof JudgeResult>;\n\nconst MODEL = process.env.VALIDATE_UI_MODEL ?? 'claude-sonnet-4-6';\n// DOM excerpt cap — enough structure for the judge, small enough to keep tokens sane.\nconst DOM_EXCERPT_LIMIT = 12_000;\n\n/**\n * Pure interpreter: turn a validated {@link JudgeResult} into a {@link Verdict}.\n * A brief mismatch is serious; each missing state is a moderate gap.\n */\nexport const interpretJudgeResult = (result: JudgeResult): Verdict => {\n const findings: Finding[] = [];\n\n if (!result.matchesBrief) {\n findings.push({\n message: `UI does not match the brief: ${result.reasons.join('; ')}`,\n severity: 'serious',\n });\n }\n\n for (const state of result.missingStates) {\n findings.push({ message: `missing state: ${state}`, severity: 'moderate' });\n }\n\n const pass = result.matchesBrief && result.missingStates.length === 0;\n return { axis: 'brief-conformance', pass, findings };\n};\n\nconst skip = (message: string): Verdict => ({\n axis: 'brief-conformance',\n pass: true,\n findings: [{ message, severity: 'minor' }],\n});\n\nconst RUBRIC = [\n 'You are judging whether a rendered UI conforms to the brief it was generated from.',\n 'Assess two things:',\n '1. matchesBrief — does the screenshot + DOM implement what the brief describes?',\n '2. missingStates — which UI states the brief implies are NOT represented.',\n ' Use short lowercase labels like \"empty\", \"loading\", \"error\".',\n 'Reply with ONLY a JSON object, no prose, matching exactly:',\n '{ \"matchesBrief\": boolean, \"missingStates\": string[], \"reasons\": string[] }',\n 'reasons: brief explanations for any mismatch (empty array if it matches).',\n].join('\\n');\n\nexport const briefConformanceAxis: Axis = {\n name: 'brief-conformance',\n run: async (context: AxisContext): Promise<Verdict> => {\n const brief = context.brief?.trim();\n if (brief === undefined || brief === '') {\n return skip('no brief provided; conformance not assessed');\n }\n\n const provider = resolveVlmProvider();\n if (provider === undefined) {\n return skip(\n 'brief-conformance skipped: no VLM backend configured ' +\n '(set VALIDATE_UI_LLM_BASE_URL + VALIDATE_UI_LLM_TOKEN for the Keboola LLM path, ' +\n 'or ANTHROPIC_API_KEY for a raw key)',\n );\n }\n\n const { artifact } = context;\n const screenshotBase64 = Buffer.from(artifact.screenshot).toString('base64');\n const domExcerpt = artifact.dom.slice(0, DOM_EXCERPT_LIMIT);\n\n const reply = await provider.judge({\n model: MODEL,\n screenshotBase64,\n prompt: `${RUBRIC}\\n\\nBrief:\\n${brief}\\n\\nDOM excerpt:\\n${domExcerpt}`,\n });\n\n try {\n const parsed = JudgeResult.parse(JSON.parse(reply));\n return interpretJudgeResult(parsed);\n } catch (error) {\n console.error('brief-conformance: failed to parse judge response', error);\n return {\n axis: 'brief-conformance',\n pass: false,\n findings: [{ message: 'could not parse judge response', severity: 'moderate' }],\n };\n }\n },\n};\n","import type {\n CompareConfig,\n CompareFinding,\n CompareSnapshot,\n ExpectedAbsentRule,\n Region,\n} from './types';\n\n/** Match a `*`-glob (or exact string) against a region key. */\nconst matchesGlob = (pattern: string, value: string): boolean => {\n if (!pattern.includes('*')) return pattern === value;\n const escaped = pattern.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&').replace(/\\*/g, '.*');\n return new RegExp(`^${escaped}$`).test(value);\n};\n\n/** Multiset difference: tokens present more often in `from` than in `to`. */\nconst multisetMissing = (from: string[], to: string[]): string[] => {\n const remaining = new Map<string, number>();\n for (const value of to) remaining.set(value, (remaining.get(value) ?? 0) + 1);\n const missing: string[] = [];\n for (const value of from) {\n const count = remaining.get(value) ?? 0;\n if (count > 0) remaining.set(value, count - 1);\n else missing.push(value);\n }\n return missing;\n};\n\n/** Stable signature of a delta — identical across routes for the same shared element. */\nconst signature = (regionKey: string, tokens: string[]): string =>\n `${regionKey}::${[...tokens].sort().join('|')}`;\n\n/** The rule that suppresses removing a whole region (region match, no value scope). */\nconst regionRemovalRule = (\n rules: ExpectedAbsentRule[],\n key: string,\n): ExpectedAbsentRule | undefined =>\n rules.find(\n (rule) =>\n rule.value === undefined && rule.region !== undefined && matchesGlob(rule.region, key),\n );\n\n/** Does a value rule apply to this (region, value) pair? */\nconst valueRuleMatches = (rule: ExpectedAbsentRule, key: string, value: string): boolean =>\n rule.value !== undefined &&\n matchesGlob(rule.value, value) &&\n (rule.region === undefined || matchesGlob(rule.region, key));\n\nconst removedFinding = (region: Region, rule: ExpectedAbsentRule | undefined): CompareFinding => ({\n regionKey: region.key,\n kind: 'region-removed',\n valueSignature: signature(region.key, region.values),\n message:\n rule === undefined\n ? `region \"${region.label}\" was removed`\n : `region \"${region.label}\" removed as expected`,\n severity: rule === undefined ? 'serious' : 'minor',\n ...(rule !== undefined && { suppressed: { reason: rule.reason } }),\n});\n\nconst degradedFinding = (region: Region): CompareFinding => ({\n regionKey: region.key,\n kind: 'region-degraded',\n valueSignature: signature(region.key, [region.state]),\n message: `region \"${region.label}\" degraded to ${region.state} state — not compared as value loss`,\n severity: 'minor',\n});\n\nconst addedFinding = (region: Region): CompareFinding => ({\n regionKey: region.key,\n kind: 'region-added',\n valueSignature: signature(region.key, []),\n message: `region \"${region.label}\" is new`,\n severity: 'minor',\n});\n\n/**\n * Diff one region present on both sides. Returns the value-missing findings\n * (active and/or suppressed), or none when values are unchanged.\n */\nconst diffRegionValues = (\n oldRegion: Region,\n newRegion: Region,\n rules: ExpectedAbsentRule[],\n): CompareFinding[] => {\n const missing = multisetMissing(oldRegion.values, newRegion.values);\n if (missing.length === 0) return [];\n\n const active: string[] = [];\n const suppressed: string[] = [];\n const reasons = new Set<string>();\n for (const value of missing) {\n const rule = rules.find((r) => valueRuleMatches(r, newRegion.key, value));\n if (rule === undefined) active.push(value);\n else {\n suppressed.push(value);\n reasons.add(rule.reason);\n }\n }\n\n const findings: CompareFinding[] = [];\n if (active.length > 0) {\n findings.push({\n regionKey: newRegion.key,\n kind: 'value-missing',\n valueSignature: signature(newRegion.key, active),\n message: `region \"${newRegion.label}\" is missing ${active.length} value(s)`,\n severity: 'moderate',\n detail: active.join(', '),\n });\n }\n if (suppressed.length > 0) {\n findings.push({\n regionKey: newRegion.key,\n kind: 'value-missing',\n valueSignature: signature(newRegion.key, suppressed),\n message: `region \"${newRegion.label}\" is missing ${suppressed.length} expected value(s)`,\n severity: 'minor',\n detail: suppressed.join(', '),\n suppressed: { reason: [...reasons].join('; ') },\n });\n }\n return findings;\n};\n\n/**\n * Semantic old-vs-new diff of two snapshots. Walks regions by key and localizes\n * every delta to a region: a removed region is `serious` unless allowlisted; a\n * region that degraded to an empty/error state is reported informationally and\n * NOT diffed as value loss; surviving regions get a per-region value-multiset\n * diff with allowlist suppression; new regions are non-regression notes. Pure —\n * no browser, no I/O.\n */\nexport const diffSnapshots = (\n oldSnap: CompareSnapshot,\n newSnap: CompareSnapshot,\n config: CompareConfig = {},\n): CompareFinding[] => {\n const rules = config.expectedAbsent ?? [];\n const newByKey = new Map(newSnap.regions.map((region) => [region.key, region]));\n const oldKeys = new Set(oldSnap.regions.map((region) => region.key));\n const findings: CompareFinding[] = [];\n\n for (const oldRegion of oldSnap.regions) {\n const newRegion = newByKey.get(oldRegion.key);\n if (newRegion === undefined) {\n findings.push(removedFinding(oldRegion, regionRemovalRule(rules, oldRegion.key)));\n continue;\n }\n if (oldRegion.state === 'ok' && newRegion.state !== 'ok') {\n findings.push(degradedFinding(newRegion));\n continue;\n }\n findings.push(...diffRegionValues(oldRegion, newRegion, rules));\n }\n\n for (const newRegion of newSnap.regions) {\n if (!oldKeys.has(newRegion.key)) findings.push(addedFinding(newRegion));\n }\n\n return findings;\n};\n\n/** A compare run passes when no active (non-suppressed) delta is serious/critical. */\nexport const comparePass = (findings: CompareFinding[]): boolean =>\n !findings.some(\n (f) => f.suppressed === undefined && (f.severity === 'serious' || f.severity === 'critical'),\n );\n","import type { Page } from 'playwright';\n\nimport type { CompareConfig, Region } from './types';\n\n// Region boundaries. `data-testid` is not one — it is on every button/input and would fragment\n// the page; it is only a key source.\nconst CANDIDATE_SELECTOR = [\n '[data-region]',\n 'main',\n 'nav',\n 'header',\n 'aside',\n 'footer',\n 'section[aria-label]',\n '[role=main]',\n '[role=navigation]',\n '[role=banner]',\n '[role=complementary]',\n '[role=contentinfo]',\n '[role=region]',\n].join(',');\n\nconst DEFAULT_EMPTY_MARKERS = [\n 'no data',\n 'no results',\n 'nothing to show',\n 'nothing here',\n 'get started',\n];\nconst DEFAULT_ERROR_MARKERS = [\n 'something went wrong',\n 'failed to load',\n 'could not load',\n 'unavailable',\n 'try again',\n];\n\n/**\n * Reduce the live page to its diffable regions, in the browser.\n *\n * - Regions never overlap, so no value is counted twice.\n * - Must stay symmetric across old and new so the two sides diff cleanly.\n */\nexport const extractRegions = async (page: Page, config: CompareConfig = {}): Promise<Region[]> => {\n const emptyMarkers = [...DEFAULT_EMPTY_MARKERS, ...(config.emptyStateMarkers ?? [])];\n const errorMarkers = [...DEFAULT_ERROR_MARKERS, ...(config.errorStateMarkers ?? [])];\n\n return page.evaluate(\n ({ selector, emptyMarkers, errorMarkers }): Region[] => {\n const STATES: Region['state'][] = ['ok', 'empty', 'error', 'loading'];\n const isState = (value: string): value is Region['state'] =>\n (STATES as string[]).includes(value);\n const slugify = (raw: string): string =>\n raw\n .toLowerCase()\n .trim()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 60) || 'region';\n\n const roleOf = (el: Element): string | undefined => {\n const explicit = el.getAttribute('role');\n if (explicit !== null && explicit !== '') return explicit;\n const tag = el.tagName.toLowerCase();\n if (tag === 'main') return 'main';\n if (tag === 'nav') return 'navigation';\n if (tag === 'aside') return 'complementary';\n // ARIA-in-HTML: a header/footer inside sectioning content has no landmark role.\n if (tag === 'header' || tag === 'footer') {\n if (el.closest('main,nav,section,article,aside') !== null) return undefined;\n return tag === 'header' ? 'banner' : 'contentinfo';\n }\n return undefined;\n };\n\n const headingOf = (el: Element): string | undefined => {\n const heading = el.querySelector('h1,h2,h3,h4,h5,h6');\n const text = heading?.textContent?.trim();\n return text !== undefined && text !== '' ? text : undefined;\n };\n\n const keyOf = (el: Element): string => {\n const region = el.getAttribute('data-region');\n if (region !== null && region !== '') return slugify(region);\n const testid = el.getAttribute('data-testid');\n if (testid !== null && testid !== '') return slugify(testid);\n const role = roleOf(el);\n if (role !== undefined) return role;\n const label = el.getAttribute('aria-label');\n if (label !== null && label !== '') return slugify(label);\n const heading = headingOf(el);\n if (heading !== undefined) return slugify(heading);\n return slugify(el.tagName);\n };\n\n const labelOf = (el: Element, key: string): string => {\n const label = el.getAttribute('aria-label');\n if (label !== null && label.trim() !== '') return label.trim();\n const heading = headingOf(el);\n if (heading !== undefined) return heading;\n const testid = el.getAttribute('data-testid');\n if (testid !== null && testid !== '') return testid;\n return key;\n };\n\n const textOf = (el: HTMLElement): string => (el.innerText || el.textContent || '').trim();\n\n const stateOf = (el: Element, text: string): Region['state'] => {\n const explicit = (el.getAttribute('data-state') ?? '').toLowerCase();\n if (isState(explicit)) return explicit;\n const lower = text.toLowerCase();\n if (errorMarkers.some((marker) => lower.includes(marker))) return 'error';\n if (lower.includes('loading')) return 'loading';\n if (emptyMarkers.some((marker) => lower.includes(marker))) return 'empty';\n return 'ok';\n };\n\n const valuesOf = (text: string): string[] =>\n (text.match(/-?\\$?\\s?\\d[\\d,]*(?:\\.\\d+)?%?/g) ?? [])\n .map((token) => token.replace(/[$\\s,]/g, ''))\n .filter((token) => token !== '' && token !== '-');\n\n // 1. The outermost `[data-region]` is one region; nested candidates do not split it.\n // 2. Elsewhere, a region is a leaf candidate outside any `[data-region]`.\n const qualifies = (el: Element): boolean => {\n const dataRegion = el.getAttribute('data-region');\n if (dataRegion !== null && dataRegion !== '') {\n const parent = el.parentElement;\n return parent === null || parent.closest('[data-region]') === null;\n }\n return el.querySelector(selector) === null && el.closest('[data-region]') === null;\n };\n const candidates = Array.from(document.querySelectorAll(selector));\n const regions = candidates.filter(qualifies);\n const elements: Element[] = regions.length > 0 ? regions : [document.body];\n\n const used: Record<string, number> = {};\n const uniqueKey = (key: string): string => {\n const count = (used[key] ?? 0) + 1;\n used[key] = count;\n return count === 1 ? key : `${key}-${count}`;\n };\n\n return elements.map((el) => {\n const text = textOf(el as HTMLElement);\n const key = uniqueKey(keyOf(el));\n return {\n key,\n label: labelOf(el, key),\n state: stateOf(el, text),\n values: valuesOf(text),\n };\n });\n },\n { selector: CANDIDATE_SELECTOR, emptyMarkers, errorMarkers },\n );\n};\n","import { z } from 'zod';\n\nimport type { Finding } from '../types';\n\n/**\n * Backend/render state of a region. A region that rendered its data is `ok`;\n * `empty`/`error`/`loading` are non-data states — an old→new transition into one\n * of them is a degrade, not a value regression (a backend was unavailable, or an\n * intentional empty state is showing).\n */\nexport const REGION_STATES = ['ok', 'empty', 'error', 'loading'] as const;\nexport type RegionState = (typeof REGION_STATES)[number];\n\n/** One diffable region of a page — a landmark, labelled section, or component. */\nexport const Region = z.object({\n /** Stable id: data-region → data-testid → role → aria-label → heading slug. */\n key: z.string(),\n /** Human label for the report. */\n label: z.string(),\n state: z.enum(REGION_STATES),\n /** Normalized numeric / currency / percent tokens found in the region. */\n values: z.array(z.string()),\n});\nexport type Region = z.infer<typeof Region>;\n\n/**\n * A page reduced to its diffable regions — the currency both input paths (a live\n * boot or a recorded JSON file) produce and the differ consumes. Pure JSON, so\n * it is safe to serialize to disk and re-load; {@link loadSnapshot} validates it.\n */\nexport const CompareSnapshot = z\n .object({\n /** Route the snapshot was captured at, e.g. `/financials`. */\n route: z.string(),\n regions: z.array(Region),\n })\n // The differ indexes regions by key, so duplicates would silently overwrite.\n // Live capture guarantees uniqueness; this rejects a malformed recorded snapshot.\n .refine(\n (snap) => new Set(snap.regions.map((region) => region.key)).size === snap.regions.length,\n {\n message: 'region keys must be unique',\n },\n );\nexport type CompareSnapshot = z.infer<typeof CompareSnapshot>;\n\n/** One old-vs-new delta, always attributed to a region. */\nexport type CompareFinding = Finding & {\n regionKey: string;\n kind: 'region-removed' | 'region-degraded' | 'value-missing' | 'region-added';\n /** Stable signature of the delta — identical across routes for shared chrome. */\n valueSignature: string;\n /** Present when a config rule suppressed this delta; it never fails the verdict. */\n suppressed?: { reason: string };\n};\n\n/**\n * One expected-absent rule. `region` (exact or `*`-glob against {@link Region.key})\n * alone suppresses a whole `region-removed`; add `value` to suppress a specific\n * missing value token (optionally scoped to a region). `reason` is required and\n * surfaced in the report so suppression is auditable, never silent.\n */\nexport const ExpectedAbsentRule = z\n .object({\n region: z.string().optional(),\n value: z.string().optional(),\n reason: z.string().min(1),\n })\n .refine((rule) => rule.region !== undefined || rule.value !== undefined, {\n message: 'expectedAbsent rule needs at least one of `region` or `value`',\n });\nexport type ExpectedAbsentRule = z.infer<typeof ExpectedAbsentRule>;\n\n/** Semantic-compare configuration — the allowlist plus extra state markers. */\nexport const CompareConfig = z.object({\n expectedAbsent: z.array(ExpectedAbsentRule).optional(),\n emptyStateMarkers: z.array(z.string()).optional(),\n errorStateMarkers: z.array(z.string()).optional(),\n});\nexport type CompareConfig = z.infer<typeof CompareConfig>;\n","import { readFile, writeFile } from 'node:fs/promises';\nimport type { Page } from 'playwright';\n\nimport { extractRegions } from './regions';\nimport { type CompareConfig, CompareSnapshot } from './types';\n\n/** Reduce a loaded live page to a {@link CompareSnapshot} for the given route. */\nexport const captureSnapshot = async (\n page: Page,\n route: string,\n config?: CompareConfig,\n): Promise<CompareSnapshot> => ({\n route,\n regions: await extractRegions(page, config),\n});\n\n/** Persist a snapshot as pretty JSON so an \"old\" side can be recorded once and re-diffed. */\nexport const saveSnapshot = async (snapshot: CompareSnapshot, path: string): Promise<void> => {\n await writeFile(path, JSON.stringify(snapshot, null, 2));\n};\n\n/** Read and validate a recorded snapshot from disk. */\nexport const loadSnapshot = async (path: string): Promise<CompareSnapshot> => {\n const raw = await readFile(path, 'utf8');\n return CompareSnapshot.parse(JSON.parse(raw));\n};\n","import { comparePass, diffSnapshots } from '../compare/diff';\nimport { captureSnapshot } from '../compare/snapshot';\nimport type { Axis, Verdict } from '../types';\n\nconst skip = (message: string): Verdict => ({\n axis: 'compare',\n pass: true,\n findings: [{ message, severity: 'minor' }],\n});\n\n/**\n * A5 · Semantic old-vs-new compare verdict.\n *\n * - Passing skip unless `context.comparison` (an \"old\" {@link CompareSnapshot}) is supplied.\n * - Diffs it against a snapshot of the live `context.page` via {@link diffSnapshots}.\n */\nexport const compareAxis: Axis = {\n name: 'compare',\n run: async (context): Promise<Verdict> => {\n const { comparison, compareConfig, page } = context;\n if (comparison === undefined) {\n return skip('no comparison snapshot provided; old-vs-new not assessed');\n }\n if (page === undefined) {\n return skip('compare needs a live page to extract the new snapshot');\n }\n\n const newSnapshot = await captureSnapshot(page, comparison.route, compareConfig);\n const findings = diffSnapshots(comparison, newSnapshot, compareConfig);\n return { axis: 'compare', pass: comparePass(findings), findings };\n },\n};\n","import type { Axis, ConsoleEntry, Finding, NetworkEntry, Severity } from '../types';\n\n// Dev-server error overlays: custom elements / ids injected into the DOM on a crash.\nconst OVERLAY_MARKERS = [\n 'vite-error-overlay',\n 'vite-plugin-checker-error-overlay',\n 'react-error-overlay',\n] as const;\n\n// Error-boundary copy. Only meaningful when a user can SEE it, so it is matched\n// against rendered text, never against the serialized HTML: an app whose inline\n// script carries the fallback string for a toast it may show later (e.g.\n// `fail(e.message || 'something went wrong')`) has not crashed.\nconst ERROR_TEXT_MARKERS = ['something went wrong'] as const;\n\n/**\n * The serialized DOM minus content that is never rendered as page content —\n * `<script>`, `<style>`, `<template>`, `<noscript>` bodies and comments — so a\n * marker named inside them is not mistaken for a rendered overlay.\n */\nconst stripNonRendered = (dom: string): string =>\n dom\n .replace(/<!--[\\s\\S]*?-->/g, '')\n .replace(/<(script|style|template|noscript)\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>/gi, '');\n\nconst pageErrorFindings = (console: ConsoleEntry[]): Finding[] =>\n console\n .filter((entry) => entry.type === 'pageerror')\n .map((entry) => ({\n message: `Uncaught page error: ${entry.text}`,\n severity: 'critical',\n detail: entry.location,\n }));\n\n// The browser echoes every failed resource load to the console as a generic\n// \"Failed to load resource\" error — noise that duplicates the network finding.\nconst RESOURCE_LOAD_ECHO = 'failed to load resource';\n\nconst isResourceLoadEcho = (entry: ConsoleEntry): boolean =>\n entry.text.toLowerCase().includes(RESOURCE_LOAD_ECHO);\n\n/**\n * Real app console errors only — the generic \"Failed to load resource\" echoes\n * are dropped (the network finding for the same URL is more useful). Identical\n * texts are deduped since one noisy error often logs on every render.\n */\nconst consoleErrorFindings = (console: ConsoleEntry[]): Finding[] => {\n const seen = new Set<string>();\n const findings: Finding[] = [];\n for (const entry of console) {\n if (entry.type !== 'error' || isResourceLoadEcho(entry) || seen.has(entry.text)) continue;\n seen.add(entry.text);\n findings.push({\n message: `Console error: ${entry.text}`,\n severity: 'serious',\n detail: entry.location,\n });\n }\n return findings;\n};\n\n/** Turn one network entry into a finding, or null for a healthy (<400) response. */\nconst toNetworkFinding = (entry: NetworkEntry): Finding | null => {\n if (entry.status === 0) {\n return {\n message: `Request failed: ${entry.method} ${entry.url}`,\n severity: 'serious',\n detail: entry.failure,\n ref: entry.url,\n };\n }\n if (entry.status >= 500) {\n return {\n message: `Server error ${entry.status}: ${entry.method} ${entry.url}`,\n severity: 'serious',\n ref: entry.url,\n };\n }\n if (entry.status >= 400) {\n return {\n message: `Client error ${entry.status}: ${entry.method} ${entry.url}`,\n severity: 'moderate',\n ref: entry.url,\n };\n }\n return null;\n};\n\nconst SEVERITY_RANK: Record<Severity, number> = { critical: 3, serious: 2, moderate: 1, minor: 0 };\n\n/**\n * One finding per URL. A single failing resource surfaces as a `requestfailed`,\n * a 4xx/5xx response, and a console echo — all the same root cause. Collapse\n * them by URL, keeping the most severe (a request-failure or explicit HTTP\n * status over the generic browser echo).\n */\nconst networkFindings = (network: NetworkEntry[]): Finding[] => {\n const byUrl = new Map<string, Finding>();\n for (const entry of network) {\n const finding = toNetworkFinding(entry);\n if (finding === null) continue;\n const existing = byUrl.get(entry.url);\n if (\n existing === undefined ||\n SEVERITY_RANK[finding.severity] > SEVERITY_RANK[existing.severity]\n ) {\n byUrl.set(entry.url, finding);\n }\n }\n return [...byUrl.values()];\n};\n\n/**\n * An error overlay in the DOM, or error-boundary copy in the rendered text,\n * means a crashed render rather than real content. Overlays are matched on the\n * DOM with script/style bodies removed; error copy on `renderedText` (the live\n * `innerText`, which also skips hidden elements), falling back to the stripped\n * DOM only when an artifact has no live text.\n */\nconst errorMarkerFinding = (dom: string, renderedText: string | undefined): Finding | null => {\n const markup = stripNonRendered(dom).toLowerCase();\n const visible = (renderedText ?? markup).toLowerCase();\n const marker =\n OVERLAY_MARKERS.find((m) => markup.includes(m)) ??\n ERROR_TEXT_MARKERS.find((m) => visible.includes(m));\n return marker === undefined\n ? null\n : {\n message: 'Error boundary / error overlay rendered instead of the app',\n severity: 'critical',\n detail: marker,\n };\n};\n\n/**\n * White-screen check on the reliable live-DOM signal (`renderedText`). Skipped\n * when absent — parsing the HTML string for \"emptiness\" gives false positives.\n */\nconst whiteScreenFinding = (renderedText: string | undefined): Finding | null =>\n renderedText !== undefined && renderedText.length === 0\n ? { message: 'White screen: page rendered no visible text', severity: 'critical' }\n : null;\n\n/**\n * A2 · Runtime-health verdict — UT-4493.\n *\n * Pure over `context.artifact` (no live page). Collects uncaught page errors,\n * console errors, failed/4xx/5xx requests, and white-screen / error-overlay\n * signals into `findings`.\n */\nexport const runtimeHealthAxis: Axis = {\n name: 'runtime-health',\n run: (context) => {\n const { artifact } = context;\n const findings: Finding[] = [\n ...pageErrorFindings(artifact.console),\n ...consoleErrorFindings(artifact.console),\n ...networkFindings(artifact.network),\n ];\n const errorMarker = errorMarkerFinding(artifact.dom, artifact.renderedText);\n if (errorMarker !== null) findings.push(errorMarker);\n const whiteScreen = whiteScreenFinding(artifact.renderedText);\n if (whiteScreen !== null) findings.push(whiteScreen);\n\n // Fail only on blocking severities; moderate/minor are reported but non-fatal.\n const fatal: Severity[] = ['critical', 'serious'];\n const pass = !findings.some((f) => fatal.includes(f.severity));\n\n return Promise.resolve({ axis: 'runtime-health', pass, findings });\n },\n};\n","import pixelmatch from 'pixelmatch';\nimport type { Page } from 'playwright';\nimport { PNG } from 'pngjs';\n\nimport type { Brand } from '@keboola/brand-registry';\nimport { brands, checkInteractiveContrast } from '@keboola/brand-registry';\n\nimport type { Axis, Finding, Viewport } from '../types';\nimport { isPhoneViewport } from '../viewport';\n\n// Ratio above which two screenshots count as meaningfully drifted from a baseline.\nconst DRIFT_THRESHOLD = 0.01;\n// Ratio below which an alt-brand capture is treated as \"chrome did not re-skin\".\nconst RESKIN_THRESHOLD = 0.005;\n// pixelmatch per-pixel threshold for drift; coarse, to ignore antialiasing noise.\nconst DRIFT_PIXEL_THRESHOLD = 0.1;\n// Fine: a re-skin is broad low-amplitude hue shifts that 0.1 discards, failing correct brands.\nconst RESKIN_PIXEL_THRESHOLD = 0.02;\n// Default alternate brand the fitness function re-renders under.\nconst DEFAULT_ALT_BRAND = 'example-customer';\n\nexport type PngMismatch = { ratio: number; sizeMismatch: boolean };\n\n/** Pixel mismatch ratio of two PNGs (0 = identical, 1 = every pixel differs). */\nexport const pngMismatchRatio = (\n a: Uint8Array,\n b: Uint8Array,\n pixelThreshold: number = DRIFT_PIXEL_THRESHOLD,\n): PngMismatch => {\n const imgA = PNG.sync.read(Buffer.from(a));\n const imgB = PNG.sync.read(Buffer.from(b));\n\n if (imgA.width !== imgB.width || imgA.height !== imgB.height) {\n return { ratio: 1, sizeMismatch: true };\n }\n\n const total = imgA.width * imgA.height;\n if (total === 0) return { ratio: 0, sizeMismatch: false };\n\n const mismatched = pixelmatch(imgA.data, imgB.data, undefined, imgA.width, imgA.height, {\n threshold: pixelThreshold,\n });\n return { ratio: mismatched / total, sizeMismatch: false };\n};\n\nconst baselineFinding = (screenshot: Uint8Array, baseline: Uint8Array): Finding | null => {\n const { ratio, sizeMismatch } = pngMismatchRatio(screenshot, baseline);\n if (sizeMismatch) {\n return {\n message: 'screenshot dimensions differ from baseline',\n severity: 'moderate',\n };\n }\n if (ratio > DRIFT_THRESHOLD) {\n return {\n message: `visual drift ${(ratio * 100).toFixed(1)}% vs baseline`,\n severity: 'moderate',\n detail: `${(ratio * 100).toFixed(2)}% of pixels changed`,\n };\n }\n return null;\n};\n\n/**\n * Whether the alt-brand capture actually activated that brand.\n *\n * - Most apps ignore `?brand=<id>`: brand is build configuration, not a runtime switch.\n * - `@keboola/design`'s ThemeProvider writes `data-brand`; its absence means \"not assessed\",\n * not \"did not re-skin\".\n */\nconst altBrandWasApplied = (altDom: string, brand: string): boolean =>\n altDom.includes(`data-brand=\"${brand}\"`);\n\nconst altBrandFinding = (\n screenshot: Uint8Array,\n altScreenshot: Uint8Array,\n brand: string,\n altDom: string,\n): Finding | null => {\n if (!altBrandWasApplied(altDom, brand)) {\n return {\n message: `alt-brand re-skin not assessed: the app did not apply '${brand}' from ?brand=`,\n severity: 'minor',\n detail:\n 'Brand is usually build configuration rather than a runtime switch. To have this ' +\n 'axis assess re-skinning, register the alternate brand and resolve the active id ' +\n 'from the `brand` query parameter.',\n };\n }\n\n const { ratio } = pngMismatchRatio(screenshot, altScreenshot, RESKIN_PIXEL_THRESHOLD);\n if (ratio < RESKIN_THRESHOLD) {\n return {\n message: `chrome did not re-skin under alternate brand '${brand}'`,\n severity: 'serious',\n detail: `${(ratio * 100).toFixed(2)}% of pixels changed`,\n };\n }\n return null;\n};\n\n/**\n * Non-fatal `moderate` findings for a brand's interactive pairings below WCAG-AA.\n *\n * - Surfaces `@keboola/brand-registry`'s check before axe would hit it at runtime.\n */\nexport const interactiveContrastFindings = (brand: Brand): Finding[] =>\n checkInteractiveContrast(brand).map((warning) => ({\n message: `brand '${brand.id}' ${warning.message}`,\n severity: 'moderate',\n detail: `${warning.foreground} on ${warning.background} (${warning.palette})`,\n }));\n\n/**\n * Horizontal overflow, weighed by the width it was measured at.\n *\n * - Phone viewport: `critical` — blocking is what makes phone-first a rule.\n * - Desktop: `moderate`.\n */\nconst overflowFinding = async (page: Page, viewport: Viewport): Promise<Finding | null> => {\n const overflows = await page.evaluate(\n () => document.documentElement.scrollWidth > document.documentElement.clientWidth,\n );\n if (!overflows) return null;\n return isPhoneViewport(viewport)\n ? {\n message: `horizontal layout overflow at ${viewport.width}px`,\n severity: 'critical',\n detail: 'The page scrolls sideways on a phone — lay it out in one column instead.',\n }\n : { message: 'horizontal layout overflow', severity: 'moderate' };\n};\n\n/**\n * A4 · Visual & brand-correctness verdict.\n *\n * - Baseline pixel diff; alt brand must visibly re-skin the chrome; layout overflow.\n * - Each check yields no finding when its input is absent.\n */\nexport const visualBrandAxis: Axis = {\n name: 'visual-brand',\n run: async (context) => {\n const { artifact, baseline, captureUnderBrand, page } = context;\n const findings: Finding[] = [];\n\n // 1. Baseline diff (the first run has no baseline).\n if (baseline !== undefined) {\n const finding = baselineFinding(artifact.screenshot, baseline);\n if (finding !== null) findings.push(finding);\n }\n\n // 2. Alt brand: chrome re-skins, and its interactive tokens are AA-safe.\n if (captureUnderBrand !== undefined) {\n const altArtifact = await captureUnderBrand(DEFAULT_ALT_BRAND);\n const finding = altBrandFinding(\n artifact.screenshot,\n altArtifact.screenshot,\n DEFAULT_ALT_BRAND,\n altArtifact.dom,\n );\n if (finding !== null) findings.push(finding);\n\n const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);\n if (altBrand !== undefined) findings.push(...interactiveContrastFindings(altBrand));\n }\n\n // 3. Layout overflow.\n if (page !== undefined) {\n const finding = await overflowFinding(page, artifact.viewport);\n if (finding !== null) findings.push(finding);\n }\n\n const pass = !findings.some((f) => f.severity === 'critical' || f.severity === 'serious');\n\n return { axis: 'visual-brand', pass, findings };\n },\n};\n","import type { Axis } from '../types';\n\nimport { accessibilityAxis } from './accessibility';\nimport { briefConformanceAxis } from './brief-conformance';\nimport { compareAxis } from './compare';\nimport { runtimeHealthAxis } from './runtime-health';\nimport { visualBrandAxis } from './visual-brand';\n\nexport { accessibilityAxis } from './accessibility';\nexport { briefConformanceAxis } from './brief-conformance';\nexport { compareAxis } from './compare';\nexport { runtimeHealthAxis } from './runtime-health';\nexport { visualBrandAxis } from './visual-brand';\n\n/**\n * The registered axes, in report order. `compare` runs last and only activates\n * when a comparison snapshot is supplied, so ordinary single-page runs are\n * unaffected.\n */\nexport const ALL_AXES: Axis[] = [\n runtimeHealthAxis,\n accessibilityAxis,\n visualBrandAxis,\n briefConformanceAxis,\n compareAxis,\n];\n","import { type Browser, chromium } from 'playwright';\n\nimport type { CompareConfig, CompareSnapshot } from './compare/types';\nimport { ALL_AXES } from './axes';\nimport { capturePage, DESKTOP_VIEWPORT } from './capture';\nimport type { AggregateVerdict, Axis, AxisContext, Verdict, Viewport } from './types';\n\n/**\n * Run each axis against the shared context, isolating failures: an axis that\n * throws becomes a failing verdict instead of aborting the whole run. The error\n * is logged (never swallowed) and surfaced as a critical finding.\n */\nexport const runAxes = async (axes: Axis[], context: AxisContext): Promise<Verdict[]> => {\n const verdicts: Verdict[] = [];\n for (const axis of axes) {\n try {\n verdicts.push(await axis.run(context));\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`validate-ui: axis \"${axis.name}\" threw:`, error);\n verdicts.push({\n axis: axis.name,\n pass: false,\n findings: [{ message: `Axis crashed: ${message}`, severity: 'critical' }],\n });\n }\n }\n return verdicts;\n};\n\n/** A run passes only when every axis passed. */\nexport const aggregatePass = (verdicts: Verdict[]): boolean => verdicts.every((v) => v.pass);\n\nconst withBrandParam = (url: string, brand: string): string => {\n const parsed = new URL(url);\n parsed.searchParams.set('brand', brand);\n return parsed.toString();\n};\n\nexport type ValidateOptions = {\n url: string;\n brief?: string;\n baseline?: Uint8Array;\n /** Viewport axes run against (a11y/visual use one). Defaults to desktop. */\n viewport?: Viewport;\n /** Override the axis set — defaults to all four registered axes. */\n axes?: Axis[];\n /** Reuse a browser instead of launching one. */\n browser?: Browser;\n timeoutMs?: number;\n /** The \"old\" UI reduced to regions — enables the compare axis when present. */\n comparison?: CompareSnapshot;\n /** Allowlist + state markers for the compare axis. */\n compareConfig?: CompareConfig;\n};\n\n/**\n * The closed loop: render `url`, capture ground truth, and run every axis over\n * it, returning one machine-readable {@link AggregateVerdict}. Keeps the page\n * open for axes that need a live DOM (a11y, visual) and provides\n * `captureUnderBrand` for the alt-brand fitness function.\n */\nexport const validate = async (options: ValidateOptions): Promise<AggregateVerdict> => {\n const viewport = options.viewport ?? DESKTOP_VIEWPORT;\n const axes = options.axes ?? ALL_AXES;\n const browser = options.browser ?? (await chromium.launch());\n const ownsBrowser = !options.browser;\n const context = await browser.newContext({\n viewport: { width: viewport.width, height: viewport.height },\n });\n const page = await context.newPage();\n try {\n const artifact = await capturePage(page, options.url, viewport, options.timeoutMs);\n const axisContext: AxisContext = {\n artifact,\n page,\n browser,\n brief: options.brief,\n baseline: options.baseline,\n comparison: options.comparison,\n compareConfig: options.compareConfig,\n captureUnderBrand: async (brand) => {\n const brandContext = await browser.newContext({\n viewport: { width: viewport.width, height: viewport.height },\n });\n const brandPage = await brandContext.newPage();\n try {\n return await capturePage(\n brandPage,\n withBrandParam(options.url, brand),\n viewport,\n options.timeoutMs,\n );\n } finally {\n await brandContext.close();\n }\n },\n };\n const verdicts = await runAxes(axes, axisContext);\n return { url: options.url, pass: aggregatePass(verdicts), verdicts };\n } finally {\n await context.close();\n if (ownsBrowser) await browser.close();\n }\n};\n","import type { CompareFinding } from './types';\n\n/** One route's compare findings. */\nexport type RouteFindings = {\n route: string;\n findings: CompareFinding[];\n};\n\nexport type DedupResult = {\n /** Findings seen on ≥2 routes, collapsed to one entry tagged with the route count. */\n sharedChrome: CompareFinding[];\n /** Per-route findings with the shared-chrome copies removed. */\n perRoute: RouteFindings[];\n};\n\n// A delta is \"the same delta\" when its signature and kind match; the signature\n// already encodes region key + tokens, so identical shared chrome collapses.\nconst groupKey = (finding: CompareFinding): string => `${finding.kind}::${finding.valueSignature}`;\n\n/**\n * Collapse deltas that recur across routes into a single `shared-chrome` finding.\n * A value flagged on a shared header/daily-brief element would otherwise be\n * multiplied across every route (the prototype's `8.4/7.7/-8%/67%` on all 10\n * pages); here it is reported once, tagged with the routes it spans, and dropped\n * from each route's own list. Pure — no browser, no I/O.\n */\nexport const dedupeSharedChrome = (perRoute: RouteFindings[]): DedupResult => {\n const routesByGroup = new Map<string, Set<string>>();\n for (const { route, findings } of perRoute) {\n for (const finding of findings) {\n const key = groupKey(finding);\n const routes = routesByGroup.get(key) ?? new Set<string>();\n routes.add(route);\n routesByGroup.set(key, routes);\n }\n }\n\n const sharedGroups = new Set(\n [...routesByGroup].filter(([, routes]) => routes.size >= 2).map(([key]) => key),\n );\n\n const sharedChrome: CompareFinding[] = [];\n const seenShared = new Set<string>();\n const trimmedPerRoute: RouteFindings[] = perRoute.map(({ route, findings }) => ({\n route,\n findings: findings.filter((finding) => !sharedGroups.has(groupKey(finding))),\n }));\n\n for (const { findings } of perRoute) {\n for (const finding of findings) {\n const key = groupKey(finding);\n if (!sharedGroups.has(key) || seenShared.has(key)) continue;\n seenShared.add(key);\n const routes = [...(routesByGroup.get(key) ?? [])].sort();\n sharedChrome.push({\n ...finding,\n message: `${finding.message} (shared across ${routes.length} routes: ${routes.join(', ')})`,\n });\n }\n }\n\n return { sharedChrome, perRoute: trimmedPerRoute };\n};\n","import { join } from 'node:path';\nimport { type Browser, chromium } from 'playwright';\n\nimport { DESKTOP_VIEWPORT } from '../capture';\nimport { prepareForScreenshot } from '../prepare-screenshot';\nimport type { Viewport } from '../types';\n\nimport { dedupeSharedChrome, type RouteFindings } from './dedup';\nimport { comparePass, diffSnapshots } from './diff';\nimport { captureSnapshot, loadSnapshot } from './snapshot';\nimport type { CompareConfig, CompareFinding, CompareSnapshot } from './types';\n\nconst DEFAULT_TIMEOUT_MS = 30_000;\n\n/** How the \"old\" side is sourced — booted live or loaded from recorded snapshots, never both. */\ntype OldSource =\n | { oldBaseUrl: string; oldSnapshotDir?: never }\n | { oldSnapshotDir: string; oldBaseUrl?: never };\n\nexport type CompareRoutesOptions = OldSource & {\n /** Base URL of the new build, joined with each route. */\n newBaseUrl: string;\n routes: string[];\n config?: CompareConfig;\n browser?: Browser;\n viewport?: Viewport;\n timeoutMs?: number;\n};\n\nexport type RouteCompareResult = { route: string; findings: CompareFinding[]; pass: boolean };\n\nexport type CompareRoutesResult = {\n routes: RouteCompareResult[];\n /** Deltas seen on ≥2 routes, reported once (e.g. shared chrome / daily-brief). */\n sharedChrome: CompareFinding[];\n pass: boolean;\n};\n\nconst joinUrl = (base: string, route: string): string =>\n `${base.replace(/\\/$/, '')}/${route.replace(/^\\//, '')}`;\n\n/** Recorded old snapshot filename for a route: `/a/b` → `a_b.json`, `/` → `index.json`. */\nexport const routeSnapshotFile = (route: string): string =>\n `${route.replace(/^\\//, '').replace(/\\/+$/, '').replace(/\\//g, '_') || 'index'}.json`;\n\n/**\n * Multi-route semantic compare. Boots each route on the new side (and the old\n * side too, unless recorded snapshots are supplied), diffs per route, then\n * collapses deltas that recur across routes into a single shared-chrome entry so\n * one shared element is not reported as N regressions. Exactly one of\n * `oldBaseUrl` / `oldSnapshotDir` must be given.\n */\nexport const compareRoutes = async (\n options: CompareRoutesOptions,\n): Promise<CompareRoutesResult> => {\n if ((options.oldBaseUrl === undefined) === (options.oldSnapshotDir === undefined)) {\n throw new Error('compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir');\n }\n const viewport = options.viewport ?? DESKTOP_VIEWPORT;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const browser = options.browser ?? (await chromium.launch());\n const ownsBrowser = options.browser === undefined;\n\n const { oldBaseUrl, oldSnapshotDir } = options;\n\n const snapshotUrl = async (url: string, route: string): Promise<CompareSnapshot> => {\n const context = await browser.newContext({\n viewport: { width: viewport.width, height: viewport.height },\n });\n const page = await context.newPage();\n try {\n await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });\n await prepareForScreenshot(page);\n return await captureSnapshot(page, route, options.config);\n } finally {\n await context.close();\n }\n };\n\n // The XOR guard above proves one branch holds; the trailing throw both narrows\n // the type (no `as`) and defends the invariant.\n const oldSnapshotFor = (route: string): Promise<CompareSnapshot> => {\n if (oldBaseUrl !== undefined) return snapshotUrl(joinUrl(oldBaseUrl, route), route);\n if (oldSnapshotDir !== undefined) {\n return loadSnapshot(join(oldSnapshotDir, routeSnapshotFile(route)));\n }\n throw new Error('compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir');\n };\n\n try {\n const perRoute: RouteFindings[] = [];\n for (const route of options.routes) {\n const oldSnapshot = await oldSnapshotFor(route);\n const newSnapshot = await snapshotUrl(joinUrl(options.newBaseUrl, route), route);\n perRoute.push({ route, findings: diffSnapshots(oldSnapshot, newSnapshot, options.config) });\n }\n\n const { sharedChrome, perRoute: trimmed } = dedupeSharedChrome(perRoute);\n const routes: RouteCompareResult[] = trimmed.map(({ route, findings }) => ({\n route,\n findings,\n pass: comparePass(findings),\n }));\n const pass = comparePass(sharedChrome) && routes.every((route) => route.pass);\n return { routes, sharedChrome, pass };\n } finally {\n if (ownsBrowser) await browser.close();\n }\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keboola/validate-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Closed-loop validation for agent-generated Keboola UI — boots a module headless, drives it, and returns a machine-readable verdict (runtime health, accessibility, visual/brand, brief-conformance)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"playwright": "1.63.0",
|
|
51
51
|
"pngjs": "^7.0.0",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@keboola/brand-registry": "^1.
|
|
53
|
+
"@keboola/brand-registry": "^1.10.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@arethetypeswrong/cli": "^0.18.2",
|
|
@@ -62,8 +62,9 @@
|
|
|
62
62
|
"tsdown": "^0.22.14",
|
|
63
63
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
64
64
|
"vite": "^8.3.0",
|
|
65
|
-
"vitest": "^
|
|
66
|
-
"@keboola/
|
|
65
|
+
"vitest": "^5.0.1",
|
|
66
|
+
"@keboola/eslint-plugin-design": "^0.5.0",
|
|
67
|
+
"@keboola/oxlint-config": "^10.2.0",
|
|
67
68
|
"@keboola/tsconfig": "^0.2.1"
|
|
68
69
|
},
|
|
69
70
|
"scripts": {
|