@keboola/validate-ui 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +23 -0
- package/README.md +11 -2
- package/dist/cli.cjs +9 -4
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +9 -4
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +6 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -53
- package/dist/index.d.ts +67 -53
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/{runner-CEdpbEON.cjs → runner-BwEjSueH.cjs} +153 -91
- package/dist/runner-BwEjSueH.cjs.map +1 -0
- package/dist/{runner-CLpaiCIG.js → runner-DH04MitR.js} +124 -92
- package/dist/runner-DH04MitR.js.map +1 -0
- package/package.json +9 -8
- package/dist/runner-CEdpbEON.cjs.map +0 -1
- package/dist/runner-CLpaiCIG.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -79,13 +79,12 @@ declare const CompareConfig: z.ZodObject<{
|
|
|
79
79
|
type CompareConfig = z.infer<typeof CompareConfig>;
|
|
80
80
|
//#endregion
|
|
81
81
|
//#region src/types.d.ts
|
|
82
|
-
/** A named viewport the harness renders and captures at. */
|
|
83
82
|
type Viewport = {
|
|
84
83
|
label: string;
|
|
85
84
|
width: number;
|
|
86
85
|
height: number;
|
|
87
86
|
};
|
|
88
|
-
/** A
|
|
87
|
+
/** A console or page-error message emitted while the page loaded. */
|
|
89
88
|
type ConsoleEntry = {
|
|
90
89
|
type: string;
|
|
91
90
|
text: string;
|
|
@@ -100,22 +99,18 @@ type NetworkEntry = {
|
|
|
100
99
|
failure?: string;
|
|
101
100
|
};
|
|
102
101
|
/**
|
|
103
|
-
* Ground truth
|
|
104
|
-
*
|
|
105
|
-
* so it is safe to serialize and pass to a VLM.
|
|
102
|
+
* Ground truth every verdict axis reasons over, produced by {@link capture}.
|
|
103
|
+
*
|
|
104
|
+
* - Holds no live browser handles, so it is safe to serialize and pass to a VLM.
|
|
106
105
|
*/
|
|
107
106
|
type CaptureArtifact = {
|
|
108
107
|
url: string;
|
|
109
108
|
viewport: Viewport;
|
|
110
|
-
/** Full-page PNG
|
|
109
|
+
/** Full-page PNG. */
|
|
111
110
|
screenshot: Uint8Array;
|
|
112
|
-
/**
|
|
111
|
+
/** Outer HTML after load. */
|
|
113
112
|
dom: string;
|
|
114
|
-
/**
|
|
115
|
-
* Trimmed `body.innerText` read from the live DOM when available. An empty
|
|
116
|
-
* string is a reliable white-screen signal — more robust than parsing
|
|
117
|
-
* {@link dom}. Absent only when an artifact is built without a live page.
|
|
118
|
-
*/
|
|
113
|
+
/** Trimmed `body.innerText`; empty is a reliable white-screen signal. Absent without a live page. */
|
|
119
114
|
renderedText?: string;
|
|
120
115
|
console: ConsoleEntry[];
|
|
121
116
|
network: NetworkEntry[];
|
|
@@ -126,9 +121,9 @@ type Severity = (typeof SEVERITIES)[number];
|
|
|
126
121
|
type Finding = {
|
|
127
122
|
message: string;
|
|
128
123
|
severity: Severity;
|
|
129
|
-
/**
|
|
124
|
+
/** Extra context — a stack, a WCAG id, a selector, a diff ratio. */
|
|
130
125
|
detail?: string;
|
|
131
|
-
/**
|
|
126
|
+
/** Pointer — WCAG ref, DOM selector, request URL. */
|
|
132
127
|
ref?: string;
|
|
133
128
|
};
|
|
134
129
|
/** The result of running one axis against one page. */
|
|
@@ -144,36 +139,30 @@ type AggregateVerdict = {
|
|
|
144
139
|
verdicts: Verdict[];
|
|
145
140
|
};
|
|
146
141
|
/**
|
|
147
|
-
* Everything an axis may need.
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* {@link AxisContext.captureUnderBrand}. All optional fields may be absent, and
|
|
151
|
-
* an axis must degrade to a clear finding rather than throw when its input is
|
|
152
|
-
* missing.
|
|
142
|
+
* Everything an axis may need.
|
|
143
|
+
*
|
|
144
|
+
* - Any optional field may be absent; an axis then reports a clear finding instead of throwing.
|
|
153
145
|
*/
|
|
154
146
|
type AxisContext = {
|
|
155
147
|
artifact: CaptureArtifact;
|
|
156
148
|
/** Live page for the primary capture, when the run keeps a browser open. */
|
|
157
149
|
page?: Page;
|
|
158
|
-
/** Shared browser
|
|
150
|
+
/** Shared browser for rendering additional pages. */
|
|
159
151
|
browser?: Browser;
|
|
160
|
-
/**
|
|
152
|
+
/** Natural-language brief the UI was generated from. */
|
|
161
153
|
brief?: string;
|
|
162
|
-
/**
|
|
154
|
+
/** Prior screenshot to diff against. */
|
|
163
155
|
baseline?: Uint8Array;
|
|
164
|
-
/** Re-render the same route under
|
|
156
|
+
/** Re-render and capture the same route under another brand id. */
|
|
165
157
|
captureUnderBrand?: (brand: string) => Promise<CaptureArtifact>;
|
|
166
158
|
/** The "old" UI reduced to regions — enables the compare axis when present. */
|
|
167
159
|
comparison?: CompareSnapshot;
|
|
168
|
-
/** Allowlist + state markers for the compare axis. */
|
|
169
160
|
compareConfig?: CompareConfig;
|
|
170
161
|
};
|
|
171
162
|
/**
|
|
172
|
-
* A verdict axis.
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* should not throw for expected-missing input — the aggregator converts a throw
|
|
176
|
-
* into a failing verdict, but a clear finding is better.
|
|
163
|
+
* A verdict axis.
|
|
164
|
+
*
|
|
165
|
+
* - `run` should not throw on missing input; the aggregator turns a throw into a failing verdict.
|
|
177
166
|
*/
|
|
178
167
|
type Axis = {
|
|
179
168
|
name: string;
|
|
@@ -203,6 +192,43 @@ type CaptureOptions = {
|
|
|
203
192
|
*/
|
|
204
193
|
declare const capture: (url: string, options?: CaptureOptions) => Promise<CaptureArtifact[]>;
|
|
205
194
|
//#endregion
|
|
195
|
+
//#region src/viewport.d.ts
|
|
196
|
+
/**
|
|
197
|
+
* The phone-first baseline every Keboola screen is designed against.
|
|
198
|
+
*
|
|
199
|
+
* - 375×812 is the narrowest width still in meaningful use; a layout that survives it
|
|
200
|
+
* survives every wider phone.
|
|
201
|
+
* - Distinct from {@link MOBILE_VIEWPORT} (390×844), which is one of `capture`'s two
|
|
202
|
+
* default capture sizes rather than a bar anything is held to.
|
|
203
|
+
*/
|
|
204
|
+
declare const PHONE_VIEWPORT: Viewport;
|
|
205
|
+
/** Names `--viewport` accepts in place of a `WIDTHxHEIGHT` pair. */
|
|
206
|
+
declare const VIEWPORT_PRESETS: Readonly<Record<string, Viewport>>;
|
|
207
|
+
/**
|
|
208
|
+
* Widths below this count as a phone.
|
|
209
|
+
*
|
|
210
|
+
* - 768 is the tablet breakpoint: at or above it a page has room for a desktop-shaped
|
|
211
|
+
* layout, below it the content has one column to live in.
|
|
212
|
+
* - Drives severity, not layout — see {@link isPhoneViewport}.
|
|
213
|
+
*/
|
|
214
|
+
declare const PHONE_MAX_WIDTH = 768;
|
|
215
|
+
/**
|
|
216
|
+
* Whether a viewport is phone-class.
|
|
217
|
+
*
|
|
218
|
+
* - Horizontal overflow here is a broken page, not a cosmetic slip: the reader has to
|
|
219
|
+
* scroll sideways to read a sentence. Axes weigh findings accordingly.
|
|
220
|
+
*/
|
|
221
|
+
declare const isPhoneViewport: (viewport: Viewport) => boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Read a `--viewport` value: a preset name, or `WIDTHxHEIGHT` in CSS pixels.
|
|
224
|
+
*
|
|
225
|
+
* - Throws on anything else rather than falling back to a default — a typo'd size would
|
|
226
|
+
* otherwise silently evaluate the viewport the caller was trying to move off.
|
|
227
|
+
* - Zero in either dimension is rejected for the same reason; playwright accepts it and
|
|
228
|
+
* renders nothing.
|
|
229
|
+
*/
|
|
230
|
+
declare const parseViewport: (spec: string) => Viewport;
|
|
231
|
+
//#endregion
|
|
206
232
|
//#region src/prepare-screenshot.d.ts
|
|
207
233
|
/**
|
|
208
234
|
* Settle the page before a screenshot: freeze animations first (so parking the
|
|
@@ -265,14 +291,10 @@ declare const briefConformanceAxis: Axis;
|
|
|
265
291
|
//#endregion
|
|
266
292
|
//#region src/axes/compare.d.ts
|
|
267
293
|
/**
|
|
268
|
-
* A5 · Semantic old-vs-new compare verdict
|
|
294
|
+
* A5 · Semantic old-vs-new compare verdict.
|
|
269
295
|
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* are unaffected. Extracts the "new" snapshot from the live `context.page`, then
|
|
273
|
-
* runs the pure {@link diffSnapshots}: deltas are localized per region, declared
|
|
274
|
-
* expected-absent deltas are suppressed (not dropped), and a region that degraded
|
|
275
|
-
* to an empty/error state is reported informationally rather than as value loss.
|
|
296
|
+
* - Passing skip unless `context.comparison` (an "old" {@link CompareSnapshot}) is supplied.
|
|
297
|
+
* - Diffs it against a snapshot of the live `context.page` via {@link diffSnapshots}.
|
|
276
298
|
*/
|
|
277
299
|
declare const compareAxis: Axis;
|
|
278
300
|
//#endregion
|
|
@@ -288,14 +310,10 @@ declare const runtimeHealthAxis: Axis;
|
|
|
288
310
|
//#endregion
|
|
289
311
|
//#region src/axes/visual-brand.d.ts
|
|
290
312
|
/**
|
|
291
|
-
* A4 · Visual & brand-correctness verdict
|
|
313
|
+
* A4 · Visual & brand-correctness verdict.
|
|
292
314
|
*
|
|
293
|
-
* Baseline diff
|
|
294
|
-
*
|
|
295
|
-
* `context.captureUnderBrand`: the chrome must visibly re-skin under an
|
|
296
|
-
* alternate brand. Flags layout overflow. Each check degrades to no finding when
|
|
297
|
-
* its input is absent. (Asserting categorical colors — Badge/Alert/ModalIcon —
|
|
298
|
-
* stay fixed pixel-wise is future work; see `altBrandFinding`.)
|
|
315
|
+
* - Baseline pixel diff; alt brand must visibly re-skin the chrome; layout overflow.
|
|
316
|
+
* - Each check yields no finding when its input is absent.
|
|
299
317
|
*/
|
|
300
318
|
declare const visualBrandAxis: Axis;
|
|
301
319
|
//#endregion
|
|
@@ -343,14 +361,10 @@ declare const dedupeSharedChrome: (perRoute: RouteFindings[]) => DedupResult;
|
|
|
343
361
|
//#endregion
|
|
344
362
|
//#region src/compare/regions.d.ts
|
|
345
363
|
/**
|
|
346
|
-
* Reduce the live page to its diffable regions, in the browser.
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* precedence
|
|
351
|
-
* (`data-region` → `data-testid` → role → `aria-label` → nearest heading), its
|
|
352
|
-
* state from `data-state`/text markers, and its values as normalized numeric
|
|
353
|
-
* tokens. Symmetric across old and new so the two sides diff cleanly.
|
|
364
|
+
* Reduce the live page to its diffable regions, in the browser.
|
|
365
|
+
*
|
|
366
|
+
* - Regions never overlap, so no value is counted twice.
|
|
367
|
+
* - Must stay symmetric across old and new so the two sides diff cleanly.
|
|
354
368
|
*/
|
|
355
369
|
declare const extractRegions: (page: Page, config?: CompareConfig) => Promise<Region[]>;
|
|
356
370
|
//#endregion
|
|
@@ -402,5 +416,5 @@ declare const saveSnapshot: (snapshot: CompareSnapshot, path: string) => Promise
|
|
|
402
416
|
/** Read and validate a recorded snapshot from disk. */
|
|
403
417
|
declare const loadSnapshot: (path: string) => Promise<CompareSnapshot>;
|
|
404
418
|
//#endregion
|
|
405
|
-
export { ALL_AXES, type AggregateVerdict, type Axis, type AxisContext, type CaptureArtifact, type CaptureOptions, CompareConfig, type CompareFinding, type CompareRoutesOptions, type CompareRoutesResult, CompareSnapshot, type ConsoleEntry, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, type DedupResult, ExpectedAbsentRule, type Finding, MOBILE_VIEWPORT, type NetworkEntry, REGION_STATES, Region, type RegionState, type RouteCompareResult, type RouteFindings, SEVERITIES, type Severity, type StaticServer, type ValidateOptions, type Verdict, type Viewport, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, loadSnapshot, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
|
|
419
|
+
export { ALL_AXES, type AggregateVerdict, type Axis, type AxisContext, type CaptureArtifact, type CaptureOptions, CompareConfig, type CompareFinding, type CompareRoutesOptions, type CompareRoutesResult, CompareSnapshot, type ConsoleEntry, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, type DedupResult, ExpectedAbsentRule, type Finding, MOBILE_VIEWPORT, type NetworkEntry, PHONE_MAX_WIDTH, PHONE_VIEWPORT, REGION_STATES, Region, type RegionState, type RouteCompareResult, type RouteFindings, SEVERITIES, type Severity, type StaticServer, VIEWPORT_PRESETS, type ValidateOptions, type Verdict, type Viewport, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, isPhoneViewport, loadSnapshot, parseViewport, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
|
|
406
420
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as DEFAULT_VIEWPORTS, C as accessibilityAxis, D as VIEWPORT_PRESETS, E as PHONE_VIEWPORT, F as prepareForScreenshot, M as MOBILE_VIEWPORT, N as capture, O as isPhoneViewport, P as capturePage, S as briefConformanceAxis, T as PHONE_MAX_WIDTH, _ as REGION_STATES, a as runAxes, b as comparePass, c as visualBrandAxis, d as captureSnapshot, f as loadSnapshot, g as ExpectedAbsentRule, h as CompareSnapshot, i as aggregatePass, j as DESKTOP_VIEWPORT, k as parseViewport, l as runtimeHealthAxis, m as CompareConfig, n as routeSnapshotFile, o as validate, p as saveSnapshot, r as dedupeSharedChrome, s as ALL_AXES, t as compareRoutes, u as compareAxis, v as Region, w as serveStatic, x as diffSnapshots, y as extractRegions } from "./runner-DH04MitR.js";
|
|
2
2
|
//#region src/types.ts
|
|
3
3
|
const SEVERITIES = [
|
|
4
4
|
"critical",
|
|
@@ -7,6 +7,6 @@ const SEVERITIES = [
|
|
|
7
7
|
"minor"
|
|
8
8
|
];
|
|
9
9
|
//#endregion
|
|
10
|
-
export { ALL_AXES, CompareConfig, CompareSnapshot, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, ExpectedAbsentRule, MOBILE_VIEWPORT, REGION_STATES, Region, SEVERITIES, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, loadSnapshot, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
|
|
10
|
+
export { ALL_AXES, CompareConfig, CompareSnapshot, DEFAULT_VIEWPORTS, DESKTOP_VIEWPORT, ExpectedAbsentRule, MOBILE_VIEWPORT, PHONE_MAX_WIDTH, PHONE_VIEWPORT, REGION_STATES, Region, SEVERITIES, VIEWPORT_PRESETS, accessibilityAxis, aggregatePass, briefConformanceAxis, capture, capturePage, captureSnapshot, compareAxis, comparePass, compareRoutes, dedupeSharedChrome, diffSnapshots, extractRegions, isPhoneViewport, loadSnapshot, parseViewport, prepareForScreenshot, routeSnapshotFile, runAxes, runtimeHealthAxis, saveSnapshot, serveStatic, validate, visualBrandAxis };
|
|
11
11
|
|
|
12
12
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["import type { Browser, Page } from 'playwright';\n\nimport type { CompareConfig, CompareSnapshot } from './compare/types';\n\
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["import type { Browser, Page } from 'playwright';\n\nimport type { CompareConfig, CompareSnapshot } from './compare/types';\n\nexport type Viewport = {\n label: string;\n width: number;\n height: number;\n};\n\n/** A console or page-error message emitted while the page loaded. */\nexport type ConsoleEntry = {\n type: string;\n text: string;\n location?: string;\n};\n\n/** A network response or failed request observed during load. */\nexport type NetworkEntry = {\n url: string;\n method: string;\n /** HTTP status, or 0 for a request that failed before a response. */\n status: number;\n failure?: string;\n};\n\n/**\n * Ground truth every verdict axis reasons over, produced by {@link capture}.\n *\n * - Holds no live browser handles, so it is safe to serialize and pass to a VLM.\n */\nexport type CaptureArtifact = {\n url: string;\n viewport: Viewport;\n /** Full-page PNG. */\n screenshot: Uint8Array;\n /** Outer HTML after load. */\n dom: string;\n /** Trimmed `body.innerText`; empty is a reliable white-screen signal. Absent without a live page. */\n renderedText?: string;\n console: ConsoleEntry[];\n network: NetworkEntry[];\n};\n\nexport const SEVERITIES = ['critical', 'serious', 'moderate', 'minor'] as const;\nexport type Severity = (typeof SEVERITIES)[number];\n\n/** One problem an axis found. */\nexport type Finding = {\n message: string;\n severity: Severity;\n /** Extra context — a stack, a WCAG id, a selector, a diff ratio. */\n detail?: string;\n /** Pointer — WCAG ref, DOM selector, request URL. */\n ref?: string;\n};\n\n/** The result of running one axis against one page. */\nexport type Verdict = {\n axis: string;\n pass: boolean;\n findings: Finding[];\n};\n\n/** The combined result of running every axis against one page. */\nexport type AggregateVerdict = {\n url: string;\n pass: boolean;\n verdicts: Verdict[];\n};\n\n/**\n * Everything an axis may need.\n *\n * - Any optional field may be absent; an axis then reports a clear finding instead of throwing.\n */\nexport type AxisContext = {\n artifact: CaptureArtifact;\n /** Live page for the primary capture, when the run keeps a browser open. */\n page?: Page;\n /** Shared browser for rendering additional pages. */\n browser?: Browser;\n /** Natural-language brief the UI was generated from. */\n brief?: string;\n /** Prior screenshot to diff against. */\n baseline?: Uint8Array;\n /** Re-render and capture the same route under another brand id. */\n captureUnderBrand?: (brand: string) => Promise<CaptureArtifact>;\n /** The \"old\" UI reduced to regions — enables the compare axis when present. */\n comparison?: CompareSnapshot;\n compareConfig?: CompareConfig;\n};\n\n/**\n * A verdict axis.\n *\n * - `run` should not throw on missing input; the aggregator turns a throw into a failing verdict.\n */\nexport type Axis = {\n name: string;\n run: (context: AxisContext) => Promise<Verdict>;\n};\n"],"mappings":";;AA4CA,MAAa,aAAa;CAAC;CAAY;CAAW;CAAY;AAAO"}
|
|
@@ -165,6 +165,65 @@ const capture = async (url, options = {}) => {
|
|
|
165
165
|
}
|
|
166
166
|
};
|
|
167
167
|
//#endregion
|
|
168
|
+
//#region src/viewport.ts
|
|
169
|
+
/**
|
|
170
|
+
* The phone-first baseline every Keboola screen is designed against.
|
|
171
|
+
*
|
|
172
|
+
* - 375×812 is the narrowest width still in meaningful use; a layout that survives it
|
|
173
|
+
* survives every wider phone.
|
|
174
|
+
* - Distinct from {@link MOBILE_VIEWPORT} (390×844), which is one of `capture`'s two
|
|
175
|
+
* default capture sizes rather than a bar anything is held to.
|
|
176
|
+
*/
|
|
177
|
+
const PHONE_VIEWPORT = {
|
|
178
|
+
label: "phone",
|
|
179
|
+
width: 375,
|
|
180
|
+
height: 812
|
|
181
|
+
};
|
|
182
|
+
/** Names `--viewport` accepts in place of a `WIDTHxHEIGHT` pair. */
|
|
183
|
+
const VIEWPORT_PRESETS = {
|
|
184
|
+
desktop: DESKTOP_VIEWPORT,
|
|
185
|
+
mobile: MOBILE_VIEWPORT,
|
|
186
|
+
phone: PHONE_VIEWPORT
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Widths below this count as a phone.
|
|
190
|
+
*
|
|
191
|
+
* - 768 is the tablet breakpoint: at or above it a page has room for a desktop-shaped
|
|
192
|
+
* layout, below it the content has one column to live in.
|
|
193
|
+
* - Drives severity, not layout — see {@link isPhoneViewport}.
|
|
194
|
+
*/
|
|
195
|
+
const PHONE_MAX_WIDTH = 768;
|
|
196
|
+
/**
|
|
197
|
+
* Whether a viewport is phone-class.
|
|
198
|
+
*
|
|
199
|
+
* - Horizontal overflow here is a broken page, not a cosmetic slip: the reader has to
|
|
200
|
+
* scroll sideways to read a sentence. Axes weigh findings accordingly.
|
|
201
|
+
*/
|
|
202
|
+
const isPhoneViewport = (viewport) => viewport.width < 768;
|
|
203
|
+
const DIMENSIONS = /^(\d+)x(\d+)$/;
|
|
204
|
+
/**
|
|
205
|
+
* Read a `--viewport` value: a preset name, or `WIDTHxHEIGHT` in CSS pixels.
|
|
206
|
+
*
|
|
207
|
+
* - Throws on anything else rather than falling back to a default — a typo'd size would
|
|
208
|
+
* otherwise silently evaluate the viewport the caller was trying to move off.
|
|
209
|
+
* - Zero in either dimension is rejected for the same reason; playwright accepts it and
|
|
210
|
+
* renders nothing.
|
|
211
|
+
*/
|
|
212
|
+
const parseViewport = (spec) => {
|
|
213
|
+
const trimmed = spec.trim();
|
|
214
|
+
const preset = VIEWPORT_PRESETS[trimmed.toLowerCase()];
|
|
215
|
+
if (preset !== void 0) return preset;
|
|
216
|
+
const match = DIMENSIONS.exec(trimmed);
|
|
217
|
+
const width = Number(match?.[1]);
|
|
218
|
+
const height = Number(match?.[2]);
|
|
219
|
+
if (match === null || width === 0 || height === 0) throw new Error(`--viewport must be WIDTHxHEIGHT (e.g. 375x812) or one of ${Object.keys(VIEWPORT_PRESETS).join(", ")}, got "${spec}"`);
|
|
220
|
+
return {
|
|
221
|
+
label: `${width}x${height}`,
|
|
222
|
+
width,
|
|
223
|
+
height
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
//#endregion
|
|
168
227
|
//#region src/serve.ts
|
|
169
228
|
const CONTENT_TYPES = {
|
|
170
229
|
".html": "text/html; charset=utf-8",
|
|
@@ -241,27 +300,15 @@ const serveStatic = async (rootDir) => {
|
|
|
241
300
|
/**
|
|
242
301
|
* Elements whose content is cut off with no way for the user to reveal it.
|
|
243
302
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* `800` rendered as `8`. Every other axis scored it 100.
|
|
248
|
-
*
|
|
249
|
-
* Deliberate truncation is not a finding. Text clipped with `text-overflow: ellipsis`
|
|
250
|
-
* announces itself, and a scrollable box (`overflow: auto | scroll`) can be scrolled to
|
|
251
|
-
* the rest. Only silent loss is reported — `overflow: hidden | clip` with no ellipsis, and
|
|
252
|
-
* `<input>`s whose value is wider than the box that holds it (a single-line input is never
|
|
253
|
-
* scrollable by eye; the user has to select into it to discover the rest). A `<textarea>`
|
|
254
|
-
* is judged like any other box: it renders a real scrollbar when its content overflows
|
|
255
|
-
* horizontally, so it is only a finding once something hides the overflow.
|
|
303
|
+
* - Not reported: `text-overflow: ellipsis`, scrollable boxes (`overflow: auto | scroll`).
|
|
304
|
+
* - Reported: `overflow: hidden | clip` without ellipsis, and `<input>`s wider than their box.
|
|
305
|
+
* - A `<textarea>` scrolls, so it is judged like any other box.
|
|
256
306
|
*/
|
|
257
|
-
/**
|
|
307
|
+
/** Absorbs sub-pixel and rounding noise. */
|
|
258
308
|
const CLIP_TOLERANCE_PX = 2;
|
|
259
|
-
/**
|
|
309
|
+
/** Keeps one broken component from flooding the report. */
|
|
260
310
|
const MAX_REPORTED = 10;
|
|
261
|
-
/**
|
|
262
|
-
* Runs in the page. Kept self-contained (no imports, no outer-scope references) because it
|
|
263
|
-
* is serialised into the browser context.
|
|
264
|
-
*/
|
|
311
|
+
/** Runs in the page: serialised, so no imports or outer-scope references. */
|
|
265
312
|
/* c8 ignore start -- executes in the browser, not under node coverage */
|
|
266
313
|
const findClippedElements = ({ tolerance, max }) => {
|
|
267
314
|
const describe = (el) => {
|
|
@@ -309,16 +356,12 @@ const findClippedElements = ({ tolerance, max }) => {
|
|
|
309
356
|
return results;
|
|
310
357
|
};
|
|
311
358
|
/* c8 ignore stop */
|
|
312
|
-
/** Format one clipped element as a finding. */
|
|
313
359
|
const toFinding$1 = (clipped) => ({
|
|
314
360
|
message: `Content is cut off with no way to reveal it (${clipped.selector})`,
|
|
315
361
|
severity: "serious",
|
|
316
362
|
detail: `${clipped.isInput ? "Input value" : "Text"} "${clipped.text}" needs ${clipped.contentWidth}px but has ${clipped.visibleWidth}px. Widen the element, or make the truncation explicit with \`text-overflow: ellipsis\`.`
|
|
317
363
|
});
|
|
318
|
-
/**
|
|
319
|
-
* Report every element whose content is silently cut off. Returns no findings when the
|
|
320
|
-
* page cannot be inspected, so a caller can always spread the result.
|
|
321
|
-
*/
|
|
364
|
+
/** Report every element whose content is silently cut off; `[]` when the page can't be inspected. */
|
|
322
365
|
const clippedTextFindings = async (page) => {
|
|
323
366
|
try {
|
|
324
367
|
return (await page.evaluate(findClippedElements, {
|
|
@@ -389,7 +432,6 @@ const accessibilityAxis = {
|
|
|
389
432
|
};
|
|
390
433
|
//#endregion
|
|
391
434
|
//#region src/axes/vlm-provider.ts
|
|
392
|
-
/** Concatenate a message's text blocks. */
|
|
393
435
|
const textFromMessage = (message) => message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
394
436
|
const makeProvider = (backend, clientOptions) => {
|
|
395
437
|
const client = new _anthropic_ai_sdk.default(clientOptions);
|
|
@@ -420,25 +462,12 @@ const makeProvider = (backend, clientOptions) => {
|
|
|
420
462
|
};
|
|
421
463
|
const nonEmpty = (value) => value !== void 0 && value !== "";
|
|
422
464
|
/**
|
|
423
|
-
* Pick a VLM backend from the environment
|
|
424
|
-
* configured (the axis then skips gracefully).
|
|
425
|
-
*
|
|
426
|
-
* Preference order mirrors how kai-agent routes Claude for internal/team use
|
|
427
|
-
* (`apps/kai-agent/src/services/entrypoint-builder.ts`): the Keboola LLM path
|
|
428
|
-
* points the Anthropic SDK at a Keboola-hosted proxy via `baseURL` and
|
|
429
|
-
* authenticates with a Keboola-minted token sent as `x-api-key` (the SDK's
|
|
430
|
-
* `apiKey`). kai-agent injects that pair through the SDK-native
|
|
431
|
-
* `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` env vars; we accept those, and
|
|
432
|
-
* prefer the explicit `VALIDATE_UI_LLM_BASE_URL` / `VALIDATE_UI_LLM_TOKEN`
|
|
433
|
-
* (with `KBC_TOKEN` as a Keboola-convention alias) when set. The proxy path is
|
|
434
|
-
* preferred because the team plan has no dedicated raw key.
|
|
465
|
+
* Pick a VLM backend from the environment; `undefined` makes the axis skip.
|
|
435
466
|
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* `Anthropic` constructor explicitly, so resolution never reads ambient
|
|
441
|
-
* `process.env` behind the injected `env` arg.
|
|
467
|
+
* 1. Keboola LLM proxy (`baseURL` + Keboola token as `apiKey`), as kai-agent's
|
|
468
|
+
* `entrypoint-builder.ts` does — the team plan has no raw key.
|
|
469
|
+
* 2. Otherwise a bare `ANTHROPIC_API_KEY` selects raw Anthropic.
|
|
470
|
+
* - Reads only the injected `env`, never ambient `process.env`.
|
|
442
471
|
*/
|
|
443
472
|
const resolveVlmProvider = (env = process.env) => {
|
|
444
473
|
const baseUrl = env.VALIDATE_UI_LLM_BASE_URL ?? env.ANTHROPIC_BASE_URL;
|
|
@@ -676,14 +705,10 @@ const DEFAULT_ERROR_MARKERS = [
|
|
|
676
705
|
"try again"
|
|
677
706
|
];
|
|
678
707
|
/**
|
|
679
|
-
* Reduce the live page to its diffable regions, in the browser.
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
* precedence
|
|
684
|
-
* (`data-region` → `data-testid` → role → `aria-label` → nearest heading), its
|
|
685
|
-
* state from `data-state`/text markers, and its values as normalized numeric
|
|
686
|
-
* tokens. Symmetric across old and new so the two sides diff cleanly.
|
|
708
|
+
* Reduce the live page to its diffable regions, in the browser.
|
|
709
|
+
*
|
|
710
|
+
* - Regions never overlap, so no value is counted twice.
|
|
711
|
+
* - Must stay symmetric across old and new so the two sides diff cleanly.
|
|
687
712
|
*/
|
|
688
713
|
const extractRegions = async (page, config = {}) => {
|
|
689
714
|
const emptyMarkers = [...DEFAULT_EMPTY_MARKERS, ...config.emptyStateMarkers ?? []];
|
|
@@ -856,14 +881,10 @@ const skip = (message) => ({
|
|
|
856
881
|
}]
|
|
857
882
|
});
|
|
858
883
|
/**
|
|
859
|
-
* A5 · Semantic old-vs-new compare verdict
|
|
884
|
+
* A5 · Semantic old-vs-new compare verdict.
|
|
860
885
|
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
* are unaffected. Extracts the "new" snapshot from the live `context.page`, then
|
|
864
|
-
* runs the pure {@link diffSnapshots}: deltas are localized per region, declared
|
|
865
|
-
* expected-absent deltas are suppressed (not dropped), and a region that degraded
|
|
866
|
-
* to an empty/error state is reported informationally rather than as value loss.
|
|
886
|
+
* - Passing skip unless `context.comparison` (an "old" {@link CompareSnapshot}) is supplied.
|
|
887
|
+
* - Diffs it against a snapshot of the live `context.page` via {@link diffSnapshots}.
|
|
867
888
|
*/
|
|
868
889
|
const compareAxis = {
|
|
869
890
|
name: "compare",
|
|
@@ -882,12 +903,18 @@ const compareAxis = {
|
|
|
882
903
|
};
|
|
883
904
|
//#endregion
|
|
884
905
|
//#region src/axes/runtime-health.ts
|
|
885
|
-
const
|
|
886
|
-
"something went wrong",
|
|
906
|
+
const OVERLAY_MARKERS = [
|
|
887
907
|
"vite-error-overlay",
|
|
888
908
|
"vite-plugin-checker-error-overlay",
|
|
889
909
|
"react-error-overlay"
|
|
890
910
|
];
|
|
911
|
+
const ERROR_TEXT_MARKERS = ["something went wrong"];
|
|
912
|
+
/**
|
|
913
|
+
* The serialized DOM minus content that is never rendered as page content —
|
|
914
|
+
* `<script>`, `<style>`, `<template>`, `<noscript>` bodies and comments — so a
|
|
915
|
+
* marker named inside them is not mistaken for a rendered overlay.
|
|
916
|
+
*/
|
|
917
|
+
const stripNonRendered = (dom) => dom.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|template|noscript)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
|
|
891
918
|
const pageErrorFindings = (console) => console.filter((entry) => entry.type === "pageerror").map((entry) => ({
|
|
892
919
|
message: `Uncaught page error: ${entry.text}`,
|
|
893
920
|
severity: "critical",
|
|
@@ -956,10 +983,17 @@ const networkFindings = (network) => {
|
|
|
956
983
|
}
|
|
957
984
|
return [...byUrl.values()];
|
|
958
985
|
};
|
|
959
|
-
/**
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
986
|
+
/**
|
|
987
|
+
* An error overlay in the DOM, or error-boundary copy in the rendered text,
|
|
988
|
+
* means a crashed render rather than real content. Overlays are matched on the
|
|
989
|
+
* DOM with script/style bodies removed; error copy on `renderedText` (the live
|
|
990
|
+
* `innerText`, which also skips hidden elements), falling back to the stripped
|
|
991
|
+
* DOM only when an artifact has no live text.
|
|
992
|
+
*/
|
|
993
|
+
const errorMarkerFinding = (dom, renderedText) => {
|
|
994
|
+
const markup = stripNonRendered(dom).toLowerCase();
|
|
995
|
+
const visible = (renderedText ?? markup).toLowerCase();
|
|
996
|
+
const marker = OVERLAY_MARKERS.find((m) => markup.includes(m)) ?? ERROR_TEXT_MARKERS.find((m) => visible.includes(m));
|
|
963
997
|
return marker === void 0 ? null : {
|
|
964
998
|
message: "Error boundary / error overlay rendered instead of the app",
|
|
965
999
|
severity: "critical",
|
|
@@ -990,7 +1024,7 @@ const runtimeHealthAxis = {
|
|
|
990
1024
|
...consoleErrorFindings(artifact.console),
|
|
991
1025
|
...networkFindings(artifact.network)
|
|
992
1026
|
];
|
|
993
|
-
const errorMarker = errorMarkerFinding(artifact.dom);
|
|
1027
|
+
const errorMarker = errorMarkerFinding(artifact.dom, artifact.renderedText);
|
|
994
1028
|
if (errorMarker !== null) findings.push(errorMarker);
|
|
995
1029
|
const whiteScreen = whiteScreenFinding(artifact.renderedText);
|
|
996
1030
|
if (whiteScreen !== null) findings.push(whiteScreen);
|
|
@@ -1010,7 +1044,7 @@ const RESKIN_THRESHOLD = .005;
|
|
|
1010
1044
|
const DRIFT_PIXEL_THRESHOLD = .1;
|
|
1011
1045
|
const RESKIN_PIXEL_THRESHOLD = .02;
|
|
1012
1046
|
const DEFAULT_ALT_BRAND = "example-customer";
|
|
1013
|
-
/**
|
|
1047
|
+
/** Pixel mismatch ratio of two PNGs (0 = identical, 1 = every pixel differs). */
|
|
1014
1048
|
const pngMismatchRatio = (a, b, pixelThreshold = DRIFT_PIXEL_THRESHOLD) => {
|
|
1015
1049
|
const imgA = pngjs.PNG.sync.read(Buffer.from(a));
|
|
1016
1050
|
const imgB = pngjs.PNG.sync.read(Buffer.from(b));
|
|
@@ -1044,16 +1078,9 @@ const baselineFinding = (screenshot, baseline) => {
|
|
|
1044
1078
|
/**
|
|
1045
1079
|
* Whether the alt-brand capture actually activated that brand.
|
|
1046
1080
|
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
* brand switcher into every generated app is not something a scaffold should impose.
|
|
1051
|
-
*
|
|
1052
|
-
* Without this check, such an app captures the same brand twice, and the identical
|
|
1053
|
-
* screenshots are reported as "chrome did not re-skin" — a false failure for code that is
|
|
1054
|
-
* entirely correct. The brand is nominated via `data-brand` on the root element (written
|
|
1055
|
-
* by `@keboola/design`'s ThemeProvider), so absence of the requested id means the request
|
|
1056
|
-
* was ignored, not that the chrome failed to re-skin.
|
|
1081
|
+
* - Most apps ignore `?brand=<id>`: brand is build configuration, not a runtime switch.
|
|
1082
|
+
* - `@keboola/design`'s ThemeProvider writes `data-brand`; its absence means "not assessed",
|
|
1083
|
+
* not "did not re-skin".
|
|
1057
1084
|
*/
|
|
1058
1085
|
const altBrandWasApplied = (altDom, brand) => altDom.includes(`data-brand="${brand}"`);
|
|
1059
1086
|
const altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
|
|
@@ -1071,32 +1098,37 @@ const altBrandFinding = (screenshot, altScreenshot, brand, altDom) => {
|
|
|
1071
1098
|
return null;
|
|
1072
1099
|
};
|
|
1073
1100
|
/**
|
|
1074
|
-
* Non-fatal `moderate` findings for a brand's interactive
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
* build-time warning here, not a runtime axe surprise. Empty for an AA-safe brand.
|
|
1101
|
+
* Non-fatal `moderate` findings for a brand's interactive pairings below WCAG-AA.
|
|
1102
|
+
*
|
|
1103
|
+
* - Surfaces `@keboola/brand-registry`'s check before axe would hit it at runtime.
|
|
1078
1104
|
*/
|
|
1079
1105
|
const interactiveContrastFindings = (brand) => (0, _keboola_brand_registry.checkInteractiveContrast)(brand).map((warning) => ({
|
|
1080
1106
|
message: `brand '${brand.id}' ${warning.message}`,
|
|
1081
1107
|
severity: "moderate",
|
|
1082
1108
|
detail: `${warning.foreground} on ${warning.background} (${warning.palette})`
|
|
1083
1109
|
}));
|
|
1084
|
-
|
|
1085
|
-
|
|
1110
|
+
/**
|
|
1111
|
+
* Horizontal overflow, weighed by the width it was measured at.
|
|
1112
|
+
*
|
|
1113
|
+
* - Phone viewport: `critical` — blocking is what makes phone-first a rule.
|
|
1114
|
+
* - Desktop: `moderate`.
|
|
1115
|
+
*/
|
|
1116
|
+
const overflowFinding = async (page, viewport) => {
|
|
1117
|
+
if (!await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)) return null;
|
|
1118
|
+
return isPhoneViewport(viewport) ? {
|
|
1119
|
+
message: `horizontal layout overflow at ${viewport.width}px`,
|
|
1120
|
+
severity: "critical",
|
|
1121
|
+
detail: "The page scrolls sideways on a phone — lay it out in one column instead."
|
|
1122
|
+
} : {
|
|
1086
1123
|
message: "horizontal layout overflow",
|
|
1087
1124
|
severity: "moderate"
|
|
1088
1125
|
};
|
|
1089
|
-
return null;
|
|
1090
1126
|
};
|
|
1091
1127
|
/**
|
|
1092
|
-
* A4 · Visual & brand-correctness verdict
|
|
1128
|
+
* A4 · Visual & brand-correctness verdict.
|
|
1093
1129
|
*
|
|
1094
|
-
* Baseline diff
|
|
1095
|
-
*
|
|
1096
|
-
* `context.captureUnderBrand`: the chrome must visibly re-skin under an
|
|
1097
|
-
* alternate brand. Flags layout overflow. Each check degrades to no finding when
|
|
1098
|
-
* its input is absent. (Asserting categorical colors — Badge/Alert/ModalIcon —
|
|
1099
|
-
* stay fixed pixel-wise is future work; see `altBrandFinding`.)
|
|
1130
|
+
* - Baseline pixel diff; alt brand must visibly re-skin the chrome; layout overflow.
|
|
1131
|
+
* - Each check yields no finding when its input is absent.
|
|
1100
1132
|
*/
|
|
1101
1133
|
const visualBrandAxis = {
|
|
1102
1134
|
name: "visual-brand",
|
|
@@ -1115,7 +1147,7 @@ const visualBrandAxis = {
|
|
|
1115
1147
|
if (altBrand !== void 0) findings.push(...interactiveContrastFindings(altBrand));
|
|
1116
1148
|
}
|
|
1117
1149
|
if (page !== void 0) {
|
|
1118
|
-
const finding = await overflowFinding(page);
|
|
1150
|
+
const finding = await overflowFinding(page, artifact.viewport);
|
|
1119
1151
|
if (finding !== null) findings.push(finding);
|
|
1120
1152
|
}
|
|
1121
1153
|
return {
|
|
@@ -1370,6 +1402,18 @@ Object.defineProperty(exports, "MOBILE_VIEWPORT", {
|
|
|
1370
1402
|
return MOBILE_VIEWPORT;
|
|
1371
1403
|
}
|
|
1372
1404
|
});
|
|
1405
|
+
Object.defineProperty(exports, "PHONE_MAX_WIDTH", {
|
|
1406
|
+
enumerable: true,
|
|
1407
|
+
get: function() {
|
|
1408
|
+
return PHONE_MAX_WIDTH;
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
Object.defineProperty(exports, "PHONE_VIEWPORT", {
|
|
1412
|
+
enumerable: true,
|
|
1413
|
+
get: function() {
|
|
1414
|
+
return PHONE_VIEWPORT;
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1373
1417
|
Object.defineProperty(exports, "REGION_STATES", {
|
|
1374
1418
|
enumerable: true,
|
|
1375
1419
|
get: function() {
|
|
@@ -1382,6 +1426,12 @@ Object.defineProperty(exports, "Region", {
|
|
|
1382
1426
|
return Region;
|
|
1383
1427
|
}
|
|
1384
1428
|
});
|
|
1429
|
+
Object.defineProperty(exports, "VIEWPORT_PRESETS", {
|
|
1430
|
+
enumerable: true,
|
|
1431
|
+
get: function() {
|
|
1432
|
+
return VIEWPORT_PRESETS;
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1385
1435
|
Object.defineProperty(exports, "__toESM", {
|
|
1386
1436
|
enumerable: true,
|
|
1387
1437
|
get: function() {
|
|
@@ -1460,12 +1510,24 @@ Object.defineProperty(exports, "extractRegions", {
|
|
|
1460
1510
|
return extractRegions;
|
|
1461
1511
|
}
|
|
1462
1512
|
});
|
|
1513
|
+
Object.defineProperty(exports, "isPhoneViewport", {
|
|
1514
|
+
enumerable: true,
|
|
1515
|
+
get: function() {
|
|
1516
|
+
return isPhoneViewport;
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1463
1519
|
Object.defineProperty(exports, "loadSnapshot", {
|
|
1464
1520
|
enumerable: true,
|
|
1465
1521
|
get: function() {
|
|
1466
1522
|
return loadSnapshot;
|
|
1467
1523
|
}
|
|
1468
1524
|
});
|
|
1525
|
+
Object.defineProperty(exports, "parseViewport", {
|
|
1526
|
+
enumerable: true,
|
|
1527
|
+
get: function() {
|
|
1528
|
+
return parseViewport;
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1469
1531
|
Object.defineProperty(exports, "prepareForScreenshot", {
|
|
1470
1532
|
enumerable: true,
|
|
1471
1533
|
get: function() {
|
|
@@ -1515,4 +1577,4 @@ Object.defineProperty(exports, "visualBrandAxis", {
|
|
|
1515
1577
|
}
|
|
1516
1578
|
});
|
|
1517
1579
|
|
|
1518
|
-
//# sourceMappingURL=runner-
|
|
1580
|
+
//# sourceMappingURL=runner-BwEjSueH.cjs.map
|