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