@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/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import axe from 'axe-core';
|
|
|
8
8
|
import Anthropic from '@anthropic-ai/sdk';
|
|
9
9
|
import pixelmatch from 'pixelmatch';
|
|
10
10
|
import { PNG } from 'pngjs';
|
|
11
|
+
import { brands, checkInteractiveContrast } from '@keboola/brand-registry';
|
|
11
12
|
import { createReadStream } from 'fs';
|
|
12
13
|
import { createServer } from 'http';
|
|
13
14
|
|
|
@@ -446,10 +447,85 @@ var compareRoutes = async (options) => {
|
|
|
446
447
|
if (ownsBrowser) await browser.close();
|
|
447
448
|
}
|
|
448
449
|
};
|
|
450
|
+
|
|
451
|
+
// src/axes/clipped-text.ts
|
|
452
|
+
var CLIP_TOLERANCE_PX = 2;
|
|
453
|
+
var MAX_REPORTED = 10;
|
|
454
|
+
var findClippedElements = ({
|
|
455
|
+
tolerance,
|
|
456
|
+
max
|
|
457
|
+
}) => {
|
|
458
|
+
const describe = (el) => {
|
|
459
|
+
const tag = el.tagName.toLowerCase();
|
|
460
|
+
const label = el.getAttribute("aria-label");
|
|
461
|
+
if (label) return `${tag}[aria-label="${label}"]`;
|
|
462
|
+
if (el.id) return `${tag}#${el.id}`;
|
|
463
|
+
const name = el.getAttribute("name");
|
|
464
|
+
if (name) return `${tag}[name="${name}"]`;
|
|
465
|
+
const cls = typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}` : "";
|
|
466
|
+
return `${tag}${cls}`;
|
|
467
|
+
};
|
|
468
|
+
const results = [];
|
|
469
|
+
const reported = /* @__PURE__ */ new Set();
|
|
470
|
+
const hasReportedAncestor = (el) => {
|
|
471
|
+
let parent = el.parentElement;
|
|
472
|
+
while (parent) {
|
|
473
|
+
if (reported.has(parent)) return true;
|
|
474
|
+
parent = parent.parentElement;
|
|
475
|
+
}
|
|
476
|
+
return false;
|
|
477
|
+
};
|
|
478
|
+
for (const el of document.querySelectorAll("*")) {
|
|
479
|
+
if (results.length >= max) break;
|
|
480
|
+
if (hasReportedAncestor(el)) continue;
|
|
481
|
+
const style = window.getComputedStyle(el);
|
|
482
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (el.clientWidth <= 1 || el.clientHeight <= 1) continue;
|
|
486
|
+
const overflowX = style.overflowX;
|
|
487
|
+
const isFormValue = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;
|
|
488
|
+
const isSingleLineInput = el instanceof HTMLInputElement;
|
|
489
|
+
if (!isSingleLineInput && overflowX !== "hidden" && overflowX !== "clip") continue;
|
|
490
|
+
if (!isSingleLineInput && style.textOverflow === "ellipsis") continue;
|
|
491
|
+
const overflowBy = el.scrollWidth - el.clientWidth;
|
|
492
|
+
if (overflowBy <= tolerance) continue;
|
|
493
|
+
const text = isFormValue ? el.value : (el.textContent ?? "").trim();
|
|
494
|
+
if (!text) continue;
|
|
495
|
+
reported.add(el);
|
|
496
|
+
results.push({
|
|
497
|
+
selector: describe(el),
|
|
498
|
+
text: text.length > 40 ? `${text.slice(0, 40)}\u2026` : text,
|
|
499
|
+
visibleWidth: Math.round(el.clientWidth),
|
|
500
|
+
contentWidth: Math.round(el.scrollWidth),
|
|
501
|
+
isInput: isFormValue
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return results;
|
|
505
|
+
};
|
|
506
|
+
var toFinding = (clipped) => ({
|
|
507
|
+
message: `Content is cut off with no way to reveal it (${clipped.selector})`,
|
|
508
|
+
severity: "serious",
|
|
509
|
+
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\`.`
|
|
510
|
+
});
|
|
511
|
+
var clippedTextFindings = async (page) => {
|
|
512
|
+
try {
|
|
513
|
+
const clipped = await page.evaluate(findClippedElements, {
|
|
514
|
+
tolerance: CLIP_TOLERANCE_PX,
|
|
515
|
+
max: MAX_REPORTED
|
|
516
|
+
});
|
|
517
|
+
return clipped.map(toFinding);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
console.error("clipped-text: could not inspect the page", error);
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// src/axes/accessibility.ts
|
|
449
525
|
var IMPACTS = ["critical", "serious", "moderate", "minor"];
|
|
450
526
|
var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
|
|
451
527
|
var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
|
|
452
|
-
var
|
|
528
|
+
var toFinding2 = (violation) => {
|
|
453
529
|
const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
|
|
454
530
|
const wcag = wcagTag(violation.tags);
|
|
455
531
|
return {
|
|
@@ -476,7 +552,7 @@ var accessibilityAxis = {
|
|
|
476
552
|
if (results === null) {
|
|
477
553
|
return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
|
|
478
554
|
}
|
|
479
|
-
const findings = results.violations.map(
|
|
555
|
+
const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
|
|
480
556
|
const pass = !findings.some(
|
|
481
557
|
(finding) => finding.severity === "critical" || finding.severity === "serious"
|
|
482
558
|
);
|
|
@@ -718,8 +794,10 @@ var runtimeHealthAxis = {
|
|
|
718
794
|
};
|
|
719
795
|
var DRIFT_THRESHOLD = 0.01;
|
|
720
796
|
var RESKIN_THRESHOLD = 5e-3;
|
|
797
|
+
var DRIFT_PIXEL_THRESHOLD = 0.1;
|
|
798
|
+
var RESKIN_PIXEL_THRESHOLD = 0.02;
|
|
721
799
|
var DEFAULT_ALT_BRAND = "example-customer";
|
|
722
|
-
var pngMismatchRatio = (a, b) => {
|
|
800
|
+
var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
|
|
723
801
|
const imgA = PNG.sync.read(Buffer.from(a));
|
|
724
802
|
const imgB = PNG.sync.read(Buffer.from(b));
|
|
725
803
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
@@ -728,7 +806,7 @@ var pngMismatchRatio = (a, b) => {
|
|
|
728
806
|
const total = imgA.width * imgA.height;
|
|
729
807
|
if (total === 0) return { ratio: 0, sizeMismatch: false };
|
|
730
808
|
const mismatched = pixelmatch(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
|
|
731
|
-
threshold:
|
|
809
|
+
threshold: pixelThreshold
|
|
732
810
|
});
|
|
733
811
|
return { ratio: mismatched / total, sizeMismatch: false };
|
|
734
812
|
};
|
|
@@ -749,8 +827,16 @@ var baselineFinding = (screenshot, baseline) => {
|
|
|
749
827
|
}
|
|
750
828
|
return null;
|
|
751
829
|
};
|
|
752
|
-
var
|
|
753
|
-
|
|
830
|
+
var altBrandWasApplied = (altDom, brand) => altDom.includes(`data-brand="${brand}"`);
|
|
831
|
+
var altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
|
|
832
|
+
if (!altBrandWasApplied(altDom, brand)) {
|
|
833
|
+
return {
|
|
834
|
+
message: `alt-brand re-skin not assessed: the app did not apply '${brand}' from ?brand=`,
|
|
835
|
+
severity: "minor",
|
|
836
|
+
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."
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
const { ratio } = pngMismatchRatio(screenshot, altScreenshot, RESKIN_PIXEL_THRESHOLD);
|
|
754
840
|
if (ratio < RESKIN_THRESHOLD) {
|
|
755
841
|
return {
|
|
756
842
|
message: `chrome did not re-skin under alternate brand '${brand}'`,
|
|
@@ -760,6 +846,11 @@ var altBrandFinding = (screenshot, altScreenshot, brand) => {
|
|
|
760
846
|
}
|
|
761
847
|
return null;
|
|
762
848
|
};
|
|
849
|
+
var interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).map((warning) => ({
|
|
850
|
+
message: `brand '${brand.id}' ${warning.message}`,
|
|
851
|
+
severity: "moderate",
|
|
852
|
+
detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
|
|
853
|
+
}));
|
|
763
854
|
var overflowFinding = async (page) => {
|
|
764
855
|
const overflows = await page.evaluate(
|
|
765
856
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
@@ -783,9 +874,12 @@ var visualBrandAxis = {
|
|
|
783
874
|
const finding = altBrandFinding(
|
|
784
875
|
artifact.screenshot,
|
|
785
876
|
altArtifact.screenshot,
|
|
786
|
-
DEFAULT_ALT_BRAND
|
|
877
|
+
DEFAULT_ALT_BRAND,
|
|
878
|
+
altArtifact.dom
|
|
787
879
|
);
|
|
788
880
|
if (finding !== null) findings.push(finding);
|
|
881
|
+
const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
|
|
882
|
+
if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
|
|
789
883
|
}
|
|
790
884
|
if (page !== void 0) {
|
|
791
885
|
const finding = await overflowFinding(page);
|