@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/AGENTS.md +13 -0
- package/dist/cli.cjs +93 -7
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +93 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +93 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +93 -7
- package/dist/index.js.map +1 -1
- package/package.json +9 -7
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
|
|
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(
|
|
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:
|
|
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
|
|
769
|
-
|
|
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);
|