@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.js
CHANGED
|
@@ -8,6 +8,7 @@ import { z } from 'zod';
|
|
|
8
8
|
import Anthropic from '@anthropic-ai/sdk';
|
|
9
9
|
import pixelmatch from 'pixelmatch';
|
|
10
10
|
import { PNG } from 'pngjs';
|
|
11
|
+
import { brands, checkInteractiveContrast } from '@keboola/brand-registry';
|
|
11
12
|
|
|
12
13
|
// src/types.ts
|
|
13
14
|
var SEVERITIES = ["critical", "serious", "moderate", "minor"];
|
|
@@ -179,10 +180,85 @@ var serveStatic = async (rootDir) => {
|
|
|
179
180
|
})
|
|
180
181
|
};
|
|
181
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
|
|
182
258
|
var IMPACTS = ["critical", "serious", "moderate", "minor"];
|
|
183
259
|
var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
|
|
184
260
|
var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
|
|
185
|
-
var
|
|
261
|
+
var toFinding2 = (violation) => {
|
|
186
262
|
const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
|
|
187
263
|
const wcag = wcagTag(violation.tags);
|
|
188
264
|
return {
|
|
@@ -209,7 +285,7 @@ var accessibilityAxis = {
|
|
|
209
285
|
if (results === null) {
|
|
210
286
|
return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
|
|
211
287
|
}
|
|
212
|
-
const findings = results.violations.map(
|
|
288
|
+
const findings = [...results.violations.map(toFinding2), ...await clippedTextFindings(page)];
|
|
213
289
|
const pass = !findings.some(
|
|
214
290
|
(finding) => finding.severity === "critical" || finding.severity === "serious"
|
|
215
291
|
);
|
|
@@ -725,8 +801,10 @@ var runtimeHealthAxis = {
|
|
|
725
801
|
};
|
|
726
802
|
var DRIFT_THRESHOLD = 0.01;
|
|
727
803
|
var RESKIN_THRESHOLD = 5e-3;
|
|
804
|
+
var DRIFT_PIXEL_THRESHOLD = 0.1;
|
|
805
|
+
var RESKIN_PIXEL_THRESHOLD = 0.02;
|
|
728
806
|
var DEFAULT_ALT_BRAND = "example-customer";
|
|
729
|
-
var pngMismatchRatio = (a, b) => {
|
|
807
|
+
var pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
|
|
730
808
|
const imgA = PNG.sync.read(Buffer.from(a));
|
|
731
809
|
const imgB = PNG.sync.read(Buffer.from(b));
|
|
732
810
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
@@ -735,7 +813,7 @@ var pngMismatchRatio = (a, b) => {
|
|
|
735
813
|
const total = imgA.width * imgA.height;
|
|
736
814
|
if (total === 0) return { ratio: 0, sizeMismatch: false };
|
|
737
815
|
const mismatched = pixelmatch(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
|
|
738
|
-
threshold:
|
|
816
|
+
threshold: pixelThreshold
|
|
739
817
|
});
|
|
740
818
|
return { ratio: mismatched / total, sizeMismatch: false };
|
|
741
819
|
};
|
|
@@ -756,8 +834,16 @@ var baselineFinding = (screenshot, baseline) => {
|
|
|
756
834
|
}
|
|
757
835
|
return null;
|
|
758
836
|
};
|
|
759
|
-
var
|
|
760
|
-
|
|
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);
|
|
761
847
|
if (ratio < RESKIN_THRESHOLD) {
|
|
762
848
|
return {
|
|
763
849
|
message: `chrome did not re-skin under alternate brand '${brand}'`,
|
|
@@ -767,6 +853,11 @@ var altBrandFinding = (screenshot, altScreenshot, brand) => {
|
|
|
767
853
|
}
|
|
768
854
|
return null;
|
|
769
855
|
};
|
|
856
|
+
var interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).map((warning) => ({
|
|
857
|
+
message: `brand '${brand.id}' ${warning.message}`,
|
|
858
|
+
severity: "moderate",
|
|
859
|
+
detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
|
|
860
|
+
}));
|
|
770
861
|
var overflowFinding = async (page) => {
|
|
771
862
|
const overflows = await page.evaluate(
|
|
772
863
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
@@ -790,9 +881,12 @@ var visualBrandAxis = {
|
|
|
790
881
|
const finding = altBrandFinding(
|
|
791
882
|
artifact.screenshot,
|
|
792
883
|
altArtifact.screenshot,
|
|
793
|
-
DEFAULT_ALT_BRAND
|
|
884
|
+
DEFAULT_ALT_BRAND,
|
|
885
|
+
altArtifact.dom
|
|
794
886
|
);
|
|
795
887
|
if (finding !== null) findings.push(finding);
|
|
888
|
+
const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
|
|
889
|
+
if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
|
|
796
890
|
}
|
|
797
891
|
if (page !== void 0) {
|
|
798
892
|
const finding = await overflowFinding(page);
|