@keboola/validate-ui 0.4.0 → 0.5.2

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.js CHANGED
@@ -1,956 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { stat, readFile } from 'fs/promises';
3
- import { resolve, join, normalize, sep, extname } from 'path';
4
- import process2 from 'process';
5
- import { chromium } from 'playwright';
6
- import { z } from 'zod';
7
- import axe from 'axe-core';
8
- import Anthropic from '@anthropic-ai/sdk';
9
- import pixelmatch from 'pixelmatch';
10
- import { PNG } from 'pngjs';
11
- import { brands, checkInteractiveContrast } from '@keboola/brand-registry';
12
- import { createReadStream } from 'fs';
13
- import { createServer } from 'http';
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
- };
450
- var IMPACTS = ["critical", "serious", "moderate", "minor"];
451
- var toSeverity = (impact) => impact && IMPACTS.includes(impact) ? impact : "minor";
452
- var wcagTag = (tags) => tags.find((tag) => tag.startsWith("wcag")) ?? tags[0];
453
- var toFinding = (violation) => {
454
- const targets = violation.nodes.map((node) => node.target.join(" ")).filter((target) => target.length > 0).join(", ");
455
- const wcag = wcagTag(violation.tags);
456
- return {
457
- message: `${violation.help || violation.description}${targets ? ` (${targets})` : ""}`,
458
- severity: toSeverity(violation.impact),
459
- ref: [wcag, violation.id].filter(Boolean).join(" \xB7 ")
460
- };
461
- };
462
- var notAssessed = (message) => ({
463
- axis: "a11y",
464
- pass: true,
465
- findings: [{ message, severity: "minor" }]
466
- });
467
- var accessibilityAxis = {
468
- name: "a11y",
469
- run: async (context) => {
470
- const { page } = context;
471
- if (!page) return notAssessed("a11y skipped: no live page provided");
472
- await page.addScriptTag({ content: axe.source });
473
- const results = await page.evaluate(() => {
474
- const injected = window.axe;
475
- return injected ? injected.run(document) : null;
476
- });
477
- if (results === null) {
478
- return notAssessed("a11y not assessed: axe-core failed to inject (page CSP?)");
479
- }
480
- const findings = results.violations.map(toFinding);
481
- const pass = !findings.some(
482
- (finding) => finding.severity === "critical" || finding.severity === "serious"
483
- );
484
- return { axis: "a11y", pass, findings };
485
- }
486
- };
487
- var textFromMessage = (message) => message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
488
- var makeProvider = (backend, clientOptions) => {
489
- const client = new Anthropic(clientOptions);
490
- return {
491
- backend,
492
- judge: async ({ screenshotBase64, prompt, model }) => {
493
- const message = await client.messages.create({
494
- model,
495
- max_tokens: 1024,
496
- messages: [
497
- {
498
- role: "user",
499
- content: [
500
- {
501
- type: "image",
502
- source: { type: "base64", media_type: "image/png", data: screenshotBase64 }
503
- },
504
- { type: "text", text: prompt }
505
- ]
506
- }
507
- ]
508
- });
509
- return textFromMessage(message);
510
- }
511
- };
512
- };
513
- var nonEmpty = (value) => value !== void 0 && value !== "";
514
- var resolveVlmProvider = (env = process.env) => {
515
- const baseUrl = env.VALIDATE_UI_LLM_BASE_URL ?? env.ANTHROPIC_BASE_URL;
516
- const proxyToken = env.VALIDATE_UI_LLM_TOKEN ?? env.KBC_TOKEN ?? env.ANTHROPIC_API_KEY;
517
- if (nonEmpty(baseUrl) && nonEmpty(proxyToken)) {
518
- return makeProvider("keboola-llm", { baseURL: baseUrl, apiKey: proxyToken });
519
- }
520
- if (nonEmpty(env.ANTHROPIC_API_KEY)) {
521
- return makeProvider("raw-anthropic", { apiKey: env.ANTHROPIC_API_KEY });
522
- }
523
- return void 0;
524
- };
525
-
526
- // src/axes/brief-conformance.ts
527
- var JudgeResult = z.object({
528
- matchesBrief: z.boolean(),
529
- missingStates: z.array(z.string()),
530
- reasons: z.array(z.string())
531
- });
532
- var MODEL = process.env.VALIDATE_UI_MODEL ?? "claude-sonnet-4-6";
533
- var DOM_EXCERPT_LIMIT = 12e3;
534
- var interpretJudgeResult = (result) => {
535
- const findings = [];
536
- if (!result.matchesBrief) {
537
- findings.push({
538
- message: `UI does not match the brief: ${result.reasons.join("; ")}`,
539
- severity: "serious"
540
- });
541
- }
542
- for (const state of result.missingStates) {
543
- findings.push({ message: `missing state: ${state}`, severity: "moderate" });
544
- }
545
- const pass = result.matchesBrief && result.missingStates.length === 0;
546
- return { axis: "brief-conformance", pass, findings };
547
- };
548
- var skip = (message) => ({
549
- axis: "brief-conformance",
550
- pass: true,
551
- findings: [{ message, severity: "minor" }]
552
- });
553
- var RUBRIC = [
554
- "You are judging whether a rendered UI conforms to the brief it was generated from.",
555
- "Assess two things:",
556
- "1. matchesBrief \u2014 does the screenshot + DOM implement what the brief describes?",
557
- "2. missingStates \u2014 which UI states the brief implies are NOT represented.",
558
- ' Use short lowercase labels like "empty", "loading", "error".',
559
- "Reply with ONLY a JSON object, no prose, matching exactly:",
560
- '{ "matchesBrief": boolean, "missingStates": string[], "reasons": string[] }',
561
- "reasons: brief explanations for any mismatch (empty array if it matches)."
562
- ].join("\n");
563
- var briefConformanceAxis = {
564
- name: "brief-conformance",
565
- run: async (context) => {
566
- const brief = context.brief?.trim();
567
- if (brief === void 0 || brief === "") {
568
- return skip("no brief provided; conformance not assessed");
569
- }
570
- const provider = resolveVlmProvider();
571
- if (provider === void 0) {
572
- return skip(
573
- "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)"
574
- );
575
- }
576
- const { artifact } = context;
577
- const screenshotBase64 = Buffer.from(artifact.screenshot).toString("base64");
578
- const domExcerpt = artifact.dom.slice(0, DOM_EXCERPT_LIMIT);
579
- const reply = await provider.judge({
580
- model: MODEL,
581
- screenshotBase64,
582
- prompt: `${RUBRIC}
583
-
584
- Brief:
585
- ${brief}
586
-
587
- DOM excerpt:
588
- ${domExcerpt}`
589
- });
590
- try {
591
- const parsed = JudgeResult.parse(JSON.parse(reply));
592
- return interpretJudgeResult(parsed);
593
- } catch (error) {
594
- console.error("brief-conformance: failed to parse judge response", error);
595
- return {
596
- axis: "brief-conformance",
597
- pass: false,
598
- findings: [{ message: "could not parse judge response", severity: "moderate" }]
599
- };
600
- }
601
- }
602
- };
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
-
626
- // src/axes/runtime-health.ts
627
- var ERROR_MARKERS = [
628
- "something went wrong",
629
- "vite-error-overlay",
630
- "vite-plugin-checker-error-overlay",
631
- "react-error-overlay"
632
- ];
633
- var pageErrorFindings = (console2) => console2.filter((entry) => entry.type === "pageerror").map((entry) => ({
634
- message: `Uncaught page error: ${entry.text}`,
635
- severity: "critical",
636
- detail: entry.location
637
- }));
638
- var RESOURCE_LOAD_ECHO = "failed to load resource";
639
- var isResourceLoadEcho = (entry) => entry.text.toLowerCase().includes(RESOURCE_LOAD_ECHO);
640
- var consoleErrorFindings = (console2) => {
641
- const seen = /* @__PURE__ */ new Set();
642
- const findings = [];
643
- for (const entry of console2) {
644
- if (entry.type !== "error" || isResourceLoadEcho(entry) || seen.has(entry.text)) continue;
645
- seen.add(entry.text);
646
- findings.push({
647
- message: `Console error: ${entry.text}`,
648
- severity: "serious",
649
- detail: entry.location
650
- });
651
- }
652
- return findings;
653
- };
654
- var toNetworkFinding = (entry) => {
655
- if (entry.status === 0) {
656
- return {
657
- message: `Request failed: ${entry.method} ${entry.url}`,
658
- severity: "serious",
659
- detail: entry.failure,
660
- ref: entry.url
661
- };
662
- }
663
- if (entry.status >= 500) {
664
- return {
665
- message: `Server error ${entry.status}: ${entry.method} ${entry.url}`,
666
- severity: "serious",
667
- ref: entry.url
668
- };
669
- }
670
- if (entry.status >= 400) {
671
- return {
672
- message: `Client error ${entry.status}: ${entry.method} ${entry.url}`,
673
- severity: "moderate",
674
- ref: entry.url
675
- };
676
- }
677
- return null;
678
- };
679
- var SEVERITY_RANK = { critical: 3, serious: 2, moderate: 1, minor: 0 };
680
- var networkFindings = (network) => {
681
- const byUrl = /* @__PURE__ */ new Map();
682
- for (const entry of network) {
683
- const finding = toNetworkFinding(entry);
684
- if (finding === null) continue;
685
- const existing = byUrl.get(entry.url);
686
- if (existing === void 0 || SEVERITY_RANK[finding.severity] > SEVERITY_RANK[existing.severity]) {
687
- byUrl.set(entry.url, finding);
688
- }
689
- }
690
- return [...byUrl.values()];
691
- };
692
- var errorMarkerFinding = (dom) => {
693
- const lower = dom.toLowerCase();
694
- const marker = ERROR_MARKERS.find((m) => lower.includes(m));
695
- return marker === void 0 ? null : {
696
- message: "Error boundary / error overlay rendered instead of the app",
697
- severity: "critical",
698
- detail: marker
699
- };
700
- };
701
- var whiteScreenFinding = (renderedText) => renderedText !== void 0 && renderedText.length === 0 ? { message: "White screen: page rendered no visible text", severity: "critical" } : null;
702
- var runtimeHealthAxis = {
703
- name: "runtime-health",
704
- run: (context) => {
705
- const { artifact } = context;
706
- const findings = [
707
- ...pageErrorFindings(artifact.console),
708
- ...consoleErrorFindings(artifact.console),
709
- ...networkFindings(artifact.network)
710
- ];
711
- const errorMarker = errorMarkerFinding(artifact.dom);
712
- if (errorMarker !== null) findings.push(errorMarker);
713
- const whiteScreen = whiteScreenFinding(artifact.renderedText);
714
- if (whiteScreen !== null) findings.push(whiteScreen);
715
- const fatal = ["critical", "serious"];
716
- const pass = !findings.some((f) => fatal.includes(f.severity));
717
- return Promise.resolve({ axis: "runtime-health", pass, findings });
718
- }
719
- };
720
- var DRIFT_THRESHOLD = 0.01;
721
- var RESKIN_THRESHOLD = 5e-3;
722
- var DEFAULT_ALT_BRAND = "example-customer";
723
- var pngMismatchRatio = (a, b) => {
724
- const imgA = PNG.sync.read(Buffer.from(a));
725
- const imgB = PNG.sync.read(Buffer.from(b));
726
- if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
727
- return { ratio: 1, sizeMismatch: true };
728
- }
729
- const total = imgA.width * imgA.height;
730
- if (total === 0) return { ratio: 0, sizeMismatch: false };
731
- const mismatched = pixelmatch(imgA.data, imgB.data, void 0, imgA.width, imgA.height, {
732
- threshold: 0.1
733
- });
734
- return { ratio: mismatched / total, sizeMismatch: false };
735
- };
736
- var baselineFinding = (screenshot, baseline) => {
737
- const { ratio, sizeMismatch } = pngMismatchRatio(screenshot, baseline);
738
- if (sizeMismatch) {
739
- return {
740
- message: "screenshot dimensions differ from baseline",
741
- severity: "moderate"
742
- };
743
- }
744
- if (ratio > DRIFT_THRESHOLD) {
745
- return {
746
- message: `visual drift ${(ratio * 100).toFixed(1)}% vs baseline`,
747
- severity: "moderate",
748
- detail: `${(ratio * 100).toFixed(2)}% of pixels changed`
749
- };
750
- }
751
- return null;
752
- };
753
- var altBrandFinding = (screenshot, altScreenshot, brand) => {
754
- const { ratio } = pngMismatchRatio(screenshot, altScreenshot);
755
- if (ratio < RESKIN_THRESHOLD) {
756
- return {
757
- message: `chrome did not re-skin under alternate brand '${brand}'`,
758
- severity: "serious",
759
- detail: `${(ratio * 100).toFixed(2)}% of pixels changed`
760
- };
761
- }
762
- return null;
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
- }));
769
- var overflowFinding = async (page) => {
770
- const overflows = await page.evaluate(
771
- () => document.documentElement.scrollWidth > document.documentElement.clientWidth
772
- );
773
- if (overflows) {
774
- return { message: "horizontal layout overflow", severity: "moderate" };
775
- }
776
- return null;
777
- };
778
- var visualBrandAxis = {
779
- name: "visual-brand",
780
- run: async (context) => {
781
- const { artifact, baseline, captureUnderBrand, page } = context;
782
- const findings = [];
783
- if (baseline !== void 0) {
784
- const finding = baselineFinding(artifact.screenshot, baseline);
785
- if (finding !== null) findings.push(finding);
786
- }
787
- if (captureUnderBrand !== void 0) {
788
- const altArtifact = await captureUnderBrand(DEFAULT_ALT_BRAND);
789
- const finding = altBrandFinding(
790
- artifact.screenshot,
791
- altArtifact.screenshot,
792
- DEFAULT_ALT_BRAND
793
- );
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));
797
- }
798
- if (page !== void 0) {
799
- const finding = await overflowFinding(page);
800
- if (finding !== null) findings.push(finding);
801
- }
802
- const pass = !findings.some((f) => f.severity === "critical" || f.severity === "serious");
803
- return { axis: "visual-brand", pass, findings };
804
- }
805
- };
806
-
807
- // src/axes/index.ts
808
- var ALL_AXES = [
809
- runtimeHealthAxis,
810
- accessibilityAxis,
811
- visualBrandAxis,
812
- briefConformanceAxis,
813
- compareAxis
814
- ];
815
-
816
- // src/aggregate.ts
817
- var runAxes = async (axes, context) => {
818
- const verdicts = [];
819
- for (const axis of axes) {
820
- try {
821
- verdicts.push(await axis.run(context));
822
- } catch (error) {
823
- const message = error instanceof Error ? error.message : String(error);
824
- console.error(`validate-ui: axis "${axis.name}" threw:`, error);
825
- verdicts.push({
826
- axis: axis.name,
827
- pass: false,
828
- findings: [{ message: `Axis crashed: ${message}`, severity: "critical" }]
829
- });
830
- }
831
- }
832
- return verdicts;
833
- };
834
- var aggregatePass = (verdicts) => verdicts.every((v) => v.pass);
835
- var withBrandParam = (url, brand) => {
836
- const parsed = new URL(url);
837
- parsed.searchParams.set("brand", brand);
838
- return parsed.toString();
839
- };
840
- var validate = async (options) => {
841
- const viewport = options.viewport ?? DESKTOP_VIEWPORT;
842
- const axes = options.axes ?? ALL_AXES;
843
- const browser = options.browser ?? await chromium.launch();
844
- const ownsBrowser = !options.browser;
845
- const context = await browser.newContext({
846
- viewport: { width: viewport.width, height: viewport.height }
847
- });
848
- const page = await context.newPage();
849
- try {
850
- const artifact = await capturePage(page, options.url, viewport, options.timeoutMs);
851
- const axisContext = {
852
- artifact,
853
- page,
854
- browser,
855
- brief: options.brief,
856
- baseline: options.baseline,
857
- comparison: options.comparison,
858
- compareConfig: options.compareConfig,
859
- captureUnderBrand: async (brand) => {
860
- const brandContext = await browser.newContext({
861
- viewport: { width: viewport.width, height: viewport.height }
862
- });
863
- const brandPage = await brandContext.newPage();
864
- try {
865
- return await capturePage(
866
- brandPage,
867
- withBrandParam(options.url, brand),
868
- viewport,
869
- options.timeoutMs
870
- );
871
- } finally {
872
- await brandContext.close();
873
- }
874
- }
875
- };
876
- const verdicts = await runAxes(axes, axisContext);
877
- return { url: options.url, pass: aggregatePass(verdicts), verdicts };
878
- } finally {
879
- await context.close();
880
- if (ownsBrowser) await browser.close();
881
- }
882
- };
883
- var CONTENT_TYPES = {
884
- ".html": "text/html; charset=utf-8",
885
- ".js": "text/javascript; charset=utf-8",
886
- ".css": "text/css; charset=utf-8",
887
- ".json": "application/json; charset=utf-8",
888
- ".svg": "image/svg+xml",
889
- ".png": "image/png",
890
- ".jpg": "image/jpeg",
891
- ".woff2": "font/woff2",
892
- ".woff": "font/woff"
893
- };
894
- var serveStatic = async (rootDir) => {
895
- const indexPath = join(rootDir, "index.html");
896
- const rootResolved = resolve(rootDir);
897
- const server = createServer((request, response) => {
898
- const send = (filePath, status = 200) => {
899
- response.writeHead(status, {
900
- "content-type": CONTENT_TYPES[extname(filePath)] ?? "application/octet-stream"
901
- });
902
- const stream = createReadStream(filePath);
903
- stream.on("error", (error) => {
904
- console.error(`serveStatic: failed to read ${filePath}:`, error);
905
- if (!response.headersSent) response.writeHead(500);
906
- response.end();
907
- });
908
- stream.pipe(response);
909
- };
910
- const notFound = () => {
911
- response.writeHead(404);
912
- response.end();
913
- };
914
- let rawPath;
915
- try {
916
- rawPath = decodeURIComponent((request.url ?? "/").split("?")[0] ?? "/");
917
- } catch {
918
- response.writeHead(400);
919
- response.end();
920
- return;
921
- }
922
- const relative = normalize(rawPath).replace(/^(\.\.[/\\])+/, "");
923
- const candidate = join(rootDir, relative);
924
- const candidateResolved = resolve(candidate);
925
- if (candidateResolved !== rootResolved && !candidateResolved.startsWith(rootResolved + sep)) {
926
- notFound();
927
- return;
928
- }
929
- stat(candidate).then((info) => {
930
- send(info.isDirectory() ? indexPath : candidate);
931
- }).catch(() => {
932
- if (extname(candidate) === "") {
933
- send(indexPath);
934
- return;
935
- }
936
- notFound();
937
- });
938
- });
939
- await new Promise((resolve3) => server.listen(0, "127.0.0.1", resolve3));
940
- const address = server.address();
941
- if (address === null || typeof address === "string") {
942
- throw new Error("serveStatic: failed to bind an ephemeral port");
943
- }
944
- return {
945
- url: `http://127.0.0.1:${address.port}`,
946
- close: () => new Promise((resolve3, reject) => {
947
- server.close((error) => error ? reject(error) : resolve3());
948
- })
949
- };
950
- };
951
-
952
- // src/cli.ts
953
- var HELP = `@keboola/validate-ui \u2014 closed-loop validation for generated Keboola UI
2
+ import { m as CompareConfig, o as validate, t as compareRoutes, w as serveStatic } from "./runner-ZOonToZg.js";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import process from "node:process";
6
+ //#region src/cli.ts
7
+ const HELP = `@keboola/validate-ui — closed-loop validation for generated Keboola UI
954
8
 
