@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.cjs CHANGED
@@ -188,10 +188,85 @@ var serveStatic = async (rootDir) => {
188
188
  })
189
189
  };
190
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
191
266
  var IMPACTS = ["critical", "serious", "moderate", "minor"];
192
267
  var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
193
268
  var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
194
- var toFinding = (violation) => {
269
+ var toFinding2 = (violation) => {
195
270
  const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
196
271
  const wcag = wcagTag(violation.tags);
197
272
  return {
@@ -218,7 +293,7 @@ var accessibilityAxis = {
218
293
  if (results === null) {
219
294
  return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
220
295
  }
221
- const findings = results.violations.map(toFinding);
296
+ const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
222
297
  const pass = !findings.some(
223
298
  (finding) => finding.severity === "critical" || finding.severity === "serious"
224
299
  );
@@ -734,8 +809,10 @@ var runtimeHealthAxis = {
734
809
  };
735
810
  var DRIFT_THRESHOLD = 0.01;
736
811
  var RESKIN_THRESHOLD = 5e-3;
812
+ var DRIFT_PIXEL_THRESHOLD = 0.1;
813
+ var RESKIN_PIXEL_THRESHOLD = 0.02;
737
814
  var DEFAULT_ALT_BRAND = "example-customer";
738
- var pngMismatchRatio = (a, b) => {
815
+ var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
739
816
  const imgA = pngjs.PNG.sync.read(Buffer.from(a));
740
817
  const imgB = pngjs.PNG.sync.read(Buffer.from(b));
741
818
  if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
@@ -744,7 +821,7 @@ var pngMismatchRatio = (a, b) => {
744
821
  const total = imgA.width * imgA.height;
745
822
  if (total === 0) return { ratio: 0, sizeMismatch: false };
746
823
  const mismatched = pixelmatch__default.default(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
747
- threshold: 0.1
824
+ threshold: pixelThreshold
748
825
  });
749
826
  return { ratio: mismatched / total, sizeMismatch: false };
750
827
  };
@@ -765,8 +842,16 @@ var baselineFinding = (screenshot, baseline) => {
765
842
  }
766
843
  return null;
767
844
  };
768
- var altBrandFinding = (screenshot, altScreenshot, brand) => {
769
- 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);
770
855
  if (ratio < RESKIN_THRESHOLD) {
771
856
  return {
772
857
  message: `chrome did not re-skin under alternate brand '${brand}'`,
@@ -804,7 +889,8 @@ var visualBrandAxis = {
804
889
  const finding = altBrandFinding(
805
890
  artifact.screenshot,
806
891
  altArtifact.screenshot,
807
- DEFAULT_ALT_BRAND
892
+ DEFAULT_ALT_BRAND,
893
+ altArtifact.dom
808
894
  );
809
895
  if (finding !== null) findings.push(finding);
810
896
  const altBrand = brandRegistry.brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);