@keboola/validate-ui 0.4.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 +93 -7
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +93 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +93 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +93 -7
- package/dist/index.js.map +1 -1
- package/package.json +9 -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
|
@@ -456,10 +456,85 @@ var compareRoutes = async (options) => {
|
|
|
456
456
|
if (ownsBrowser) await browser.close();
|
|
457
457
|
}
|
|
458
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
|
|
459
534
|
var IMPACTS = ["critical", "serious", "moderate", "minor"];
|
|
460
535
|
var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
|
|
461
536
|
var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
|
|
462
|
-
var
|
|
537
|
+
var toFinding2 = (violation) => {
|
|
463
538
|
const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
|
|
464
539
|
const wcag = wcagTag(violation.tags);
|
|
465
540
|
return {
|
|
@@ -486,7 +561,7 @@ var accessibilityAxis = {
|
|
|
486
561
|
if (results === null) {
|
|
487
562
|
return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
|
|
488
563
|
}
|
|
489
|
-
const findings = results.violations.map(
|
|
564
|
+
const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
|
|
490
565
|
const pass = !findings.some(
|
|
491
566
|
(finding) => finding.severity === "critical" || finding.severity === "serious"
|
|
492
567
|
);
|
|
@@ -728,8 +803,10 @@ var runtimeHealthAxis = {
|
|
|
728
803
|
};
|
|
729
804
|
var DRIFT_THRESHOLD = 0.01;
|
|
730
805
|
var RESKIN_THRESHOLD = 5e-3;
|
|
806
|
+
var DRIFT_PIXEL_THRESHOLD = 0.1;
|
|
807
|
+
var RESKIN_PIXEL_THRESHOLD = 0.02;
|
|
731
808
|
var DEFAULT_ALT_BRAND = "example-customer";
|
|
732
|
-
var pngMismatchRatio = (a, b) => {
|
|
809
|
+
var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
|
|
733
810
|
const imgA = pngjs.PNG.sync.read(Buffer.from(a));
|
|
734
811
|
const imgB = pngjs.PNG.sync.read(Buffer.from(b));
|
|
735
812
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
@@ -738,7 +815,7 @@ var pngMismatchRatio = (a, b) => {
|
|
|
738
815
|
const total = imgA.width * imgA.height;
|
|
739
816
|
if (total === 0) return { ratio: 0, sizeMismatch: false };
|
|
740
817
|
const mismatched = pixelmatch__default.default(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
|
|
741
|
-
threshold:
|
|
818
|
+
threshold: pixelThreshold
|
|
742
819
|
});
|
|
743
820
|
return { ratio: mismatched / total, sizeMismatch: false };
|
|
744
821
|
};
|
|
@@ -759,8 +836,16 @@ var baselineFinding = (screenshot, baseline) => {
|
|
|
759
836
|
}
|
|
760
837
|
return null;
|
|
761
838
|
};
|
|
762
|
-
var
|
|
763
|
-
|
|
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);
|
|
764
849
|
if (ratio < RESKIN_THRESHOLD) {
|
|
765
850
|
return {
|
|
766
851
|
message: `chrome did not re-skin under alternate brand '${brand}'`,
|
|
@@ -798,7 +883,8 @@ var visualBrandAxis = {
|
|
|
798
883
|
const finding = altBrandFinding(
|
|
799
884
|
artifact.screenshot,
|
|
800
885
|
altArtifact.screenshot,
|
|
801
|
-
DEFAULT_ALT_BRAND
|
|
886
|
+
DEFAULT_ALT_BRAND,
|
|
887
|
+
altArtifact.dom
|
|
802
888
|
);
|
|
803
889
|
if (finding !== null) findings.push(finding);
|
|
804
890
|
const altBrand = brandRegistry.brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
|