@keboola/validate-ui 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -0
- package/dist/cli.cjs +101 -7
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +101 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +101 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +101 -7
- package/dist/index.js.map +1 -1
- package/package.json +10 -7
package/AGENTS.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @keboola/validate-ui — agent guide
|
|
2
|
+
|
|
3
|
+
Closed-loop validator for generated UI. Static gates check code; this checks the
|
|
4
|
+
rendered result. Reach for it after generating/scaffolding a module, before a PR.
|
|
5
|
+
|
|
6
|
+
- **Run it:** `validate-ui --url <url> --brief "<the brief>"` (or `--serve <distDir> --route <path>`). Exit 0 = all axes pass. `--json` for machine output.
|
|
7
|
+
- **Programmatic:** `validate({ url, brief })` → `AggregateVerdict`. Building a pipeline? `capture()` for artifacts, `runAxes(axes, ctx)` to run a subset.
|
|
8
|
+
- **Five axes** live one-per-file in `src/axes/` (`runtime-health`, `a11y`, `visual-brand`, `brief-conformance`, `compare`), each implementing the `Axis` contract in `src/types.ts`. Add an axis: implement `{ name, run }`, register it in `src/axes/index.ts`.
|
|
9
|
+
- **Contract boundary:** everything an axis needs is on `AxisContext` (`artifact`, live `page`, `brief`, `baseline`, `captureUnderBrand`, `comparison`, `compareConfig`). Don't reach outside it.
|
|
10
|
+
- **Compare (old-vs-new)** is semantic, not a value multiset — `src/compare/`. Both sides reduce to a serializable `CompareSnapshot` (regions with key/state/values) via `extractRegions`; the pure `diffSnapshots` localizes every delta to a region, suppresses declared `expectedAbsent` deltas (recorded, not dropped), and reports an empty/error `region-degraded` instead of value loss. Multi-route: `validate-ui compare --new <url> (--old <url> | --old-snapshots <dir>) --routes a,b,c [--config f.json]` — `dedupeSharedChrome` collapses a delta seen on ≥2 routes into one finding so shared chrome isn't multiplied. The `compare` axis only activates when `context.comparison` is set, so single-page runs are unaffected.
|
|
11
|
+
- **Do not** depend on `@keboola/e2e-testing` (private) — the screenshot-prep helper is reimplemented in `src/prepare-screenshot.ts`.
|
|
12
|
+
- **Want one pass/fail number + a gate exit code**, not raw findings? Use `@keboola/ui-gen-bench` — `ui-gen-bench eval --serve <dir> --route <path> --brief "…"` boots via this validator, scores the verdict, and exits 0/1. That's the per-generation gate; this package is the validator underneath it.
|
|
13
|
+
- **VLM backend** for `brief-conformance` is chosen in `src/axes/vlm-provider.ts`: Keboola LLM proxy (base URL `VALIDATE_UI_LLM_BASE_URL` → `ANTHROPIC_BASE_URL`; token `VALIDATE_UI_LLM_TOKEN` → `KBC_TOKEN` → `ANTHROPIC_API_KEY` — so kai-agent's SDK-native `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` pair works, preferred) → raw `ANTHROPIC_API_KEY` (no proxy base URL) → skip. Route new VLM calls through `resolveVlmProvider()`, never `new Anthropic()` directly.
|
package/dist/cli.cjs
CHANGED
|
@@ -10,6 +10,7 @@ var axe = require('axe-core');
|
|
|
10
10
|
var Anthropic = require('@anthropic-ai/sdk');
|
|
11
11
|
var pixelmatch = require('pixelmatch');
|
|
12
12
|
var pngjs = require('pngjs');
|
|
13
|
+
var brandRegistry = require('@keboola/brand-registry');
|
|
13
14
|
var fs = require('fs');
|
|
14
15
|
var http = require('http');
|
|
15
16
|
|
|
@@ -455,10 +456,85 @@ var compareRoutes = async (options) => {
|
|
|
455
456
|
if (ownsBrowser) await browser.close();
|
|
456
457
|
}
|
|
457
458
|
};
|
|
459
|
+
|
|
460
|
+
// src/axes/clipped-text.ts
|
|
461
|
+
var CLIP_TOLERANCE_PX = 2;
|
|
462
|
+
var MAX_REPORTED = 10;
|
|
463
|
+
var findClippedElements = ({
|
|
464
|
+
tolerance,
|
|
465
|
+
max
|
|
466
|
+
}) => {
|
|
467
|
+
const describe = (el) => {
|
|
468
|
+
const tag = el.tagName.toLowerCase();
|
|
469
|
+
const label = el.getAttribute("aria-label");
|
|
470
|
+
if (label) return `${tag}[aria-label="${label}"]`;
|
|
471
|
+
if (el.id) return `${tag}#${el.id}`;
|
|
472
|
+
const name = el.getAttribute("name");
|
|
473
|
+
if (name) return `${tag}[name="${name}"]`;
|
|
474
|
+
const cls = typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}` : "";
|
|
475
|
+
return `${tag}${cls}`;
|
|
476
|
+
};
|
|
477
|
+
const results = [];
|
|
478
|
+
const reported = /* @__PURE__ */ new Set();
|
|
479
|
+
const hasReportedAncestor = (el) => {
|
|
480
|
+
let parent = el.parentElement;
|
|
481
|
+
while (parent) {
|
|
482
|
+
if (reported.has(parent)) return true;
|
|
483
|
+
parent = parent.parentElement;
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
486
|
+
};
|
|
487
|
+
for (const el of document.querySelectorAll("*")) {
|
|
488
|
+
if (results.length >= max) break;
|
|
489
|
+
if (hasReportedAncestor(el)) continue;
|
|
490
|
+
const style = window.getComputedStyle(el);
|
|
491
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (el.clientWidth <= 1 || el.clientHeight <= 1) continue;
|
|
495
|
+
const overflowX = style.overflowX;
|
|
496
|
+
const isFormValue = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;
|
|
497
|
+
const isSingleLineInput = el instanceof HTMLInputElement;
|
|
498
|
+
if (!isSingleLineInput && overflowX !== "hidden" && overflowX !== "clip") continue;
|
|
499
|
+
if (!isSingleLineInput && style.textOverflow === "ellipsis") continue;
|
|
500
|
+
const overflowBy = el.scrollWidth - el.clientWidth;
|
|
501
|
+
if (overflowBy <= tolerance) continue;
|
|
502
|
+
const text = isFormValue ? el.value : (el.textContent ?? "").trim();
|
|
503
|
+
if (!text) continue;
|
|
504
|
+
reported.add(el);
|
|
505
|
+
results.push({
|
|
506
|
+
selector: describe(el),
|
|
507
|
+
text: text.length > 40 ? `${text.slice(0, 40)}\u2026` : text,
|
|
508
|
+
visibleWidth: Math.round(el.clientWidth),
|
|
509
|
+
contentWidth: Math.round(el.scrollWidth),
|
|
510
|
+
isInput: isFormValue
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return results;
|
|
514
|
+
};
|
|
515
|
+
var toFinding = (clipped) => ({
|
|
516
|
+
message: `Content is cut off with no way to reveal it (${clipped.selector})`,
|
|
517
|
+
severity: "serious",
|
|
518
|
+
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\`.`
|
|
519
|
+
});
|
|
520
|
+
var clippedTextFindings = async (page) => {
|
|
521
|
+
try {
|
|
522
|
+
const clipped = await page.evaluate(findClippedElements, {
|
|
523
|
+
tolerance: CLIP_TOLERANCE_PX,
|
|
524
|
+
max: MAX_REPORTED
|
|
525
|
+
});
|
|
526
|
+
return clipped.map(toFinding);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
console.error("clipped-text: could not inspect the page", error);
|
|
529
|
+
return [];
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// src/axes/accessibility.ts
|
|
458
534
|
var IMPACTS = ["critical", "serious", "moderate", "minor"];
|
|
459
535
|
var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
|
|
460
536
|
var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
|
|
461
|
-
var
|
|
537
|
+
var toFinding2 = (violation) => {
|
|
462
538
|
const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
|
|
463
539
|
const wcag = wcagTag(violation.tags);
|
|
464
540
|
return {
|
|
@@ -485,7 +561,7 @@ var accessibilityAxis = {
|
|
|
485
561
|
if (results === null) {
|
|
486
562
|
return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
|
|
487
563
|
}
|
|
488
|
-
const findings = results.violations.map(
|
|
564
|
+
const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
|
|
489
565
|
const pass = !findings.some(
|
|
490
566
|
(finding) => finding.severity === "critical" || finding.severity === "serious"
|
|
491
567
|
);
|
|
@@ -727,8 +803,10 @@ var runtimeHealthAxis = {
|
|
|
727
803
|
};
|
|
728
804
|
var DRIFT_THRESHOLD = 0.01;
|
|
729
805
|
var RESKIN_THRESHOLD = 5e-3;
|
|
806
|
+
var DRIFT_PIXEL_THRESHOLD = 0.1;
|
|
807
|
+
var RESKIN_PIXEL_THRESHOLD = 0.02;
|
|
730
808
|
var DEFAULT_ALT_BRAND = "example-customer";
|
|
731
|
-
var pngMismatchRatio = (a, b) => {
|
|
809
|
+
var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
|
|
732
810
|
const imgA = pngjs.PNG.sync.read(Buffer.from(a));
|
|
733
811
|
const imgB = pngjs.PNG.sync.read(Buffer.from(b));
|
|
734
812
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
@@ -737,7 +815,7 @@ var pngMismatchRatio = (a, b) => {
|
|
|
737
815
|
const total = imgA.width * imgA.height;
|
|
738
816
|
if (total === 0) return { ratio: 0, sizeMismatch: false };
|
|
739
817
|
const mismatched = pixelmatch__default.default(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
|
|
740
|
-
threshold:
|
|
818
|
+
threshold: pixelThreshold
|
|
741
819
|
});
|
|
742
820
|
return { ratio: mismatched / total, sizeMismatch: false };
|
|
743
821
|
};
|
|
@@ -758,8 +836,16 @@ var baselineFinding = (screenshot, baseline) => {
|
|
|
758
836
|
}
|
|
759
837
|
return null;
|
|
760
838
|
};
|
|
761
|
-
var
|
|
762
|
-
|
|
839
|
+
var altBrandWasApplied = (altDom, brand) => altDom.includes(`data-brand="${brand}"`);
|
|
840
|
+
var altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
|
|
841
|
+
if (!altBrandWasApplied(altDom, brand)) {
|
|
842
|
+
return {
|
|
843
|
+
message: `alt-brand re-skin not assessed: the app did not apply '${brand}' from ?brand=`,
|
|
844
|
+
severity: "minor",
|
|
845
|
+
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."
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
const { ratio } = pngMismatchRatio(screenshot, altScreenshot, RESKIN_PIXEL_THRESHOLD);
|
|
763
849
|
if (ratio < RESKIN_THRESHOLD) {
|
|
764
850
|
return {
|
|
765
851
|
message: `chrome did not re-skin under alternate brand '${brand}'`,
|
|
@@ -769,6 +855,11 @@ var altBrandFinding = (screenshot, altScreenshot, brand) => {
|
|
|
769
855
|
}
|
|
770
856
|
return null;
|
|
771
857
|
};
|
|
858
|
+
var interactiveContrastFindings = (brand) => brandRegistry.checkInteractiveContrast(brand).map((warning) => ({
|
|
859
|
+
message: `brand '${brand.id}' ${warning.message}`,
|
|
860
|
+
severity: "moderate",
|
|
861
|
+
detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
|
|
862
|
+
}));
|
|
772
863
|
var overflowFinding = async (page) => {
|
|
773
864
|
const overflows = await page.evaluate(
|
|
774
865
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
@@ -792,9 +883,12 @@ var visualBrandAxis = {
|
|
|
792
883
|
const finding = altBrandFinding(
|
|
793
884
|
artifact.screenshot,
|
|
794
885
|
altArtifact.screenshot,
|
|
795
|
-
DEFAULT_ALT_BRAND
|
|
886
|
+
DEFAULT_ALT_BRAND,
|
|
887
|
+
altArtifact.dom
|
|
796
888
|
);
|
|
797
889
|
if (finding !== null) findings.push(finding);
|
|
890
|
+
const altBrand = brandRegistry.brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
|
|
891
|
+
if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
|
|
798
892
|
}
|
|
799
893
|
if (page !== void 0) {
|
|
800
894
|
const finding = await overflowFinding(page);
|