955
9
  Usage:
956
10
  validate-ui --url <url> [--brief <text>] [--json]
@@ -974,112 +28,119 @@ compare options:
974
28
 
975
29
  Exit code is 0 when every axis / route passes, 1 otherwise.
976
30
  `;
977
- var flagValue = (argv, name) => {
978
- const index = argv.indexOf(name);
979
- if (index < 0) return void 0;
980
- const value = argv[index + 1];
981
- return value === void 0 || value.startsWith("-") ? void 0 : value;
982
- };
983
- var formatText = (verdict) => {
984
- const lines = [`validate-ui ${verdict.pass ? "PASS" : "FAIL"} \u2014 ${verdict.url}`];
985
- for (const axis of verdict.verdicts) {
986
- lines.push(` ${axis.pass ? "\u2713" : "\u2717"} ${axis.axis}`);
987
- for (const finding of axis.findings) {
988
- lines.push(` [${finding.severity}] ${finding.message}`);
989
- }
990
- }
991
- return lines.join("\n");
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
- };
1046
- var main = async () => {
1047
- const argv = process2.argv.slice(2);
1048
- if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
1049
- process2.stdout.write(HELP);
1050
- return;
1051
- }
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");
1058
- const serveDir = flagValue(argv, "--serve");
1059
- let url = flagValue(argv, "--url");
1060
- let server;
1061
- if (serveDir !== void 0) {
1062
- server = await serveStatic(resolve(serveDir));
1063
- const route = flagValue(argv, "--route") ?? "/";
1064
- url = `${server.url}${route.startsWith("/") ? route : `/${route}`}`;
1065
- }
1066
- if (url === void 0) {
1067
- process2.stderr.write("validate-ui: one of --url or --serve is required\n\n" + HELP);
1068
- process2.exitCode = 1;
1069
- return;
1070
- }
1071
- try {
1072
- const verdict = await validate({ url, brief });
1073
- process2.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + "\n");
1074
- process2.exitCode = verdict.pass ? 0 : 1;
1075
- } finally {
1076
- await server?.close();
1077
- }
31
+ const flagValue = (argv, name) => {
32
+ const index = argv.indexOf(name);
33
+ if (index < 0) return void 0;
34
+ const value = argv[index + 1];
35
+ return value === void 0 || value.startsWith("-") ? void 0 : value;
36
+ };
37
+ const formatText = (verdict) => {
38
+ const lines = [`validate-ui ${verdict.pass ? "PASS" : "FAIL"} — ${verdict.url}`];
39
+ for (const axis of verdict.verdicts) {
40
+ lines.push(` ${axis.pass ? "✓" : "✗"} ${axis.axis}`);
41
+ for (const finding of axis.findings) lines.push(` [${finding.severity}] ${finding.message}`);
42
+ }
43
+ return lines.join("\n");
44
+ };
45
+ const formatCompareFinding = (finding) => {
46
+ const tag = finding.suppressed !== void 0 ? " (suppressed)" : "";
47
+ const detail = finding.detail !== void 0 ? ` — ${finding.detail}` : "";
48
+ return ` [${finding.severity}] ${finding.message}${detail}${tag}`;
49
+ };
50
+ const formatCompare = (result) => {
51
+ const lines = [`validate-ui compare ${result.pass ? "PASS" : "FAIL"}`];
52
+ if (result.sharedChrome.length > 0) {
53
+ lines.push(" shared across routes:");
54
+ for (const finding of result.sharedChrome) lines.push(formatCompareFinding(finding));
55
+ }
56
+ for (const route of result.routes) {
57
+ lines.push(` ${route.pass ? "✓" : "✗"} ${route.route}`);
58
+ for (const finding of route.findings) lines.push(formatCompareFinding(finding));
59
+ }
60
+ return lines.join("\n");
61
+ };
62
+ const loadCompareConfig = async (path) => {
63
+ if (path === void 0) return void 0;
64
+ return CompareConfig.parse(JSON.parse(await readFile(resolve(path), "utf8")));
65
+ };
66
+ const runCompare = async (argv, asJson) => {
67
+ const newBaseUrl = flagValue(argv, "--new");
68
+ const oldBaseUrl = flagValue(argv, "--old");
69
+ const oldSnapshotDir = flagValue(argv, "--old-snapshots");
70
+ const routes = flagValue(argv, "--routes")?.split(",").map((route) => route.trim()).filter((route) => route !== "");
71
+ if (newBaseUrl === void 0 || routes === void 0 || routes.length === 0) {
72
+ process.stderr.write("validate-ui compare: --new and --routes are required\n\n" + HELP);
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+ if (oldBaseUrl !== void 0 && oldSnapshotDir !== void 0) {
77
+ process.stderr.write("validate-ui compare: pass only one of --old or --old-snapshots\n\n" + HELP);
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ const base = {
82
+ newBaseUrl,
83
+ routes,
84
+ config: await loadCompareConfig(flagValue(argv, "--config"))
85
+ };
86
+ let result;
87
+ if (oldBaseUrl !== void 0) result = await compareRoutes({
88
+ ...base,
89
+ oldBaseUrl
90
+ });
91
+ else if (oldSnapshotDir !== void 0) result = await compareRoutes({
92
+ ...base,
93
+ oldSnapshotDir
94
+ });
95
+ else {
96
+ process.stderr.write("validate-ui compare: provide --old or --old-snapshots\n\n" + HELP);
97
+ process.exitCode = 1;
98
+ return;
99
+ }
100
+ process.stdout.write((asJson ? JSON.stringify(result, null, 2) : formatCompare(result)) + "\n");
101
+ process.exitCode = result.pass ? 0 : 1;
102
+ };
103
+ const main = async () => {
104
+ const argv = process.argv.slice(2);
105
+ if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
106
+ process.stdout.write(HELP);
107
+ return;
108
+ }
109
+ const asJson = argv.includes("--json");
110
+ if (argv[0] === "compare") {
111
+ await runCompare(argv.slice(1), asJson);
112
+ return;
113
+ }
114
+ const brief = flagValue(argv, "--brief");
115
+ const serveDir = flagValue(argv, "--serve");
116
+ let url = flagValue(argv, "--url");
117
+ let server;
118
+ if (serveDir !== void 0) {
119
+ server = await serveStatic(resolve(serveDir));
120
+ const route = flagValue(argv, "--route") ?? "/";
121
+ url = `${server.url}${route.startsWith("/") ? route : `/${route}`}`;
122
+ }
123
+ if (url === void 0) {
124
+ process.stderr.write("validate-ui: one of --url or --serve is required\n\n" + HELP);
125
+ process.exitCode = 1;
126
+ return;
127
+ }
128
+ try {
129
+ const verdict = await validate({
130
+ url,
131
+ brief
132
+ });
133
+ process.stdout.write((asJson ? JSON.stringify(verdict, null, 2) : formatText(verdict)) + "\n");
134
+ process.exitCode = verdict.pass ? 0 : 1;
135
+ } finally {
136
+ await server?.close();
137
+ }
1078
138
  };
1079
139
  main().catch((error) => {
1080
- process2.stderr.write(`validate-ui: ${error instanceof Error ? error.message : String(error)}
1081
- `);
1082
- process2.exitCode = 1;
140
+ process.stderr.write(`validate-ui: ${error instanceof Error ? error.message : String(error)}\n`);
141
+ process.exitCode = 1;
1083
142
  });
1084
- //# sourceMappingURL=cli.js.map
143
+ //#endregion
144
+ export {};
145
+
1085
146
  //# sourceMappingURL=cli.js.map