@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/dist/cli.js
CHANGED
|
@@ -447,10 +447,85 @@ var compareRoutes = async (options) => {
|
|
|
447
447
|
if (ownsBrowser) await browser.close();
|
|
448
448
|
}
|
|
449
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
|
|
450
525
|
var IMPACTS = ["critical", "serious", "moderate", "minor"];
|
|
451
526
|
var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
|
|
452
527
|
var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
|
|
453
|
-
var
|
|
528
|
+
var toFinding2 = (violation) => {
|
|
454
529
|
const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
|
|
455
530
|
const wcag = wcagTag(violation.tags);
|
|
456
531
|
return {
|
|
@@ -477,7 +552,7 @@ var accessibilityAxis = {
|
|
|
477
552
|
if (results === null) {
|
|
478
553
|
return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
|
|
479
554
|
}
|
|
480
|
-
const findings = results.violations.map(
|
|
555
|
+
const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
|
|
481
556
|
const pass = !findings.some(
|
|
482
557
|
(finding) => finding.severity === "critical" || finding.severity === "serious"
|
|
483
558
|
);
|
|
@@ -719,8 +794,10 @@ var runtimeHealthAxis = {
|
|
|
719
794
|
};
|
|
720
795
|
var DRIFT_THRESHOLD = 0.01;
|
|
721
796
|
var RESKIN_THRESHOLD = 5e-3;
|
|
797
|
+
var DRIFT_PIXEL_THRESHOLD = 0.1;
|
|
798
|
+
var RESKIN_PIXEL_THRESHOLD = 0.02;
|
|
722
799
|
var DEFAULT_ALT_BRAND = "example-customer";
|
|
723
|
-
var pngMismatchRatio = (a, b) => {
|
|
800
|
+
var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
|
|
724
801
|
const imgA = PNG.sync.read(Buffer.from(a));
|
|
725
802
|
const imgB = PNG.sync.read(Buffer.from(b));
|
|
726
803
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
@@ -729,7 +806,7 @@ var pngMismatchRatio = (a, b) => {
|
|
|
729
806
|
const total = imgA.width * imgA.height;
|
|
730
807
|
if (total === 0) return { ratio: 0, sizeMismatch: false };
|
|
731
808
|
const mismatched = pixelmatch(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
|
|
732
|
-
threshold:
|
|
809
|
+
threshold: pixelThreshold
|
|
733
810
|
});
|
|
734
811
|
return { ratio: mismatched / total, sizeMismatch: false };
|
|
735
812
|
};
|
|
@@ -750,8 +827,16 @@ var baselineFinding = (screenshot, baseline) => {
|
|
|
750
827
|
}
|
|
751
828
|
return null;
|
|
752
829
|
};
|
|
753
|
-
var
|
|
754
|
-
|
|
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);
|
|
755
840
|
if (ratio < RESKIN_THRESHOLD) {
|
|
756
841
|
return {
|
|
757
842
|
message: `chrome did not re-skin under alternate brand '${brand}'`,
|
|
@@ -789,7 +874,8 @@ var visualBrandAxis = {
|
|
|
789
874
|
const finding = altBrandFinding(
|
|
790
875
|
artifact.screenshot,
|
|
791
876
|
altArtifact.screenshot,
|
|
792
|
-
DEFAULT_ALT_BRAND
|
|
877
|
+
DEFAULT_ALT_BRAND,
|
|
878
|
+
altArtifact.dom
|
|
793
879
|
);
|
|
794
880
|
if (finding !== null) findings.push(finding);
|
|
795
881
|
const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
|