@keboola/validate-ui 0.2.0 → 0.4.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/cli.cjs +544 -87
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +544 -87
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +410 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -5
- package/dist/index.d.ts +196 -5
- package/dist/index.js +397 -3
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,452 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { stat, readFile } from 'fs/promises';
|
|
2
3
|
import { resolve, join, normalize, sep, extname } from 'path';
|
|
3
4
|
import process2 from 'process';
|
|
4
5
|
import { chromium } from 'playwright';
|
|
5
|
-
import axe from 'axe-core';
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import axe from 'axe-core';
|
|
7
8
|
import Anthropic from '@anthropic-ai/sdk';
|
|
8
9
|
import pixelmatch from 'pixelmatch';
|
|
9
10
|
import { PNG } from 'pngjs';
|
|
11
|
+
import { brands, checkInteractiveContrast } from '@keboola/brand-registry';
|
|
10
12
|
import { createReadStream } from 'fs';
|
|
11
|
-
import { stat } from 'fs/promises';
|
|
12
13
|
import { createServer } from 'http';
|
|
13
14
|
|
|
15
|
+
// src/prepare-screenshot.ts
|
|
16
|
+
var disableAnimations = async (page) => {
|
|
17
|
+
await page.addStyleTag({
|
|
18
|
+
content: `*, *::before, *::after {
|
|
19
|
+
animation-duration: 0s !important;
|
|
20
|
+
animation-delay: 0s !important;
|
|
21
|
+
transition-duration: 0s !important;
|
|
22
|
+
transition-delay: 0s !important;
|
|
23
|
+
caret-color: transparent !important;
|
|
24
|
+
}`
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
var parkMouseOffElements = async (page) => {
|
|
28
|
+
await page.mouse.move(0, 0);
|
|
29
|
+
};
|
|
30
|
+
var waitForFonts = async (page) => {
|
|
31
|
+
await page.evaluate(() => document.fonts.ready.then(() => void 0));
|
|
32
|
+
};
|
|
33
|
+
var prepareForScreenshot = async (page) => {
|
|
34
|
+
await disableAnimations(page);
|
|
35
|
+
await parkMouseOffElements(page);
|
|
36
|
+
await waitForFonts(page);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/capture.ts
|
|
40
|
+
var DESKTOP_VIEWPORT = { label: "desktop", width: 1280, height: 800 };
|
|
41
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
42
|
+
var attachRecorders = (page) => {
|
|
43
|
+
const consoleEntries = [];
|
|
44
|
+
const networkEntries = [];
|
|
45
|
+
page.on("console", (message) => {
|
|
46
|
+
consoleEntries.push({
|
|
47
|
+
type: message.type(),
|
|
48
|
+
text: message.text(),
|
|
49
|
+
location: message.location().url || void 0
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
page.on("pageerror", (error) => {
|
|
53
|
+
consoleEntries.push({ type: "pageerror", text: error.message });
|
|
54
|
+
});
|
|
55
|
+
page.on("requestfailed", (request) => {
|
|
56
|
+
networkEntries.push({
|
|
57
|
+
url: request.url(),
|
|
58
|
+
method: request.method(),
|
|
59
|
+
status: 0,
|
|
60
|
+
failure: request.failure()?.errorText
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
page.on("response", (response) => {
|
|
64
|
+
networkEntries.push({
|
|
65
|
+
url: response.url(),
|
|
66
|
+
method: response.request().method(),
|
|
67
|
+
status: response.status()
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
return { console: consoleEntries, network: networkEntries };
|
|
71
|
+
};
|
|
72
|
+
var capturePage = async (page, url, viewport, timeoutMs = DEFAULT_TIMEOUT_MS) => {
|
|
73
|
+
const recorders = attachRecorders(page);
|
|
74
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
75
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: timeoutMs });
|
|
76
|
+
await prepareForScreenshot(page);
|
|
77
|
+
const screenshot = await page.screenshot({ fullPage: true });
|
|
78
|
+
const dom = await page.content();
|
|
79
|
+
const renderedText = (await page.evaluate(() => document.body.innerText)).trim();
|
|
80
|
+
return {
|
|
81
|
+
url,
|
|
82
|
+
viewport,
|
|
83
|
+
screenshot,
|
|
84
|
+
dom,
|
|
85
|
+
renderedText,
|
|
86
|
+
console: recorders.console,
|
|
87
|
+
network: recorders.network
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// src/compare/dedup.ts
|
|
92
|
+
var groupKey = (finding) => `${finding.kind}::${finding.valueSignature}`;
|
|
93
|
+
var dedupeSharedChrome = (perRoute) => {
|
|
94
|
+
const routesByGroup = /* @__PURE__ */ new Map();
|
|
95
|
+
for (const { route, findings } of perRoute) {
|
|
96
|
+
for (const finding of findings) {
|
|
97
|
+
const key = groupKey(finding);
|
|
98
|
+
const routes = routesByGroup.get(key) ?? /* @__PURE__ */ new Set();
|
|
99
|
+
routes.add(route);
|
|
100
|
+
routesByGroup.set(key, routes);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const sharedGroups = new Set(
|
|
104
|
+
[...routesByGroup].filter(([, routes]) => routes.size >= 2).map(([key]) => key)
|
|
105
|
+
);
|
|
106
|
+
const sharedChrome = [];
|
|
107
|
+
const seenShared = /* @__PURE__ */ new Set();
|
|
108
|
+
const trimmedPerRoute = perRoute.map(({ route, findings }) => ({
|
|
109
|
+
route,
|
|
110
|
+
findings: findings.filter((finding) => !sharedGroups.has(groupKey(finding)))
|
|
111
|
+
}));
|
|
112
|
+
for (const { findings } of perRoute) {
|
|
113
|
+
for (const finding of findings) {
|
|
114
|
+
const key = groupKey(finding);
|
|
115
|
+
if (!sharedGroups.has(key) || seenShared.has(key)) continue;
|
|
116
|
+
seenShared.add(key);
|
|
117
|
+
const routes = [...routesByGroup.get(key) ?? []].sort();
|
|
118
|
+
sharedChrome.push({
|
|
119
|
+
...finding,
|
|
120
|
+
message: `${finding.message} (shared across ${routes.length} routes: ${routes.join(", ")})`
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { sharedChrome, perRoute: trimmedPerRoute };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/compare/diff.ts
|
|
128
|
+
var matchesGlob = (pattern, value) => {
|
|
129
|
+
if (!pattern.includes("*")) return pattern === value;
|
|
130
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
131
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
132
|
+
};
|
|
133
|
+
var multisetMissing = (from, to) => {
|
|
134
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const value of to) remaining.set(value, (remaining.get(value) ?? 0) + 1);
|
|
136
|
+
const missing = [];
|
|
137
|
+
for (const value of from) {
|
|
138
|
+
const count = remaining.get(value) ?? 0;
|
|
139
|
+
if (count > 0) remaining.set(value, count - 1);
|
|
140
|
+
else missing.push(value);
|
|
141
|
+
}
|
|
142
|
+
return missing;
|
|
143
|
+
};
|
|
144
|
+
var signature = (regionKey, tokens) => `${regionKey}::${[...tokens].sort().join("|")}`;
|
|
145
|
+
var regionRemovalRule = (rules, key) => rules.find(
|
|
146
|
+
(rule) => rule.value === void 0 && rule.region !== void 0 && matchesGlob(rule.region, key)
|
|
147
|
+
);
|
|
148
|
+
var valueRuleMatches = (rule, key, value) => rule.value !== void 0 && matchesGlob(rule.value, value) && (rule.region === void 0 || matchesGlob(rule.region, key));
|
|
149
|
+
var removedFinding = (region, rule) => ({
|
|
150
|
+
regionKey: region.key,
|
|
151
|
+
kind: "region-removed",
|
|
152
|
+
valueSignature: signature(region.key, region.values),
|
|
153
|
+
message: rule === void 0 ? `region "${region.label}" was removed` : `region "${region.label}" removed as expected`,
|
|
154
|
+
severity: rule === void 0 ? "serious" : "minor",
|
|
155
|
+
...rule !== void 0 && { suppressed: { reason: rule.reason } }
|
|
156
|
+
});
|
|
157
|
+
var degradedFinding = (region) => ({
|
|
158
|
+
regionKey: region.key,
|
|
159
|
+
kind: "region-degraded",
|
|
160
|
+
valueSignature: signature(region.key, [region.state]),
|
|
161
|
+
message: `region "${region.label}" degraded to ${region.state} state \u2014 not compared as value loss`,
|
|
162
|
+
severity: "minor"
|
|
163
|
+
});
|
|
164
|
+
var addedFinding = (region) => ({
|
|
165
|
+
regionKey: region.key,
|
|
166
|
+
kind: "region-added",
|
|
167
|
+
valueSignature: signature(region.key, []),
|
|
168
|
+
message: `region "${region.label}" is new`,
|
|
169
|
+
severity: "minor"
|
|
170
|
+
});
|
|
171
|
+
var diffRegionValues = (oldRegion, newRegion, rules) => {
|
|
172
|
+
const missing = multisetMissing(oldRegion.values, newRegion.values);
|
|
173
|
+
if (missing.length === 0) return [];
|
|
174
|
+
const active = [];
|
|
175
|
+
const suppressed = [];
|
|
176
|
+
const reasons = /* @__PURE__ */ new Set();
|
|
177
|
+
for (const value of missing) {
|
|
178
|
+
const rule = rules.find((r) => valueRuleMatches(r, newRegion.key, value));
|
|
179
|
+
if (rule === void 0) active.push(value);
|
|
180
|
+
else {
|
|
181
|
+
suppressed.push(value);
|
|
182
|
+
reasons.add(rule.reason);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const findings = [];
|
|
186
|
+
if (active.length > 0) {
|
|
187
|
+
findings.push({
|
|
188
|
+
regionKey: newRegion.key,
|
|
189
|
+
kind: "value-missing",
|
|
190
|
+
valueSignature: signature(newRegion.key, active),
|
|
191
|
+
message: `region "${newRegion.label}" is missing ${active.length} value(s)`,
|
|
192
|
+
severity: "moderate",
|
|
193
|
+
detail: active.join(", ")
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (suppressed.length > 0) {
|
|
197
|
+
findings.push({
|
|
198
|
+
regionKey: newRegion.key,
|
|
199
|
+
kind: "value-missing",
|
|
200
|
+
valueSignature: signature(newRegion.key, suppressed),
|
|
201
|
+
message: `region "${newRegion.label}" is missing ${suppressed.length} expected value(s)`,
|
|
202
|
+
severity: "minor",
|
|
203
|
+
detail: suppressed.join(", "),
|
|
204
|
+
suppressed: { reason: [...reasons].join("; ") }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return findings;
|
|
208
|
+
};
|
|
209
|
+
var diffSnapshots = (oldSnap, newSnap, config = {}) => {
|
|
210
|
+
const rules = config.expectedAbsent ?? [];
|
|
211
|
+
const newByKey = new Map(newSnap.regions.map((region) => [region.key, region]));
|
|
212
|
+
const oldKeys = new Set(oldSnap.regions.map((region) => region.key));
|
|
213
|
+
const findings = [];
|
|
214
|
+
for (const oldRegion of oldSnap.regions) {
|
|
215
|
+
const newRegion = newByKey.get(oldRegion.key);
|
|
216
|
+
if (newRegion === void 0) {
|
|
217
|
+
findings.push(removedFinding(oldRegion, regionRemovalRule(rules, oldRegion.key)));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (oldRegion.state === "ok" && newRegion.state !== "ok") {
|
|
221
|
+
findings.push(degradedFinding(newRegion));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
findings.push(...diffRegionValues(oldRegion, newRegion, rules));
|
|
225
|
+
}
|
|
226
|
+
for (const newRegion of newSnap.regions) {
|
|
227
|
+
if (!oldKeys.has(newRegion.key)) findings.push(addedFinding(newRegion));
|
|
228
|
+
}
|
|
229
|
+
return findings;
|
|
230
|
+
};
|
|
231
|
+
var comparePass = (findings) => !findings.some(
|
|
232
|
+
(f) => f.suppressed === void 0 && (f.severity === "serious" || f.severity === "critical")
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// src/compare/regions.ts
|
|
236
|
+
var CANDIDATE_SELECTOR = [
|
|
237
|
+
"[data-region]",
|
|
238
|
+
"main",
|
|
239
|
+
"nav",
|
|
240
|
+
"header",
|
|
241
|
+
"aside",
|
|
242
|
+
"footer",
|
|
243
|
+
"section[aria-label]",
|
|
244
|
+
"[role=main]",
|
|
245
|
+
"[role=navigation]",
|
|
246
|
+
"[role=banner]",
|
|
247
|
+
"[role=complementary]",
|
|
248
|
+
"[role=contentinfo]",
|
|
249
|
+
"[role=region]"
|
|
250
|
+
].join(",");
|
|
251
|
+
var DEFAULT_EMPTY_MARKERS = [
|
|
252
|
+
"no data",
|
|
253
|
+
"no results",
|
|
254
|
+
"nothing to show",
|
|
255
|
+
"nothing here",
|
|
256
|
+
"get started"
|
|
257
|
+
];
|
|
258
|
+
var DEFAULT_ERROR_MARKERS = [
|
|
259
|
+
"something went wrong",
|
|
260
|
+
"failed to load",
|
|
261
|
+
"could not load",
|
|
262
|
+
"unavailable",
|
|
263
|
+
"try again"
|
|
264
|
+
];
|
|
265
|
+
var extractRegions = async (page, config = {}) => {
|
|
266
|
+
const emptyMarkers = [...DEFAULT_EMPTY_MARKERS, ...config.emptyStateMarkers ?? []];
|
|
267
|
+
const errorMarkers = [...DEFAULT_ERROR_MARKERS, ...config.errorStateMarkers ?? []];
|
|
268
|
+
return page.evaluate(
|
|
269
|
+
({ selector, emptyMarkers: emptyMarkers2, errorMarkers: errorMarkers2 }) => {
|
|
270
|
+
const STATES = ["ok", "empty", "error", "loading"];
|
|
271
|
+
const isState = (value) => STATES.includes(value);
|
|
272
|
+
const slugify = (raw) => raw.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "region";
|
|
273
|
+
const roleOf = (el) => {
|
|
274
|
+
const explicit = el.getAttribute("role");
|
|
275
|
+
if (explicit !== null && explicit !== "") return explicit;
|
|
276
|
+
const tag = el.tagName.toLowerCase();
|
|
277
|
+
if (tag === "main") return "main";
|
|
278
|
+
if (tag === "nav") return "navigation";
|
|
279
|
+
if (tag === "aside") return "complementary";
|
|
280
|
+
if (tag === "header" || tag === "footer") {
|
|
281
|
+
if (el.closest("main,nav,section,article,aside") !== null) return void 0;
|
|
282
|
+
return tag === "header" ? "banner" : "contentinfo";
|
|
283
|
+
}
|
|
284
|
+
return void 0;
|
|
285
|
+
};
|
|
286
|
+
const headingOf = (el) => {
|
|
287
|
+
const heading = el.querySelector("h1,h2,h3,h4,h5,h6");
|
|
288
|
+
const text = heading?.textContent?.trim();
|
|
289
|
+
return text !== void 0 && text !== "" ? text : void 0;
|
|
290
|
+
};
|
|
291
|
+
const keyOf = (el) => {
|
|
292
|
+
const region = el.getAttribute("data-region");
|
|
293
|
+
if (region !== null && region !== "") return slugify(region);
|
|
294
|
+
const testid = el.getAttribute("data-testid");
|
|
295
|
+
if (testid !== null && testid !== "") return slugify(testid);
|
|
296
|
+
const role = roleOf(el);
|
|
297
|
+
if (role !== void 0) return role;
|
|
298
|
+
const label = el.getAttribute("aria-label");
|
|
299
|
+
if (label !== null && label !== "") return slugify(label);
|
|
300
|
+
const heading = headingOf(el);
|
|
301
|
+
if (heading !== void 0) return slugify(heading);
|
|
302
|
+
return slugify(el.tagName);
|
|
303
|
+
};
|
|
304
|
+
const labelOf = (el, key) => {
|
|
305
|
+
const label = el.getAttribute("aria-label");
|
|
306
|
+
if (label !== null && label.trim() !== "") return label.trim();
|
|
307
|
+
const heading = headingOf(el);
|
|
308
|
+
if (heading !== void 0) return heading;
|
|
309
|
+
const testid = el.getAttribute("data-testid");
|
|
310
|
+
if (testid !== null && testid !== "") return testid;
|
|
311
|
+
return key;
|
|
312
|
+
};
|
|
313
|
+
const textOf = (el) => (el.innerText || el.textContent || "").trim();
|
|
314
|
+
const stateOf = (el, text) => {
|
|
315
|
+
const explicit = (el.getAttribute("data-state") ?? "").toLowerCase();
|
|
316
|
+
if (isState(explicit)) return explicit;
|
|
317
|
+
const lower = text.toLowerCase();
|
|
318
|
+
if (errorMarkers2.some((marker) => lower.includes(marker))) return "error";
|
|
319
|
+
if (lower.includes("loading")) return "loading";
|
|
320
|
+
if (emptyMarkers2.some((marker) => lower.includes(marker))) return "empty";
|
|
321
|
+
return "ok";
|
|
322
|
+
};
|
|
323
|
+
const valuesOf = (text) => (text.match(/-?\$?\s?\d[\d,]*(?:\.\d+)?%?/g) ?? []).map((token) => token.replace(/[$\s,]/g, "")).filter((token) => token !== "" && token !== "-");
|
|
324
|
+
const qualifies = (el) => {
|
|
325
|
+
const dataRegion = el.getAttribute("data-region");
|
|
326
|
+
if (dataRegion !== null && dataRegion !== "") {
|
|
327
|
+
const parent = el.parentElement;
|
|
328
|
+
return parent === null || parent.closest("[data-region]") === null;
|
|
329
|
+
}
|
|
330
|
+
return el.querySelector(selector) === null && el.closest("[data-region]") === null;
|
|
331
|
+
};
|
|
332
|
+
const candidates = Array.from(document.querySelectorAll(selector));
|
|
333
|
+
const regions = candidates.filter(qualifies);
|
|
334
|
+
const elements = regions.length > 0 ? regions : [document.body];
|
|
335
|
+
const used = {};
|
|
336
|
+
const uniqueKey = (key) => {
|
|
337
|
+
const count = (used[key] ?? 0) + 1;
|
|
338
|
+
used[key] = count;
|
|
339
|
+
return count === 1 ? key : `${key}-${count}`;
|
|
340
|
+
};
|
|
341
|
+
return elements.map((el) => {
|
|
342
|
+
const text = textOf(el);
|
|
343
|
+
const key = uniqueKey(keyOf(el));
|
|
344
|
+
return {
|
|
345
|
+
key,
|
|
346
|
+
label: labelOf(el, key),
|
|
347
|
+
state: stateOf(el, text),
|
|
348
|
+
values: valuesOf(text)
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
{ selector: CANDIDATE_SELECTOR, emptyMarkers, errorMarkers }
|
|
353
|
+
);
|
|
354
|
+
};
|
|
355
|
+
var REGION_STATES = ["ok", "empty", "error", "loading"];
|
|
356
|
+
var Region = z.object({
|
|
357
|
+
/** Stable id: data-region → data-testid → role → aria-label → heading slug. */
|
|
358
|
+
key: z.string(),
|
|
359
|
+
/** Human label for the report. */
|
|
360
|
+
label: z.string(),
|
|
361
|
+
state: z.enum(REGION_STATES),
|
|
362
|
+
/** Normalized numeric / currency / percent tokens found in the region. */
|
|
363
|
+
values: z.array(z.string())
|
|
364
|
+
});
|
|
365
|
+
var CompareSnapshot = z.object({
|
|
366
|
+
/** Route the snapshot was captured at, e.g. `/financials`. */
|
|
367
|
+
route: z.string(),
|
|
368
|
+
regions: z.array(Region)
|
|
369
|
+
}).refine(
|
|
370
|
+
(snap) => new Set(snap.regions.map((region) => region.key)).size === snap.regions.length,
|
|
371
|
+
{
|
|
372
|
+
message: "region keys must be unique"
|
|
373
|
+
}
|
|
374
|
+
);
|
|
375
|
+
var ExpectedAbsentRule = z.object({
|
|
376
|
+
region: z.string().optional(),
|
|
377
|
+
value: z.string().optional(),
|
|
378
|
+
reason: z.string().min(1)
|
|
379
|
+
}).refine((rule) => rule.region !== void 0 || rule.value !== void 0, {
|
|
380
|
+
message: "expectedAbsent rule needs at least one of `region` or `value`"
|
|
381
|
+
});
|
|
382
|
+
var CompareConfig = z.object({
|
|
383
|
+
expectedAbsent: z.array(ExpectedAbsentRule).optional(),
|
|
384
|
+
emptyStateMarkers: z.array(z.string()).optional(),
|
|
385
|
+
errorStateMarkers: z.array(z.string()).optional()
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// src/compare/snapshot.ts
|
|
389
|
+
var captureSnapshot = async (page, route, config) => ({
|
|
390
|
+
route,
|
|
391
|
+
regions: await extractRegions(page, config)
|
|
392
|
+
});
|
|
393
|
+
var loadSnapshot = async (path) => {
|
|
394
|
+
const raw = await readFile(path, "utf8");
|
|
395
|
+
return CompareSnapshot.parse(JSON.parse(raw));
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/compare/runner.ts
|
|
399
|
+
var DEFAULT_TIMEOUT_MS2 = 3e4;
|
|
400
|
+
var joinUrl = (base, route) => `${base.replace(/\/$/, "")}/${route.replace(/^\//, "")}`;
|
|
401
|
+
var routeSnapshotFile = (route) => `${route.replace(/^\//, "").replace(/\/+$/, "").replace(/\//g, "_") || "index"}.json`;
|
|
402
|
+
var compareRoutes = async (options) => {
|
|
403
|
+
if (options.oldBaseUrl === void 0 === (options.oldSnapshotDir === void 0)) {
|
|
404
|
+
throw new Error("compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir");
|
|
405
|
+
}
|
|
406
|
+
const viewport = options.viewport ?? DESKTOP_VIEWPORT;
|
|
407
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
408
|
+
const browser = options.browser ?? await chromium.launch();
|
|
409
|
+
const ownsBrowser = options.browser === void 0;
|
|
410
|
+
const { oldBaseUrl, oldSnapshotDir } = options;
|
|
411
|
+
const snapshotUrl = async (url, route) => {
|
|
412
|
+
const context = await browser.newContext({
|
|
413
|
+
viewport: { width: viewport.width, height: viewport.height }
|
|
414
|
+
});
|
|
415
|
+
const page = await context.newPage();
|
|
416
|
+
try {
|
|
417
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: timeoutMs });
|
|
418
|
+
await prepareForScreenshot(page);
|
|
419
|
+
return await captureSnapshot(page, route, options.config);
|
|
420
|
+
} finally {
|
|
421
|
+
await context.close();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const oldSnapshotFor = (route) => {
|
|
425
|
+
if (oldBaseUrl !== void 0) return snapshotUrl(joinUrl(oldBaseUrl, route), route);
|
|
426
|
+
if (oldSnapshotDir !== void 0) {
|
|
427
|
+
return loadSnapshot(join(oldSnapshotDir, routeSnapshotFile(route)));
|
|
428
|
+
}
|
|
429
|
+
throw new Error("compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir");
|
|
430
|
+
};
|
|
431
|
+
try {
|
|
432
|
+
const perRoute = [];
|
|
433
|
+
for (const route of options.routes) {
|
|
434
|
+
const oldSnapshot = await oldSnapshotFor(route);
|
|
435
|
+
const newSnapshot = await snapshotUrl(joinUrl(options.newBaseUrl, route), route);
|
|
436
|
+
perRoute.push({ route, findings: diffSnapshots(oldSnapshot, newSnapshot, options.config) });
|
|
437
|
+
}
|
|
438
|
+
const { sharedChrome, perRoute: trimmed } = dedupeSharedChrome(perRoute);
|
|
439
|
+
const routes = trimmed.map(({ route, findings }) => ({
|
|
440
|
+
route,
|
|
441
|
+
findings,
|
|
442
|
+
pass: comparePass(findings)
|
|
443
|
+
}));
|
|
444
|
+
const pass = comparePass(sharedChrome) && routes.every((route) => route.pass);
|
|
445
|
+
return { routes, sharedChrome, pass };
|
|
446
|
+
} finally {
|
|
447
|
+
if (ownsBrowser) await browser.close();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
14
450
|
var IMPACTS = ["critical", "serious", "moderate", "minor"];
|
|
15
451
|
var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
|
|
16
452
|
var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
|
|
@@ -165,6 +601,28 @@ ${domExcerpt}`
|
|
|
165
601
|
}
|
|
166
602
|
};
|
|
167
603
|
|
|
604
|
+
// src/axes/compare.ts
|
|
605
|
+
var skip2 = (message) => ({
|
|
606
|
+
axis: "compare",
|
|
607
|
+
pass: true,
|
|
608
|
+
findings: [{ message, severity: "minor" }]
|
|
609
|
+
});
|
|
610
|
+
var compareAxis = {
|
|
611
|
+
name: "compare",
|
|
612
|
+
run: async (context) => {
|
|
613
|
+
const { comparison, compareConfig, page } = context;
|
|
614
|
+
if (comparison === void 0) {
|
|
615
|
+
return skip2("no comparison snapshot provided; old-vs-new not assessed");
|
|
616
|
+
}
|
|
617
|
+
if (page === void 0) {
|
|
618
|
+
return skip2("compare needs a live page to extract the new snapshot");
|
|
619
|
+
}
|
|
620
|
+
const newSnapshot = await captureSnapshot(page, comparison.route, compareConfig);
|
|
621
|
+
const findings = diffSnapshots(comparison, newSnapshot, compareConfig);
|
|
622
|
+
return { axis: "compare", pass: comparePass(findings), findings };
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
168
626
|
// src/axes/runtime-health.ts
|
|
169
627
|
var ERROR_MARKERS = [
|
|
170
628
|
"something went wrong",
|
|
@@ -303,6 +761,11 @@ var altBrandFinding = (screenshot, altScreenshot, brand) => {
|
|
|
303
761
|
}
|
|
304
762
|
return null;
|
|
305
763
|
};
|
|
764
|
+
var interactiveContrastFindings = (brand) => checkInteractiveContrast(brand).map((warning) => ({
|
|
765
|
+
message: `brand '${brand.id}' ${warning.message}`,
|
|
766
|
+
severity: "moderate",
|
|
767
|
+
detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
|
|
768
|
+
}));
|
|
306
769
|
var overflowFinding = async (page) => {
|
|
307
770
|
const overflows = await page.evaluate(
|
|
308
771
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
@@ -329,6 +792,8 @@ var visualBrandAxis = {
|
|
|
329
792
|
DEFAULT_ALT_BRAND
|
|
330
793
|
);
|
|
331
794
|
if (finding !== null) findings.push(finding);
|
|
795
|
+
const altBrand = brands.find((brand) => brand.id === DEFAULT_ALT_BRAND);
|
|
796
|
+
if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
|
|
332
797
|
}
|
|
333
798
|
if (page !== void 0) {
|
|
334
799
|
const finding = await overflowFinding(page);
|
|
@@ -344,85 +809,10 @@ var ALL_AXES = [
|
|
|
344
809
|
runtimeHealthAxis,
|
|
345
810
|
accessibilityAxis,
|
|
346
811
|
visualBrandAxis,
|
|
347
|
-
briefConformanceAxis
|
|
812
|
+
briefConformanceAxis,
|
|
813
|
+
compareAxis
|
|
348
814
|
];
|
|
349
815
|
|
|
350
|
-
// src/prepare-screenshot.ts
|
|
351
|
-
var disableAnimations = async (page) => {
|
|
352
|
-
await page.addStyleTag({
|
|
353
|
-
content: `*, *::before, *::after {
|
|
354
|
-
animation-duration: 0s !important;
|
|
355
|
-
animation-delay: 0s !important;
|
|
356
|
-
transition-duration: 0s !important;
|
|
357
|
-
transition-delay: 0s !important;
|
|
358
|
-
caret-color: transparent !important;
|
|
359
|
-
}`
|
|
360
|
-
});
|
|
361
|
-
};
|
|
362
|
-
var parkMouseOffElements = async (page) => {
|
|
363
|
-
await page.mouse.move(0, 0);
|
|
364
|
-
};
|
|
365
|
-
var waitForFonts = async (page) => {
|
|
366
|
-
await page.evaluate(() => document.fonts.ready.then(() => void 0));
|
|
367
|
-
};
|
|
368
|
-
var prepareForScreenshot = async (page) => {
|
|
369
|
-
await disableAnimations(page);
|
|
370
|
-
await parkMouseOffElements(page);
|
|
371
|
-
await waitForFonts(page);
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
// src/capture.ts
|
|
375
|
-
var DESKTOP_VIEWPORT = { label: "desktop", width: 1280, height: 800 };
|
|
376
|
-
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
377
|
-
var attachRecorders = (page) => {
|
|
378
|
-
const consoleEntries = [];
|
|
379
|
-
const networkEntries = [];
|
|
380
|
-
page.on("console", (message) => {
|
|
381
|
-
consoleEntries.push({
|
|
382
|
-
type: message.type(),
|
|
383
|
-
text: message.text(),
|
|
384
|
-
location: message.location().url || void 0
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
page.on("pageerror", (error) => {
|
|
388
|
-
consoleEntries.push({ type: "pageerror", text: error.message });
|
|
389
|
-
});
|
|
390
|
-
page.on("requestfailed", (request) => {
|
|
391
|
-
networkEntries.push({
|
|
392
|
-
url: request.url(),
|
|
393
|
-
method: request.method(),
|
|
394
|
-
status: 0,
|
|
395
|
-
failure: request.failure()?.errorText
|
|
396
|
-
});
|
|
397
|
-
});
|
|
398
|
-
page.on("response", (response) => {
|
|
399
|
-
networkEntries.push({
|
|
400
|
-
url: response.url(),
|
|
401
|
-
method: response.request().method(),
|
|
402
|
-
status: response.status()
|
|
403
|
-
});
|
|
404
|
-
});
|
|
405
|
-
return { console: consoleEntries, network: networkEntries };
|
|
406
|
-
};
|
|
407
|
-
var capturePage = async (page, url, viewport, timeoutMs = DEFAULT_TIMEOUT_MS) => {
|
|
408
|
-
const recorders = attachRecorders(page);
|
|
409
|
-
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
410
|
-
await page.goto(url, { waitUntil: "networkidle", timeout: timeoutMs });
|
|
411
|
-
await prepareForScreenshot(page);
|
|
412
|
-
const screenshot = await page.screenshot({ fullPage: true });
|
|
413
|
-
const dom = await page.content();
|
|
414
|
-
const renderedText = (await page.evaluate(() => document.body.innerText)).trim();
|
|
415
|
-
return {
|
|
416
|
-
url,
|
|
417
|
-
viewport,
|
|
418
|
-
screenshot,
|
|
419
|
-
dom,
|
|
420
|
-
renderedText,
|
|
421
|
-
console: recorders.console,
|
|
422
|
-
network: recorders.network
|
|
423
|
-
};
|
|
424
|
-
};
|
|
425
|
-
|
|
426
816
|
// src/aggregate.ts
|
|
427
817
|
var runAxes = async (axes, context) => {
|
|
428
818
|
const verdicts = [];
|
|
@@ -464,6 +854,8 @@ var validate = async (options) => {
|
|
|
464
854
|
browser,
|
|
465
855
|
brief: options.brief,
|
|
466
856
|
baseline: options.baseline,
|
|
857
|
+
comparison: options.comparison,
|
|
858
|
+
compareConfig: options.compareConfig,
|
|
467
859
|
captureUnderBrand: async (brand) => {
|
|
468
860
|
const brandContext = await browser.newContext({
|
|
469
861
|
viewport: { width: viewport.width, height: viewport.height }
|
|
@@ -563,16 +955,24 @@ var HELP = `@keboola/validate-ui \u2014 closed-loop validation for generated Keb
|
|
|
563
955
|
Usage:
|
|
564
956
|
validate-ui --url <url> [--brief <text>] [--json]
|
|
565
957
|
validate-ui --serve <dir> [--route <path>] [--brief <text>] [--json]
|
|
958
|
+
validate-ui compare --new <baseUrl> (--old <baseUrl> | --old-snapshots <dir>) --routes <a,b,c> [--config <file>] [--json]
|
|
566
959
|
|
|
567
960
|
Options:
|
|
568
|
-
--url <url>
|
|
569
|
-
--serve <dir>
|
|
570
|
-
--route <path>
|
|
571
|
-
--brief <text>
|
|
572
|
-
--json
|
|
573
|
-
--help, -h
|
|
961
|
+
--url <url> Render and validate a running URL
|
|
962
|
+
--serve <dir> Serve a built SPA dir (e.g. apps/boilerplate/dist) and validate it
|
|
963
|
+
--route <path> Route to open when using --serve (default: /)
|
|
964
|
+
--brief <text> The brief the UI was generated from (feeds brief-conformance)
|
|
965
|
+
--json Emit the verdict as JSON
|
|
966
|
+
--help, -h Show this help
|
|
574
967
|
|
|
575
|
-
|
|
968
|
+
compare options:
|
|
969
|
+
--new <baseUrl> Base URL of the new build
|
|
970
|
+
--old <baseUrl> Base URL of the old build (booted live)
|
|
971
|
+
--old-snapshots <dir> Directory of recorded old snapshots (alternative to --old)
|
|
972
|
+
--routes <a,b,c> Comma-separated routes to compare
|
|
973
|
+
--config <file> JSON compare config (expectedAbsent allowlist + state markers)
|
|
974
|
+
|
|
975
|
+
Exit code is 0 when every axis / route passes, 1 otherwise.
|
|
576
976
|
`;
|
|
577
977
|
var flagValue = (argv, name) => {
|
|
578
978
|
const index = argv.indexOf(name);
|
|
@@ -590,14 +990,71 @@ var formatText = (verdict) => {
|
|
|
590
990
|
}
|
|
591
991
|
return lines.join("\n");
|
|
592
992
|
};
|
|
993
|
+
var formatCompareFinding = (finding) => {
|
|
994
|
+
const tag = finding.suppressed !== void 0 ? " (suppressed)" : "";
|
|
995
|
+
const detail = finding.detail !== void 0 ? ` \u2014 ${finding.detail}` : "";
|
|
996
|
+
return ` [${finding.severity}] ${finding.message}${detail}${tag}`;
|
|
997
|
+
};
|
|
998
|
+
var formatCompare = (result) => {
|
|
999
|
+
const lines = [`validate-ui compare ${result.pass ? "PASS" : "FAIL"}`];
|
|
1000
|
+
if (result.sharedChrome.length > 0) {
|
|
1001
|
+
lines.push(" shared across routes:");
|
|
1002
|
+
for (const finding of result.sharedChrome) lines.push(formatCompareFinding(finding));
|
|
1003
|
+
}
|
|
1004
|
+
for (const route of result.routes) {
|
|
1005
|
+
lines.push(` ${route.pass ? "\u2713" : "\u2717"} ${route.route}`);
|
|
1006
|
+
for (const finding of route.findings) lines.push(formatCompareFinding(finding));
|
|
1007
|
+
}
|
|
1008
|
+
return lines.join("\n");
|
|
1009
|
+
};
|
|
1010
|
+
var loadCompareConfig = async (path) => {
|
|
1011
|
+
if (path === void 0) return void 0;
|
|
1012
|
+
return CompareConfig.parse(JSON.parse(await readFile(resolve(path), "utf8")));
|
|
1013
|
+
};
|
|
1014
|
+
var runCompare = async (argv, asJson) => {
|
|
1015
|
+
const newBaseUrl = flagValue(argv, "--new");
|
|
1016
|
+
const oldBaseUrl = flagValue(argv, "--old");
|
|
1017
|
+
const oldSnapshotDir = flagValue(argv, "--old-snapshots");
|
|
1018
|
+
const routes = flagValue(argv, "--routes")?.split(",").map((route) => route.trim()).filter((route) => route !== "");
|
|
1019
|
+
if (newBaseUrl === void 0 || routes === void 0 || routes.length === 0) {
|
|
1020
|
+
process2.stderr.write("validate-ui compare: --new and --routes are required\n\n" + HELP);
|
|
1021
|
+
process2.exitCode = 1;
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (oldBaseUrl !== void 0 && oldSnapshotDir !== void 0) {
|
|
1025
|
+
process2.stderr.write(
|
|
1026
|
+
"validate-ui compare: pass only one of --old or --old-snapshots\n\n" + HELP
|
|
1027
|
+
);
|
|
1028
|
+
process2.exitCode = 1;
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const config = await loadCompareConfig(flagValue(argv, "--config"));
|
|
1032
|
+
const base = { newBaseUrl, routes, config };
|
|
1033
|
+
let result;
|
|
1034
|
+
if (oldBaseUrl !== void 0) {
|
|
1035
|
+
result = await compareRoutes({ ...base, oldBaseUrl });
|
|
1036
|
+
} else if (oldSnapshotDir !== void 0) {
|
|
1037
|
+
result = await compareRoutes({ ...base, oldSnapshotDir });
|
|
1038
|
+
} else {
|
|
1039
|
+
process2.stderr.write("validate-ui compare: provide --old or --old-snapshots\n\n" + HELP);
|
|
1040
|
+
process2.exitCode = 1;
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
process2.stdout.write((asJson ? JSON.stringify(result, null, 2) : formatCompare(result)) + "\n");
|
|
1044
|
+
process2.exitCode = result.pass ? 0 : 1;
|
|
1045
|
+
};
|
|
593
1046
|
var main = async () => {
|
|
594
1047
|
const argv = process2.argv.slice(2);
|
|
595
1048
|
if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
|
|
596
1049
|
process2.stdout.write(HELP);
|
|
597
1050
|
return;
|
|
598
1051
|
}
|
|
599
|
-
const brief = flagValue(argv, "--brief");
|
|
600
1052
|
const asJson = argv.includes("--json");
|
|
1053
|
+
if (argv[0] === "compare") {
|
|
1054
|
+
await runCompare(argv.slice(1), asJson);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const brief = flagValue(argv, "--brief");
|
|
601
1058
|
const serveDir = flagValue(argv, "--serve");
|
|
602
1059
|
let url = flagValue(argv, "--url");
|
|
603
1060
|
let server;
|