@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.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
|
|
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(
|
|
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:
|
|
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
|
|
761
|
-
|
|
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);
|