@keboola/validate-ui 0.1.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/README.md +80 -0
- package/dist/cli.cjs +637 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +628 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +609 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +197 -0
- package/dist/index.d.ts +197 -0
- package/dist/index.js +586 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var playwright = require('playwright');
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var promises = require('fs/promises');
|
|
6
|
+
var http = require('http');
|
|
7
|
+
var path = require('path');
|
|
8
|
+
var axe = require('axe-core');
|
|
9
|
+
var zod = require('zod');
|
|
10
|
+
var Anthropic = require('@anthropic-ai/sdk');
|
|
11
|
+
var pixelmatch = require('pixelmatch');
|
|
12
|
+
var pngjs = require('pngjs');
|
|
13
|
+
|
|
14
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
15
|
+
|
|
16
|
+
var axe__default = /*#__PURE__*/_interopDefault(axe);
|
|
17
|
+
var Anthropic__default = /*#__PURE__*/_interopDefault(Anthropic);
|
|
18
|
+
var pixelmatch__default = /*#__PURE__*/_interopDefault(pixelmatch);
|
|
19
|
+
|
|
20
|
+
// src/types.ts
|
|
21
|
+
var SEVERITIES = ["critical", "serious", "moderate", "minor"];
|
|
22
|
+
|
|
23
|
+
// src/prepare-screenshot.ts
|
|
24
|
+
var disableAnimations = async (page) => {
|
|
25
|
+
await page.addStyleTag({
|
|
26
|
+
content: `*, *::before, *::after {
|
|
27
|
+
animation-duration: 0s !important;
|
|
28
|
+
animation-delay: 0s !important;
|
|
29
|
+
transition-duration: 0s !important;
|
|
30
|
+
transition-delay: 0s !important;
|
|
31
|
+
caret-color: transparent !important;
|
|
32
|
+
}`
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
var parkMouseOffElements = async (page) => {
|
|
36
|
+
await page.mouse.move(0, 0);
|
|
37
|
+
};
|
|
38
|
+
var waitForFonts = async (page) => {
|
|
39
|
+
await page.evaluate(() => document.fonts.ready.then(() => void 0));
|
|
40
|
+
};
|
|
41
|
+
var prepareForScreenshot = async (page) => {
|
|
42
|
+
await disableAnimations(page);
|
|
43
|
+
await parkMouseOffElements(page);
|
|
44
|
+
await waitForFonts(page);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/capture.ts
|
|
48
|
+
var DESKTOP_VIEWPORT = { label: "desktop", width: 1280, height: 800 };
|
|
49
|
+
var MOBILE_VIEWPORT = { label: "mobile", width: 390, height: 844 };
|
|
50
|
+
var DEFAULT_VIEWPORTS = [DESKTOP_VIEWPORT, MOBILE_VIEWPORT];
|
|
51
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
52
|
+
var attachRecorders = (page) => {
|
|
53
|
+
const consoleEntries = [];
|
|
54
|
+
const networkEntries = [];
|
|
55
|
+
page.on("console", (message) => {
|
|
56
|
+
consoleEntries.push({
|
|
57
|
+
type: message.type(),
|
|
58
|
+
text: message.text(),
|
|
59
|
+
location: message.location().url || void 0
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
page.on("pageerror", (error) => {
|
|
63
|
+
consoleEntries.push({ type: "pageerror", text: error.message });
|
|
64
|
+
});
|
|
65
|
+
page.on("requestfailed", (request) => {
|
|
66
|
+
networkEntries.push({
|
|
67
|
+
url: request.url(),
|
|
68
|
+
method: request.method(),
|
|
69
|
+
status: 0,
|
|
70
|
+
failure: request.failure()?.errorText
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
page.on("response", (response) => {
|
|
74
|
+
networkEntries.push({
|
|
75
|
+
url: response.url(),
|
|
76
|
+
method: response.request().method(),
|
|
77
|
+
status: response.status()
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
return { console: consoleEntries, network: networkEntries };
|
|
81
|
+
};
|
|
82
|
+
var capturePage = async (page, url, viewport, timeoutMs = DEFAULT_TIMEOUT_MS) => {
|
|
83
|
+
const recorders = attachRecorders(page);
|
|
84
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
85
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: timeoutMs });
|
|
86
|
+
await prepareForScreenshot(page);
|
|
87
|
+
const screenshot = await page.screenshot({ fullPage: true });
|
|
88
|
+
const dom = await page.content();
|
|
89
|
+
const renderedText = (await page.evaluate(() => document.body.innerText)).trim();
|
|
90
|
+
return {
|
|
91
|
+
url,
|
|
92
|
+
viewport,
|
|
93
|
+
screenshot,
|
|
94
|
+
dom,
|
|
95
|
+
renderedText,
|
|
96
|
+
console: recorders.console,
|
|
97
|
+
network: recorders.network
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
var capture = async (url, options = {}) => {
|
|
101
|
+
const viewports = options.viewports ?? DEFAULT_VIEWPORTS;
|
|
102
|
+
const browser = options.browser ?? await playwright.chromium.launch();
|
|
103
|
+
const ownsBrowser = !options.browser;
|
|
104
|
+
try {
|
|
105
|
+
const artifacts = [];
|
|
106
|
+
for (const viewport of viewports) {
|
|
107
|
+
const context = await browser.newContext({
|
|
108
|
+
viewport: { width: viewport.width, height: viewport.height }
|
|
109
|
+
});
|
|
110
|
+
const page = await context.newPage();
|
|
111
|
+
try {
|
|
112
|
+
artifacts.push(await capturePage(page, url, viewport, options.timeoutMs));
|
|
113
|
+
} finally {
|
|
114
|
+
await context.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return artifacts;
|
|
118
|
+
} finally {
|
|
119
|
+
if (ownsBrowser) await browser.close();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var CONTENT_TYPES = {
|
|
123
|
+
".html": "text/html; charset=utf-8",
|
|
124
|
+
".js": "text/javascript; charset=utf-8",
|
|
125
|
+
".css": "text/css; charset=utf-8",
|
|
126
|
+
".json": "application/json; charset=utf-8",
|
|
127
|
+
".svg": "image/svg+xml",
|
|
128
|
+
".png": "image/png",
|
|
129
|
+
".jpg": "image/jpeg",
|
|
130
|
+
".woff2": "font/woff2",
|
|
131
|
+
".woff": "font/woff"
|
|
132
|
+
};
|
|
133
|
+
var serveStatic = async (rootDir) => {
|
|
134
|
+
const indexPath = path.join(rootDir, "index.html");
|
|
135
|
+
const rootResolved = path.resolve(rootDir);
|
|
136
|
+
const server = http.createServer((request, response) => {
|
|
137
|
+
const send = (filePath, status = 200) => {
|
|
138
|
+
response.writeHead(status, {
|
|
139
|
+
"content-type": CONTENT_TYPES[path.extname(filePath)] ?? "application/octet-stream"
|
|
140
|
+
});
|
|
141
|
+
const stream = fs.createReadStream(filePath);
|
|
142
|
+
stream.on("error", (error) => {
|
|
143
|
+
console.error(`serveStatic: failed to read ${filePath}:`, error);
|
|
144
|
+
if (!response.headersSent) response.writeHead(500);
|
|
145
|
+
response.end();
|
|
146
|
+
});
|
|
147
|
+
stream.pipe(response);
|
|
148
|
+
};
|
|
149
|
+
const notFound = () => {
|
|
150
|
+
response.writeHead(404);
|
|
151
|
+
response.end();
|
|
152
|
+
};
|
|
153
|
+
let rawPath;
|
|
154
|
+
try {
|
|
155
|
+
rawPath = decodeURIComponent((request.url ?? "/").split("?")[0] ?? "/");
|
|
156
|
+
} catch {
|
|
157
|
+
response.writeHead(400);
|
|
158
|
+
response.end();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const relative = path.normalize(rawPath).replace(/^(\.\.[/\\])+/, "");
|
|
162
|
+
const candidate = path.join(rootDir, relative);
|
|
163
|
+
const candidateResolved = path.resolve(candidate);
|
|
164
|
+
if (candidateResolved !== rootResolved && !candidateResolved.startsWith(rootResolved + path.sep)) {
|
|
165
|
+
notFound();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
promises.stat(candidate).then((info) => {
|
|
169
|
+
send(info.isDirectory() ? indexPath : candidate);
|
|
170
|
+
}).catch(() => {
|
|
171
|
+
if (path.extname(candidate) === "") {
|
|
172
|
+
send(indexPath);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
notFound();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
await new Promise((resolve2) => server.listen(0, "127.0.0.1", resolve2));
|
|
179
|
+
const address = server.address();
|
|
180
|
+
if (address === null || typeof address === "string") {
|
|
181
|
+
throw new Error("serveStatic: failed to bind an ephemeral port");
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
url: `http://127.0.0.1:${address.port}`,
|
|
185
|
+
close: () => new Promise((resolve2, reject) => {
|
|
186
|
+
server.close((error) => error ? reject(error) : resolve2());
|
|
187
|
+
})
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
var IMPACTS = ["critical", "serious", "moderate", "minor"];
|
|
191
|
+
var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
|
|
192
|
+
var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
|
|
193
|
+
var toFinding = (violation) => {
|
|
194
|
+
const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
|
|
195
|
+
const wcag = wcagTag(violation.tags);
|
|
196
|
+
return {
|
|
197
|
+
message: `${violation.help || violation.description}${targets ? ` (${targets})` : ""}`,
|
|
198
|
+
severity: toSeverity(violation.impact),
|
|
199
|
+
ref: [wcag, violation.id].filter(Boolean).join(" \xB7 ")
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
var notAssessed = (message) => ({
|
|
203
|
+
axis: "a11y",
|
|
204
|
+
pass: true,
|
|
205
|
+
findings: [{ message, severity: "minor" }]
|
|
206
|
+
});
|
|
207
|
+
var accessibilityAxis = {
|
|
208
|
+
name: "a11y",
|
|
209
|
+
run: async (context) => {
|
|
210
|
+
const { page } = context;
|
|
211
|
+
if (!page) return notAssessed("a11y skipped: no live page provided");
|
|
212
|
+
await page.addScriptTag({ content: axe__default.default.source });
|
|
213
|
+
const results = await page.evaluate(() => {
|
|
214
|
+
const injected = window.axe;
|
|
215
|
+
return injected ? injected.run(document) : null;
|
|
216
|
+
});
|
|
217
|
+
if (results === null) {
|
|
218
|
+
return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
|
|
219
|
+
}
|
|
220
|
+
const findings = results.violations.map(toFinding);
|
|
221
|
+
const pass = !findings.some(
|
|
222
|
+
(finding) => finding.severity === "critical" || finding.severity === "serious"
|
|
223
|
+
);
|
|
224
|
+
return { axis: "a11y", pass, findings };
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
var textFromMessage = (message) => message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
228
|
+
var makeProvider = (backend, clientOptions) => {
|
|
229
|
+
const client = new Anthropic__default.default(clientOptions);
|
|
230
|
+
return {
|
|
231
|
+
backend,
|
|
232
|
+
judge: async ({ screenshotBase64, prompt, model }) => {
|
|
233
|
+
const message = await client.messages.create({
|
|
234
|
+
model,
|
|
235
|
+
max_tokens: 1024,
|
|
236
|
+
messages: [
|
|
237
|
+
{
|
|
238
|
+
role: "user",
|
|
239
|
+
content: [
|
|
240
|
+
{
|
|
241
|
+
type: "image",
|
|
242
|
+
source: { type: "base64", media_type: "image/png", data: screenshotBase64 }
|
|
243
|
+
},
|
|
244
|
+
{ type: "text", text: prompt }
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
]
|
|
248
|
+
});
|
|
249
|
+
return textFromMessage(message);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
var nonEmpty = (value) => value !== void 0 && value !== "";
|
|
254
|
+
var resolveVlmProvider = (env = process.env) => {
|
|
255
|
+
const baseUrl = env.VALIDATE_UI_LLM_BASE_URL ?? env.ANTHROPIC_BASE_URL;
|
|
256
|
+
const proxyToken = env.VALIDATE_UI_LLM_TOKEN ?? env.KBC_TOKEN ?? env.ANTHROPIC_API_KEY;
|
|
257
|
+
if (nonEmpty(baseUrl) && nonEmpty(proxyToken)) {
|
|
258
|
+
return makeProvider("keboola-llm", { baseURL: baseUrl, apiKey: proxyToken });
|
|
259
|
+
}
|
|
260
|
+
if (nonEmpty(env.ANTHROPIC_API_KEY)) {
|
|
261
|
+
return makeProvider("raw-anthropic", { apiKey: env.ANTHROPIC_API_KEY });
|
|
262
|
+
}
|
|
263
|
+
return void 0;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// src/axes/brief-conformance.ts
|
|
267
|
+
var JudgeResult = zod.z.object({
|
|
268
|
+
matchesBrief: zod.z.boolean(),
|
|
269
|
+
missingStates: zod.z.array(zod.z.string()),
|
|
270
|
+
reasons: zod.z.array(zod.z.string())
|
|
271
|
+
});
|
|
272
|
+
var MODEL = process.env.VALIDATE_UI_MODEL ?? "claude-sonnet-4-6";
|
|
273
|
+
var DOM_EXCERPT_LIMIT = 12e3;
|
|
274
|
+
var interpretJudgeResult = (result) => {
|
|
275
|
+
const findings = [];
|
|
276
|
+
if (!result.matchesBrief) {
|
|
277
|
+
findings.push({
|
|
278
|
+
message: `UI does not match the brief: ${result.reasons.join("; ")}`,
|
|
279
|
+
severity: "serious"
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
for (const state of result.missingStates) {
|
|
283
|
+
findings.push({ message: `missing state: ${state}`, severity: "moderate" });
|
|
284
|
+
}
|
|
285
|
+
const pass = result.matchesBrief && result.missingStates.length === 0;
|
|
286
|
+
return { axis: "brief-conformance", pass, findings };
|
|
287
|
+
};
|
|
288
|
+
var skip = (message) => ({
|
|
289
|
+
axis: "brief-conformance",
|
|
290
|
+
pass: true,
|
|
291
|
+
findings: [{ message, severity: "minor" }]
|
|
292
|
+
});
|
|
293
|
+
var RUBRIC = [
|
|
294
|
+
"You are judging whether a rendered UI conforms to the brief it was generated from.",
|
|
295
|
+
"Assess two things:",
|
|
296
|
+
"1. matchesBrief \u2014 does the screenshot + DOM implement what the brief describes?",
|
|
297
|
+
"2. missingStates \u2014 which UI states the brief implies are NOT represented.",
|
|
298
|
+
' Use short lowercase labels like "empty", "loading", "error".',
|
|
299
|
+
"Reply with ONLY a JSON object, no prose, matching exactly:",
|
|
300
|
+
'{ "matchesBrief": boolean, "missingStates": string[], "reasons": string[] }',
|
|
301
|
+
"reasons: brief explanations for any mismatch (empty array if it matches)."
|
|
302
|
+
].join("\n");
|
|
303
|
+
var briefConformanceAxis = {
|
|
304
|
+
name: "brief-conformance",
|
|
305
|
+
run: async (context) => {
|
|
306
|
+
const brief = context.brief?.trim();
|
|
307
|
+
if (brief === void 0 || brief === "") {
|
|
308
|
+
return skip("no brief provided; conformance not assessed");
|
|
309
|
+
}
|
|
310
|
+
const provider = resolveVlmProvider();
|
|
311
|
+
if (provider === void 0) {
|
|
312
|
+
return skip(
|
|
313
|
+
"brief-conformance skipped: no VLM backend configured (set VALIDATE_UI_LLM_BASE_URL + VALIDATE_UI_LLM_TOKEN for the Keboola LLM path, or ANTHROPIC_API_KEY for a raw key)"
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const { artifact } = context;
|
|
317
|
+
const screenshotBase64 = Buffer.from(artifact.screenshot).toString("base64");
|
|
318
|
+
const domExcerpt = artifact.dom.slice(0, DOM_EXCERPT_LIMIT);
|
|
319
|
+
const reply = await provider.judge({
|
|
320
|
+
model: MODEL,
|
|
321
|
+
screenshotBase64,
|
|
322
|
+
prompt: `${RUBRIC}
|
|
323
|
+
|
|
324
|
+
Brief:
|
|
325
|
+
${brief}
|
|
326
|
+
|
|
327
|
+
DOM excerpt:
|
|
328
|
+
${domExcerpt}`
|
|
329
|
+
});
|
|
330
|
+
try {
|
|
331
|
+
const parsed = JudgeResult.parse(JSON.parse(reply));
|
|
332
|
+
return interpretJudgeResult(parsed);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
console.error("brief-conformance: failed to parse judge response", error);
|
|
335
|
+
return {
|
|
336
|
+
axis: "brief-conformance",
|
|
337
|
+
pass: false,
|
|
338
|
+
findings: [{ message: "could not parse judge response", severity: "moderate" }]
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// src/axes/runtime-health.ts
|
|
345
|
+
var ERROR_MARKERS = [
|
|
346
|
+
"something went wrong",
|
|
347
|
+
"vite-error-overlay",
|
|
348
|
+
"vite-plugin-checker-error-overlay",
|
|
349
|
+
"react-error-overlay"
|
|
350
|
+
];
|
|
351
|
+
var pageErrorFindings = (console2) => console2.filter((entry) => entry.type === "pageerror").map((entry) => ({
|
|
352
|
+
message: `Uncaught page error: ${entry.text}`,
|
|
353
|
+
severity: "critical",
|
|
354
|
+
detail: entry.location
|
|
355
|
+
}));
|
|
356
|
+
var RESOURCE_LOAD_ECHO = "failed to load resource";
|
|
357
|
+
var isResourceLoadEcho = (entry) => entry.text.toLowerCase().includes(RESOURCE_LOAD_ECHO);
|
|
358
|
+
var consoleErrorFindings = (console2) => {
|
|
359
|
+
const seen = /* @__PURE__ */ new Set();
|
|
360
|
+
const findings = [];
|
|
361
|
+
for (const entry of console2) {
|
|
362
|
+
if (entry.type !== "error" || isResourceLoadEcho(entry) || seen.has(entry.text)) continue;
|
|
363
|
+
seen.add(entry.text);
|
|
364
|
+
findings.push({
|
|
365
|
+
message: `Console error: ${entry.text}`,
|
|
366
|
+
severity: "serious",
|
|
367
|
+
detail: entry.location
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return findings;
|
|
371
|
+
};
|
|
372
|
+
var toNetworkFinding = (entry) => {
|
|
373
|
+
if (entry.status === 0) {
|
|
374
|
+
return {
|
|
375
|
+
message: `Request failed: ${entry.method} ${entry.url}`,
|
|
376
|
+
severity: "serious",
|
|
377
|
+
detail: entry.failure,
|
|
378
|
+
ref: entry.url
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (entry.status >= 500) {
|
|
382
|
+
return {
|
|
383
|
+
message: `Server error ${entry.status}: ${entry.method} ${entry.url}`,
|
|
384
|
+
severity: "serious",
|
|
385
|
+
ref: entry.url
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (entry.status >= 400) {
|
|
389
|
+
return {
|
|
390
|
+
message: `Client error ${entry.status}: ${entry.method} ${entry.url}`,
|
|
391
|
+
severity: "moderate",
|
|
392
|
+
ref: entry.url
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
};
|
|
397
|
+
var SEVERITY_RANK = { critical: 3, serious: 2, moderate: 1, minor: 0 };
|
|
398
|
+
var networkFindings = (network) => {
|
|
399
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
400
|
+
for (const entry of network) {
|
|
401
|
+
const finding = toNetworkFinding(entry);
|
|
402
|
+
if (finding === null) continue;
|
|
403
|
+
const existing = byUrl.get(entry.url);
|
|
404
|
+
if (existing === void 0 || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[existing.severity]) {
|
|
405
|
+
byUrl.set(entry.url, finding);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return [...byUrl.values()];
|
|
409
|
+
};
|
|
410
|
+
var errorMarkerFinding = (dom) => {
|
|
411
|
+
const lower = dom.toLowerCase();
|
|
412
|
+
const marker = ERROR_MARKERS.find((m) => lower.includes(m));
|
|
413
|
+
return marker === void 0 ? null : {
|
|
414
|
+
message: "Error boundary / error overlay rendered instead of the app",
|
|
415
|
+
severity: "critical",
|
|
416
|
+
detail: marker
|
|
417
|
+
};
|
|
418
|
+
};
|
|
419
|
+
var whiteScreenFinding = (renderedText) => renderedText !== void 0 && renderedText.length === 0 ? { message: "White screen: page rendered no visible text", severity: "critical" } : null;
|
|
420
|
+
var runtimeHealthAxis = {
|
|
421
|
+
name: "runtime-health",
|
|
422
|
+
run: (context) => {
|
|
423
|
+
const { artifact } = context;
|
|
424
|
+
const findings = [
|
|
425
|
+
...pageErrorFindings(artifact.console),
|
|
426
|
+
...consoleErrorFindings(artifact.console),
|
|
427
|
+
...networkFindings(artifact.network)
|
|
428
|
+
];
|
|
429
|
+
const errorMarker = errorMarkerFinding(artifact.dom);
|
|
430
|
+
if (errorMarker !== null) findings.push(errorMarker);
|
|
431
|
+
const whiteScreen = whiteScreenFinding(artifact.renderedText);
|
|
432
|
+
if (whiteScreen !== null) findings.push(whiteScreen);
|
|
433
|
+
const fatal = ["critical", "serious"];
|
|
434
|
+
const pass = !findings.some((f) => fatal.includes(f.severity));
|
|
435
|
+
return Promise.resolve({ axis: "runtime-health", pass, findings });
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
var DRIFT_THRESHOLD = 0.01;
|
|
439
|
+
var RESKIN_THRESHOLD = 5e-3;
|
|
440
|
+
var DEFAULT_ALT_BRAND = "example-customer";
|
|
441
|
+
var pngMismatchRatio = (a, b) => {
|
|
442
|
+
const imgA = pngjs.PNG.sync.read(Buffer.from(a));
|
|
443
|
+
const imgB = pngjs.PNG.sync.read(Buffer.from(b));
|
|
444
|
+
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
|
445
|
+
return { ratio: 1, sizeMismatch: true };
|
|
446
|
+
}
|
|
447
|
+
const total = imgA.width * imgA.height;
|
|
448
|
+
if (total === 0) return { ratio: 0, sizeMismatch: false };
|
|
449
|
+
const mismatched = pixelmatch__default.default(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
|
|
450
|
+
threshold: 0.1
|
|
451
|
+
});
|
|
452
|
+
return { ratio: mismatched / total, sizeMismatch: false };
|
|
453
|
+
};
|
|
454
|
+
var baselineFinding = (screenshot, baseline) => {
|
|
455
|
+
const { ratio, sizeMismatch } = pngMismatchRatio(screenshot, baseline);
|
|
456
|
+
if (sizeMismatch) {
|
|
457
|
+
return {
|
|
458
|
+
message: "screenshot dimensions differ from baseline",
|
|
459
|
+
severity: "moderate"
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (ratio > DRIFT_THRESHOLD) {
|
|
463
|
+
return {
|
|
464
|
+
message: `visual drift ${(ratio * 100).toFixed(1)}% vs baseline`,
|
|
465
|
+
severity: "moderate",
|
|
466
|
+
detail: `${(ratio * 100).toFixed(2)}% of pixels changed`
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
};
|
|
471
|
+
var altBrandFinding = (screenshot, altScreenshot, brand) => {
|
|
472
|
+
const { ratio } = pngMismatchRatio(screenshot, altScreenshot);
|
|
473
|
+
if (ratio < RESKIN_THRESHOLD) {
|
|
474
|
+
return {
|
|
475
|
+
message: `chrome did not re-skin under alternate brand '${brand}'`,
|
|
476
|
+
severity: "serious",
|
|
477
|
+
detail: `${(ratio * 100).toFixed(2)}% of pixels changed`
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
};
|
|
482
|
+
var overflowFinding = async (page) => {
|
|
483
|
+
const overflows = await page.evaluate(
|
|
484
|
+
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
|
|
485
|
+
);
|
|
486
|
+
if (overflows) {
|
|
487
|
+
return { message: "horizontal layout overflow", severity: "moderate" };
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
};
|
|
491
|
+
var visualBrandAxis = {
|
|
492
|
+
name: "visual-brand",
|
|
493
|
+
run: async (context) => {
|
|
494
|
+
const { artifact, baseline, captureUnderBrand, page } = context;
|
|
495
|
+
const findings = [];
|
|
496
|
+
if (baseline !== void 0) {
|
|
497
|
+
const finding = baselineFinding(artifact.screenshot, baseline);
|
|
498
|
+
if (finding !== null) findings.push(finding);
|
|
499
|
+
}
|
|
500
|
+
if (captureUnderBrand !== void 0) {
|
|
501
|
+
const altArtifact = await captureUnderBrand(DEFAULT_ALT_BRAND);
|
|
502
|
+
const finding = altBrandFinding(
|
|
503
|
+
artifact.screenshot,
|
|
504
|
+
altArtifact.screenshot,
|
|
505
|
+
DEFAULT_ALT_BRAND
|
|
506
|
+
);
|
|
507
|
+
if (finding !== null) findings.push(finding);
|
|
508
|
+
}
|
|
509
|
+
if (page !== void 0) {
|
|
510
|
+
const finding = await overflowFinding(page);
|
|
511
|
+
if (finding !== null) findings.push(finding);
|
|
512
|
+
}
|
|
513
|
+
const pass = !findings.some((f) => f.severity === "critical" || f.severity === "serious");
|
|
514
|
+
return { axis: "visual-brand", pass, findings };
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
// src/axes/index.ts
|
|
519
|
+
var ALL_AXES = [
|
|
520
|
+
runtimeHealthAxis,
|
|
521
|
+
accessibilityAxis,
|
|
522
|
+
visualBrandAxis,
|
|
523
|
+
briefConformanceAxis
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
// src/aggregate.ts
|
|
527
|
+
var runAxes = async (axes, context) => {
|
|
528
|
+
const verdicts = [];
|
|
529
|
+
for (const axis of axes) {
|
|
530
|
+
try {
|
|
531
|
+
verdicts.push(await axis.run(context));
|
|
532
|
+
} catch (error) {
|
|
533
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
534
|
+
console.error(`validate-ui: axis "${axis.name}" threw:`, error);
|
|
535
|
+
verdicts.push({
|
|
536
|
+
axis: axis.name,
|
|
537
|
+
pass: false,
|
|
538
|
+
findings: [{ message: `Axis crashed: ${message}`, severity: "critical" }]
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return verdicts;
|
|
543
|
+
};
|
|
544
|
+
var aggregatePass = (verdicts) => verdicts.every((v) => v.pass);
|
|
545
|
+
var withBrandParam = (url, brand) => {
|
|
546
|
+
const parsed = new URL(url);
|
|
547
|
+
parsed.searchParams.set("brand", brand);
|
|
548
|
+
return parsed.toString();
|
|
549
|
+
};
|
|
550
|
+
var validate = async (options) => {
|
|
551
|
+
const viewport = options.viewport ?? DESKTOP_VIEWPORT;
|
|
552
|
+
const axes = options.axes ?? ALL_AXES;
|
|
553
|
+
const browser = options.browser ?? await playwright.chromium.launch();
|
|
554
|
+
const ownsBrowser = !options.browser;
|
|
555
|
+
const context = await browser.newContext({
|
|
556
|
+
viewport: { width: viewport.width, height: viewport.height }
|
|
557
|
+
});
|
|
558
|
+
const page = await context.newPage();
|
|
559
|
+
try {
|
|
560
|
+
const artifact = await capturePage(page, options.url, viewport, options.timeoutMs);
|
|
561
|
+
const axisContext = {
|
|
562
|
+
artifact,
|
|
563
|
+
page,
|
|
564
|
+
browser,
|
|
565
|
+
brief: options.brief,
|
|
566
|
+
baseline: options.baseline,
|
|
567
|
+
captureUnderBrand: async (brand) => {
|
|
568
|
+
const brandContext = await browser.newContext({
|
|
569
|
+
viewport: { width: viewport.width, height: viewport.height }
|
|
570
|
+
});
|
|
571
|
+
const brandPage = await brandContext.newPage();
|
|
572
|
+
try {
|
|
573
|
+
return await capturePage(
|
|
574
|
+
brandPage,
|
|
575
|
+
withBrandParam(options.url, brand),
|
|
576
|
+
viewport,
|
|
577
|
+
options.timeoutMs
|
|
578
|
+
);
|
|
579
|
+
} finally {
|
|
580
|
+
await brandContext.close();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
const verdicts = await runAxes(axes, axisContext);
|
|
585
|
+
return { url: options.url, pass: aggregatePass(verdicts), verdicts };
|
|
586
|
+
} finally {
|
|
587
|
+
await context.close();
|
|
588
|
+
if (ownsBrowser) await browser.close();
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
exports.ALL_AXES = ALL_AXES;
|
|
593
|
+
exports.DEFAULT_VIEWPORTS = DEFAULT_VIEWPORTS;
|
|
594
|
+
exports.DESKTOP_VIEWPORT = DESKTOP_VIEWPORT;
|
|
595
|
+
exports.MOBILE_VIEWPORT = MOBILE_VIEWPORT;
|
|
596
|
+
exports.SEVERITIES = SEVERITIES;
|
|
597
|
+
exports.accessibilityAxis = accessibilityAxis;
|
|
598
|
+
exports.aggregatePass = aggregatePass;
|
|
599
|
+
exports.briefConformanceAxis = briefConformanceAxis;
|
|
600
|
+
exports.capture = capture;
|
|
601
|
+
exports.capturePage = capturePage;
|
|
602
|
+
exports.prepareForScreenshot = prepareForScreenshot;
|
|
603
|
+
exports.runAxes = runAxes;
|
|
604
|
+
exports.runtimeHealthAxis = runtimeHealthAxis;
|
|
605
|
+
exports.serveStatic = serveStatic;
|
|
606
|
+
exports.validate = validate;
|
|
607
|
+
exports.visualBrandAxis = visualBrandAxis;
|
|
608
|
+
//# sourceMappingURL=index.cjs.map
|
|
609
|
+
//# sourceMappingURL=index.cjs.map
|