@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.cjs CHANGED
@@ -10,6 +10,7 @@ var zod = require('zod');
10
10
  var Anthropic = require('@anthropic-ai/sdk');
11
11
  var pixelmatch = require('pixelmatch');
12
12
  var pngjs = require('pngjs');
13
+ var brandRegistry = require('@keboola/brand-registry');
13
14
 
14
15
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
15
16
 
@@ -187,10 +188,85 @@ var serveStatic = async (rootDir) => {
187
188
  })
188
189
  };
189
190
  };
191
+
192
+ // src/axes/clipped-text.ts
193
+ var CLIP_TOLERANCE_PX = 2;
194
+ var MAX_REPORTED = 10;
195
+ var findClippedElements = ({
196
+ tolerance,
197
+ max
198
+ }) => {
199
+ const describe = (el) => {
200
+ const tag = el.tagName.toLowerCase();
201
+ const label = el.getAttribute("aria-label");
202
+ if (label) return `${tag}[aria-label="${label}"]`;
203
+ if (el.id) return `${tag}#${el.id}`;
204
+ const name = el.getAttribute("name");
205
+ if (name) return `${tag}[name="${name}"]`;
206
+ const cls = typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}` : "";
207
+ return `${tag}${cls}`;
208
+ };
209
+ const results = [];
210
+ const reported = /* @__PURE__ */ new Set();
211
+ const hasReportedAncestor = (el) => {
212
+ let parent = el.parentElement;
213
+ while (parent) {
214
+ if (reported.has(parent)) return true;
215
+ parent = parent.parentElement;
216
+ }
217
+ return false;
218
+ };
219
+ for (const el of document.querySelectorAll("*")) {
220
+ if (results.length >= max) break;
221
+ if (hasReportedAncestor(el)) continue;
222
+ const style = window.getComputedStyle(el);
223
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
224
+ continue;
225
+ }
226
+ if (el.clientWidth <= 1 || el.clientHeight <= 1) continue;
227
+ const overflowX = style.overflowX;
228
+ const isFormValue = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement;
229
+ const isSingleLineInput = el instanceof HTMLInputElement;
230
+ if (!isSingleLineInput && overflowX !== "hidden" && overflowX !== "clip") continue;
231
+ if (!isSingleLineInput && style.textOverflow === "ellipsis") continue;
232
+ const overflowBy = el.scrollWidth - el.clientWidth;
233
+ if (overflowBy <= tolerance) continue;
234
+ const text = isFormValue ? el.value : (el.textContent ?? "").trim();
235
+ if (!text) continue;
236
+ reported.add(el);
237
+ results.push({
238
+ selector: describe(el),
239
+ text: text.length > 40 ? `${text.slice(0, 40)}\u2026` : text,
240
+ visibleWidth: Math.round(el.clientWidth),
241
+ contentWidth: Math.round(el.scrollWidth),
242
+ isInput: isFormValue
243
+ });
244
+ }
245
+ return results;
246
+ };
247
+ var toFinding = (clipped) => ({
248
+ message: `Content is cut off with no way to reveal it (${clipped.selector})`,
249
+ severity: "serious",
250
+ 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\`.`
251
+ });
252
+ var clippedTextFindings = async (page) => {
253
+ try {
254
+ const clipped = await page.evaluate(findClippedElements, {
255
+ tolerance: CLIP_TOLERANCE_PX,
256
+ max: MAX_REPORTED
257
+ });
258
+ return clipped.map(toFinding);
259
+ } catch (error) {
260
+ console.error("clipped-text: could not inspect the page", error);
261
+ return [];
262
+ }
263
+ };
264
+
265
+ // src/axes/accessibility.ts
190
266
  var IMPACTS = ["critical", "serious", "moderate", "minor"];
191
267
  var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
192
268
  var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
193
- var toFinding = (violation) => {
269
+ var toFinding2 = (violation) => {
194
270
  const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
195
271
  const wcag = wcagTag(violation.tags);
196
272
  return {
@@ -217,7 +293,7 @@ var accessibilityAxis = {
217
293
  if (results === null) {
218
294
  return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
219
295
  }
220
- const findings = results.violations.map(toFinding);
296
+ const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
221
297
  const pass = !findings.some(
222
298
  (finding) => finding.severity === "critical" || finding.severity === "serious"
223
299
  );
@@ -733,8 +809,10 @@ var runtimeHealthAxis = {
733
809
  };
734
810
  var DRIFT_THRESHOLD = 0.01;
735
811
  var RESKIN_THRESHOLD = 5e-3;
812
+ var DRIFT_PIXEL_THRESHOLD = 0.1;
813
+ var RESKIN_PIXEL_THRESHOLD = 0.02;
736
814
  var DEFAULT_ALT_BRAND = "example-customer";
737
- var pngMismatchRatio = (a, b) => {
815
+ var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
738
816
  const imgA = pngjs.PNG.sync.read(Buffer.from(a));
739
817
  const imgB = pngjs.PNG.sync.read(Buffer.from(b));
740
818
  if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
@@ -743,7 +821,7 @@ var pngMismatchRatio = (a, b) => {
743
821
  const total = imgA.width * imgA.height;
744
822
  if (total === 0) return { ratio: 0, sizeMismatch: false };
745
823
  const mismatched = pixelmatch__default.default(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
746
- threshold: 0.1
824
+ threshold: pixelThreshold
747
825
  });
748
826
  return { ratio: mismatched / total, sizeMismatch: false };
749
827
  };
@@ -764,8 +842,16 @@ var baselineFinding = (screenshot, baseline) => {
764
842
  }
765
843
  return null;
766
844
  };
767
- var altBrandFinding = (screenshot, altScreenshot, brand) => {
768
- const { ratio } = pngMismatchRatio(screenshot, altScreenshot);
845
+ var altBrandWasApplied = (altDom, brand) => altDom.includes(`data-brand="${brand}"`);
846
+ var altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
847
+ if (!altBrandWasApplied(altDom, brand)) {
848
+ return {
849
+ message: `alt-brand re-skin not assessed: the app did not apply '${brand}' from ?brand=`,
850
+ severity: "minor",
851
+ 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."
852
+ };
853
+ }
854
+ const { ratio } = pngMismatchRatio(screenshot, altScreenshot, RESKIN_PIXEL_THRESHOLD);
769
855
  if (ratio < RESKIN_THRESHOLD) {
770
856
  return {
771
857
  message: `chrome did not re-skin under alternate brand '${brand}'`,
@@ -775,6 +861,11 @@ var altBrandFinding = (screenshot, altScreenshot, brand) => {
775
861
  }
776
862
  return null;
777
863
  };
864
+ var interactiveContrastFindings = (brand) => brandRegistry.checkInteractiveContrast(brand).map((warning) => ({
865
+ message: `brand '${brand.id}' ${warning.message}`,
866
+ severity: "moderate",
867
+ detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
868
+ }));
778
869
  var overflowFinding = async (page) => {
779
870
  const overflows = await page.evaluate(
780
871
  () => document.documentElement.scrollWidth > document.documentElement.clientWidth
@@ -798,9 +889,12 @@ var visualBrandAxis = {
798
889
  const finding = altBrandFinding(
799
890
  artifact.screenshot,
800
891
  altArtifact.screenshot,
801
- DEFAULT_ALT_BRAND
892
+ DEFAULT_ALT_BRAND,
893
+ altArtifact.dom
802
894
  );
803
895
  if (finding !== null) findings.push(finding);
896
+ const altBrand = brandRegistry.brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
897
+ if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
804
898
  }
805
899
  if (page !== void 0) {
806
900
  const finding = await overflowFinding(page);