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