@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 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 toFinding = (violation) => {
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(toFinding);
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: 0.1
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 altBrandFinding = (screenshot, altScreenshot, brand) => {
762
- const { ratio } = pngMismatchRatio(screenshot, altScreenshot);
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);