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