@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/index.js CHANGED
@@ -180,10 +180,85 @@ var serveStatic = async (rootDir) => {
180
180
  })
181
181
  };
182
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
183
258
  var IMPACTS = ["critical", "serious", "moderate", "minor"];
184
259
  var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
185
260
  var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
186
- var toFinding = (violation) => {
261
+ var toFinding2 = (violation) => {
187
262
  const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
188
263
  const wcag = wcagTag(violation.tags);
189
264
  return {
@@ -210,7 +285,7 @@ var accessibilityAxis = {
210
285
  if (results === null) {
211
286
  return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
212
287
  }
213
- const findings = results.violations.map(toFinding);
288
+ const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
214
289
  const pass = !findings.some(
215
290
  (finding) => finding.severity === "critical" || finding.severity === "serious"
216
291
  );
@@ -726,8 +801,10 @@ var runtimeHealthAxis = {
726
801
  };
727
802
  var DRIFT_THRESHOLD = 0.01;
728
803
  var RESKIN_THRESHOLD = 5e-3;
804
+ var DRIFT_PIXEL_THRESHOLD = 0.1;
805
+ var RESKIN_PIXEL_THRESHOLD = 0.02;
729
806
  var DEFAULT_ALT_BRAND = "example-customer";
730
- var pngMismatchRatio = (a, b) => {
807
+ var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
731
808
  const imgA = PNG.sync.read(Buffer.from(a));
732
809
  const imgB = PNG.sync.read(Buffer.from(b));
733
810
  if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
@@ -736,7 +813,7 @@ var pngMismatchRatio = (a, b) => {
736
813
  const total = imgA.width * imgA.height;
737
814
  if (total === 0) return { ratio: 0, sizeMismatch: false };
738
815
  const mismatched = pixelmatch(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
739
- threshold: 0.1
816
+ threshold: pixelThreshold
740
817
  });
741
818
  return { ratio: mismatched / total, sizeMismatch: false };
742
819
  };
@@ -757,8 +834,16 @@ var baselineFinding = (screenshot, baseline) => {
757
834
  }
758
835
  return null;
759
836
  };
760
- var altBrandFinding = (screenshot, altScreenshot, brand) => {
761
- 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);
762
847
  if (ratio < RESKIN_THRESHOLD) {
763
848
  return {
764
849
  message: `chrome did not re-skin under alternate brand '${brand}'`,
@@ -796,7 +881,8 @@ var visualBrandAxis = {
796
881
  const finding = altBrandFinding(
797
882
  artifact.screenshot,
798
883
  altArtifact.screenshot,
799
- DEFAULT_ALT_BRAND
884
+ DEFAULT_ALT_BRAND,
885
+ altArtifact.dom
800
886
  );
801
887
  if (finding !== null) findings.push(finding);
802
888
  const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);