@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/index.js CHANGED
@@ -8,6 +8,7 @@ import { z } from 'zod';
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
 
12
13
  // src/types.ts
13
14
  var SEVERITIES = ["critical", "serious", "moderate", "minor"];
@@ -179,10 +180,85 @@ var serveStatic = async (rootDir) => {
179
180
  })
180
181
  };
181
182
  };
183
+
184
+ // src/axes/clipped-text.ts
185
+ var CLIP_TOLERANCE_PX = 2;
186
+ var MAX_REPORTED = 10;
187
+ var findClippedElements = ({
188
+ tolerance,
189
+ max
190
+ }) => {
191
+ const describe = (el) => {
192
+ const tag = el.tagName.toLowerCase();
193
+ const label = el.getAttribute("aria-label");
194
+ if (label) return `${tag}[aria-label="${label}"]`;
195
+ if (el.id) return `${tag}#${el.id}`;
196
+ const name = el.getAttribute("name");
197
+ if (name) return `${tag}[name="${name}"]`;
198
+ const cls = typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}` : "";
199
+ return `${tag}${cls}`;
200
+ };
201
+ const results = [];
202
+ const reported = /* @__PURE__ */ new Set();
203
+ const hasReportedAncestor = (el) => {
204
+ let parent = el.parentElement;
205
+ while (parent) {
206
+ if (reported.has(parent)) return true;
207
+ parent = parent.parentElement;
208
+ }
209
+ return false;
210
+ };
211
+ for (const el of document.querySelectorAll("*")) {
212
+ if (results.length >= max) break;
213
+ if (hasReportedAncestor(el)) continue;
214
+ const style = window.getComputedStyle(el);
215
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
216
+ continue;
217
+ }
218
+ if (el.clientWidth <= 1 || el.clientHeight <= 1) continue;
219
+ const overflowX = style.overflowX;
220
+ const isFormValue = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;
221
+ const isSingleLineInput = el instanceof HTMLInputElement;
222
+ if (!isSingleLineInput && overflowX !== "hidden" && overflowX !== "clip") continue;
223
+ if (!isSingleLineInput && style.textOverflow === "ellipsis") continue;
224
+ const overflowBy = el.scrollWidth - el.clientWidth;
225
+ if (overflowBy <= tolerance) continue;
226
+ const text = isFormValue ? el.value : (el.textContent ?? "").trim();
227
+ if (!text) continue;
228
+ reported.add(el);
229
+ results.push({
230
+ selector: describe(el),
231
+ text: text.length > 40 ? `${text.slice(0, 40)}\u2026` : text,
232
+ visibleWidth: Math.round(el.clientWidth),
233
+ contentWidth: Math.round(el.scrollWidth),
234
+ isInput: isFormValue
235
+ });
236
+ }
237
+ return results;
238
+ };
239
+ var toFinding = (clipped) => ({
240
+ message: `Content is cut off with no way to reveal it (${clipped.selector})`,
241
+ severity: "serious",
242
+ 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\`.`
243
+ });
244
+ var clippedTextFindings = async (page) => {
245
+ try {
246
+ const clipped = await page.evaluate(findClippedElements, {
247
+ tolerance: CLIP_TOLERANCE_PX,
248
+ max: MAX_REPORTED
249
+ });
250
+ return clipped.map(toFinding);
251
+ } catch (error) {
252
+ console.error("clipped-text: could not inspect the page", error);
253
+ return [];
254
+ }
255
+ };
256
+
257
+ // src/axes/accessibility.ts
182
258
  var IMPACTS = ["critical", "serious", "moderate", "minor"];
183
259
  var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
184
260
  var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
185
- var toFinding = (violation) => {
261
+ var toFinding2 = (violation) => {
186
262
  const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
187
263
  const wcag = wcagTag(violation.tags);
188
264
  return {
@@ -209,7 +285,7 @@ var accessibilityAxis = {
209
285
  if (results === null) {
210
286
  return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
211
287
  }
212
- const findings = results.violations.map(toFinding);
288
+ const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
213
289
  const pass = !findings.some(
214
290
  (finding) => finding.severity === "critical" || finding.severity === "serious"
215
291
  );
@@ -725,8 +801,10 @@ var runtimeHealthAxis = {
725
801
  };
726
802
  var DRIFT_THRESHOLD = 0.01;
727
803
  var RESKIN_THRESHOLD = 5e-3;
804
+ var DRIFT_PIXEL_THRESHOLD = 0.1;
805
+ var RESKIN_PIXEL_THRESHOLD = 0.02;
728
806
  var DEFAULT_ALT_BRAND = "example-customer";
729
- var pngMismatchRatio = (a, b) => {
807
+ var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
730
808
  const imgA = PNG.sync.read(Buffer.from(a));
731
809
  const imgB = PNG.sync.read(Buffer.from(b));
732
810
  if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
@@ -735,7 +813,7 @@ var pngMismatchRatio = (a, b) => {
735
813
  const total = imgA.width * imgA.height;
736
814
  if (total === 0) return { ratio: 0, sizeMismatch: false };
737
815
  const mismatched = pixelmatch(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
738
- threshold: 0.1
816
+ threshold: pixelThreshold
739
817
  });
740
818
  return { ratio: mismatched / total, sizeMismatch: false };
741
819
  };
@@ -756,8 +834,16 @@ var baselineFinding = (screenshot, baseline) => {
756
834
  }
757
835
  return null;
758
836
  };
759
- var altBrandFinding = (screenshot, altScreenshot, brand) => {
760
- const { ratio } = pngMismatchRatio(screenshot, altScreenshot);
837
+ var altBrandWasApplied = (altDom, brand) => altDom.includes(`data-brand="${brand}"`);
838
+ var altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
839
+ if (!altBrandWasApplied(altDom, brand)) {
840
+ return {
841
+ message: `alt-brand re-skin not assessed: the app did not apply '${brand}' from ?brand=`,
842
+ severity: "minor",
843
+ 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."
844
+ };
845
+ }
846
+ const { ratio } = pngMismatchRatio(screenshot, altScreenshot, RESKIN_PIXEL_THRESHOLD);
761
847
  if (ratio < RESKIN_THRESHOLD) {
762
848
  return {
763
849
  message: `chrome did not re-skin under alternate brand '${brand}'`,
@@ -767,6 +853,11 @@ var altBrandFinding = (screenshot, altScreenshot, brand) => {
767
853
  }
768
854
  return null;
769
855
  };
856
+ var interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).map((warning) => ({
857
+ message: `brand '${brand.id}' ${warning.message}`,
858
+ severity: "moderate",
859
+ detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
860
+ }));
770
861
  var overflowFinding = async (page) => {
771
862
  const overflows = await page.evaluate(
772
863
  () => document.documentElement.scrollWidth > document.documentElement.clientWidth
@@ -790,9 +881,12 @@ var visualBrandAxis = {
790
881
  const finding = altBrandFinding(
791
882
  artifact.screenshot,
792
883
  altArtifact.screenshot,
793
- DEFAULT_ALT_BRAND
884
+ DEFAULT_ALT_BRAND,
885
+ altArtifact.dom
794
886
  );
795
887
  if (finding !== null) findings.push(finding);
888
+ const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
889
+ if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
796
890
  }
797
891
  if (page !== void 0) {
798
892
  const finding = await overflowFinding(page);