@keboola/validate-ui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +536 -87
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +536 -87
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +402 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -5
- package/dist/index.d.ts +196 -5
- package/dist/index.js +389 -3
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,81 @@
|
|
|
1
1
|
import { Page, Browser } from 'playwright';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Backend/render state of a region. A region that rendered its data is `ok`;
|
|
6
|
+
* `empty`/`error`/`loading` are non-data states — an old→new transition into one
|
|
7
|
+
* of them is a degrade, not a value regression (a backend was unavailable, or an
|
|
8
|
+
* intentional empty state is showing).
|
|
9
|
+
*/
|
|
10
|
+
declare const REGION_STATES: readonly ["ok", "empty", "error", "loading"];
|
|
11
|
+
type RegionState = (typeof REGION_STATES)[number];
|
|
12
|
+
/** One diffable region of a page — a landmark, labelled section, or component. */
|
|
13
|
+
declare const Region: z.ZodObject<{
|
|
14
|
+
key: z.ZodString;
|
|
15
|
+
label: z.ZodString;
|
|
16
|
+
state: z.ZodEnum<{
|
|
17
|
+
ok: "ok";
|
|
18
|
+
empty: "empty";
|
|
19
|
+
error: "error";
|
|
20
|
+
loading: "loading";
|
|
21
|
+
}>;
|
|
22
|
+
values: z.ZodArray<z.ZodString>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
type Region = z.infer<typeof Region>;
|
|
25
|
+
/**
|
|
26
|
+
* A page reduced to its diffable regions — the currency both input paths (a live
|
|
27
|
+
* boot or a recorded JSON file) produce and the differ consumes. Pure JSON, so
|
|
28
|
+
* it is safe to serialize to disk and re-load; {@link loadSnapshot} validates it.
|
|
29
|
+
*/
|
|
30
|
+
declare const CompareSnapshot: z.ZodObject<{
|
|
31
|
+
route: z.ZodString;
|
|
32
|
+
regions: z.ZodArray<z.ZodObject<{
|
|
33
|
+
key: z.ZodString;
|
|
34
|
+
label: z.ZodString;
|
|
35
|
+
state: z.ZodEnum<{
|
|
36
|
+
ok: "ok";
|
|
37
|
+
empty: "empty";
|
|
38
|
+
error: "error";
|
|
39
|
+
loading: "loading";
|
|
40
|
+
}>;
|
|
41
|
+
values: z.ZodArray<z.ZodString>;
|
|
42
|
+
}, z.core.$strip>>;
|
|
43
|
+
}, z.core.$strip>;
|
|
44
|
+
type CompareSnapshot = z.infer<typeof CompareSnapshot>;
|
|
45
|
+
/** One old-vs-new delta, always attributed to a region. */
|
|
46
|
+
type CompareFinding = Finding & {
|
|
47
|
+
regionKey: string;
|
|
48
|
+
kind: 'region-removed' | 'region-degraded' | 'value-missing' | 'region-added';
|
|
49
|
+
/** Stable signature of the delta — identical across routes for shared chrome. */
|
|
50
|
+
valueSignature: string;
|
|
51
|
+
/** Present when a config rule suppressed this delta; it never fails the verdict. */
|
|
52
|
+
suppressed?: {
|
|
53
|
+
reason: string;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* One expected-absent rule. `region` (exact or `*`-glob against {@link Region.key})
|
|
58
|
+
* alone suppresses a whole `region-removed`; add `value` to suppress a specific
|
|
59
|
+
* missing value token (optionally scoped to a region). `reason` is required and
|
|
60
|
+
* surfaced in the report so suppression is auditable, never silent.
|
|
61
|
+
*/
|
|
62
|
+
declare const ExpectedAbsentRule: z.ZodObject<{
|
|
63
|
+
region: z.ZodOptional<z.ZodString>;
|
|
64
|
+
value: z.ZodOptional<z.ZodString>;
|
|
65
|
+
reason: z.ZodString;
|
|
66
|
+
}, z.core.$strip>;
|
|
67
|
+
type ExpectedAbsentRule = z.infer<typeof ExpectedAbsentRule>;
|
|
68
|
+
/** Semantic-compare configuration — the allowlist plus extra state markers. */
|
|
69
|
+
declare const CompareConfig: z.ZodObject<{
|
|
70
|
+
expectedAbsent: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
71
|
+
region: z.ZodOptional<z.ZodString>;
|
|
72
|
+
value: z.ZodOptional<z.ZodString>;
|
|
73
|
+
reason: z.ZodString;
|
|
74
|
+
}, z.core.$strip>>>;
|
|
75
|
+
emptyStateMarkers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
76
|
+
errorStateMarkers: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
77
|
+
}, z.core.$strip>;
|
|
78
|
+
type CompareConfig = z.infer<typeof CompareConfig>;
|
|
2
79
|
|
|
3
80
|
/** A named viewport the harness renders and captures at. */
|
|
4
81
|
type Viewport = {
|
|
@@ -84,11 +161,15 @@ type AxisContext = {
|
|
|
84
161
|
baseline?: Uint8Array;
|
|
85
162
|
/** Re-render the same route under a different brand id and capture it. */
|
|
86
163
|
captureUnderBrand?: (brand: string) => Promise<CaptureArtifact>;
|
|
164
|
+
/** The "old" UI reduced to regions — enables the compare axis when present. */
|
|
165
|
+
comparison?: CompareSnapshot;
|
|
166
|
+
/** Allowlist + state markers for the compare axis. */
|
|
167
|
+
compareConfig?: CompareConfig;
|
|
87
168
|
};
|
|
88
169
|
/**
|
|
89
|
-
* A verdict axis. Each of the
|
|
90
|
-
* visual-brand, brief-conformance) implements this contract in its own
|
|
91
|
-
* they can be built independently. `run` must resolve to a {@link Verdict} and
|
|
170
|
+
* A verdict axis. Each of the axes (runtime-health, accessibility,
|
|
171
|
+
* visual-brand, brief-conformance, compare) implements this contract in its own
|
|
172
|
+
* file so they can be built independently. `run` must resolve to a {@link Verdict} and
|
|
92
173
|
* should not throw for expected-missing input — the aggregator converts a throw
|
|
93
174
|
* into a failing verdict, but a clear finding is better.
|
|
94
175
|
*/
|
|
@@ -157,6 +238,10 @@ type ValidateOptions = {
|
|
|
157
238
|
/** Reuse a browser instead of launching one. */
|
|
158
239
|
browser?: Browser;
|
|
159
240
|
timeoutMs?: number;
|
|
241
|
+
/** The "old" UI reduced to regions — enables the compare axis when present. */
|
|
242
|
+
comparison?: CompareSnapshot;
|
|
243
|
+
/** Allowlist + state markers for the compare axis. */
|
|
244
|
+
compareConfig?: CompareConfig;
|
|
160
245
|
};
|
|
161
246
|
/**
|
|
162
247
|
* The closed loop: render `url`, capture ground truth, and run every axis over
|
|
@@ -170,6 +255,18 @@ declare const accessibilityAxis: Axis;
|
|
|
170
255
|
|
|
171
256
|
declare const briefConformanceAxis: Axis;
|
|
172
257
|
|
|
258
|
+
/**
|
|
259
|
+
* A5 · Semantic old-vs-new compare verdict — UT-4572.
|
|
260
|
+
*
|
|
261
|
+
* Activates only when `context.comparison` (an "old" {@link CompareSnapshot}) is
|
|
262
|
+
* supplied; otherwise it degrades to a passing skip, so ordinary single-page runs
|
|
263
|
+
* are unaffected. Extracts the "new" snapshot from the live `context.page`, then
|
|
264
|
+
* runs the pure {@link diffSnapshots}: deltas are localized per region, declared
|
|
265
|
+
* expected-absent deltas are suppressed (not dropped), and a region that degraded
|
|
266
|
+
* to an empty/error state is reported informationally rather than as value loss.
|
|
267
|
+
*/
|
|
268
|
+
declare const compareAxis: Axis;
|
|
269
|
+
|
|
173
270
|
/**
|
|
174
271
|
* A2 · Runtime-health verdict — UT-4493.
|
|
175
272
|
*
|
|
@@ -191,7 +288,101 @@ declare const runtimeHealthAxis: Axis;
|
|
|
191
288
|
*/
|
|
192
289
|
declare const visualBrandAxis: Axis;
|
|
193
290
|
|
|
194
|
-
/**
|
|
291
|
+
/**
|
|
292
|
+
* The registered axes, in report order. `compare` runs last and only activates
|
|
293
|
+
* when a comparison snapshot is supplied, so ordinary single-page runs are
|
|
294
|
+
* unaffected.
|
|
295
|
+
*/
|
|
195
296
|
declare const ALL_AXES: Axis[];
|
|
196
297
|
|
|
197
|
-
|
|
298
|
+
/**
|
|
299
|
+
* Semantic old-vs-new diff of two snapshots. Walks regions by key and localizes
|
|
300
|
+
* every delta to a region: a removed region is `serious` unless allowlisted; a
|
|
301
|
+
* region that degraded to an empty/error state is reported informationally and
|
|
302
|
+
* NOT diffed as value loss; surviving regions get a per-region value-multiset
|
|
303
|
+
* diff with allowlist suppression; new regions are non-regression notes. Pure —
|
|
304
|
+
* no browser, no I/O.
|
|
305
|
+
*/
|
|
306
|
+
declare const diffSnapshots: (oldSnap: CompareSnapshot, newSnap: CompareSnapshot, config?: CompareConfig) => CompareFinding[];
|
|
307
|
+
/** A compare run passes when no active (non-suppressed) delta is serious/critical. */
|
|
308
|
+
declare const comparePass: (findings: CompareFinding[]) => boolean;
|
|
309
|
+
|
|
310
|
+
/** One route's compare findings. */
|
|
311
|
+
type RouteFindings = {
|
|
312
|
+
route: string;
|
|
313
|
+
findings: CompareFinding[];
|
|
314
|
+
};
|
|
315
|
+
type DedupResult = {
|
|
316
|
+
/** Findings seen on ≥2 routes, collapsed to one entry tagged with the route count. */
|
|
317
|
+
sharedChrome: CompareFinding[];
|
|
318
|
+
/** Per-route findings with the shared-chrome copies removed. */
|
|
319
|
+
perRoute: RouteFindings[];
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Collapse deltas that recur across routes into a single `shared-chrome` finding.
|
|
323
|
+
* A value flagged on a shared header/daily-brief element would otherwise be
|
|
324
|
+
* multiplied across every route (the prototype's `8.4/7.7/-8%/67%` on all 10
|
|
325
|
+
* pages); here it is reported once, tagged with the routes it spans, and dropped
|
|
326
|
+
* from each route's own list. Pure — no browser, no I/O.
|
|
327
|
+
*/
|
|
328
|
+
declare const dedupeSharedChrome: (perRoute: RouteFindings[]) => DedupResult;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Reduce the live page to its diffable regions, in the browser. An explicit
|
|
332
|
+
* `[data-region]` is an authoritative boundary (its subtree is one region);
|
|
333
|
+
* elsewhere boundaries are the outermost-empty ("leaf") landmarks/sections, so
|
|
334
|
+
* regions never overlap or double-count values. Each region's key is resolved by
|
|
335
|
+
* precedence
|
|
336
|
+
* (`data-region` → `data-testid` → role → `aria-label` → nearest heading), its
|
|
337
|
+
* state from `data-state`/text markers, and its values as normalized numeric
|
|
338
|
+
* tokens. Symmetric across old and new so the two sides diff cleanly.
|
|
339
|
+
*/
|
|
340
|
+
declare const extractRegions: (page: Page, config?: CompareConfig) => Promise<Region[]>;
|
|
341
|
+
|
|
342
|
+
/** How the "old" side is sourced — booted live or loaded from recorded snapshots, never both. */
|
|
343
|
+
type OldSource = {
|
|
344
|
+
oldBaseUrl: string;
|
|
345
|
+
oldSnapshotDir?: never;
|
|
346
|
+
} | {
|
|
347
|
+
oldSnapshotDir: string;
|
|
348
|
+
oldBaseUrl?: never;
|
|
349
|
+
};
|
|
350
|
+
type CompareRoutesOptions = OldSource & {
|
|
351
|
+
/** Base URL of the new build, joined with each route. */
|
|
352
|
+
newBaseUrl: string;
|
|
353
|
+
routes: string[];
|
|
354
|
+
config?: CompareConfig;
|
|
355
|
+
browser?: Browser;
|
|
356
|
+
viewport?: Viewport;
|
|
357
|
+
timeoutMs?: number;
|
|
358
|
+
};
|
|
359
|
+
type RouteCompareResult = {
|
|
360
|
+
route: string;
|
|
361
|
+
findings: CompareFinding[];
|
|
362
|
+
pass: boolean;
|
|
363
|
+
};
|
|
364
|
+
type CompareRoutesResult = {
|
|
365
|
+
routes: RouteCompareResult[];
|
|
366
|
+
/** Deltas seen on ≥2 routes, reported once (e.g. shared chrome / daily-brief). */
|
|
367
|
+
sharedChrome: CompareFinding[];
|
|
368
|
+
pass: boolean;
|
|
369
|
+
};
|
|
370
|
+
/** Recorded old snapshot filename for a route: `/a/b` → `a_b.json`, `/` → `index.json`. */
|
|
371
|
+
declare const routeSnapshotFile: (route: string) => string;
|
|
372
|
+
/**
|
|
373
|
+
* Multi-route semantic compare. Boots each route on the new side (and the old
|
|
374
|
+
* side too, unless recorded snapshots are supplied), diffs per route, then
|
|
375
|
+
* collapses deltas that recur across routes into a single shared-chrome entry so
|
|
376
|
+
* one shared element is not reported as N regressions. Exactly one of
|
|
377
|
+
* `oldBaseUrl` / `oldSnapshotDir` must be given.
|
|
378
|
+
*/
|
|
379
|
+
declare const compareRoutes: (options: CompareRoutesOptions) => Promise<CompareRoutesResult>;
|
|
380
|
+
|
|
381
|
+
/** Reduce a loaded live page to a {@link CompareSnapshot} for the given route. */
|
|
382
|
+
declare const captureSnapshot: (page: Page, route: string, config?: CompareConfig) => Promise<CompareSnapshot>;
|
|
383
|
+
/** Persist a snapshot as pretty JSON so an "old" side can be recorded once and re-diffed. */
|
|
384
|
+
declare const saveSnapshot: (snapshot: CompareSnapshot, path: string) => Promise<void>;
|
|
385
|
+
/** Read and validate a recorded snapshot from disk. */
|
|
386
|
+
declare const loadSnapshot: (path: string) => Promise<CompareSnapshot>;
|
|
387
|
+
|
|
388
|
+
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 };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
2
|
import { createReadStream } from 'fs';
|
|
3
|
-
import { stat } from 'fs/promises';
|
|
3
|
+
import { stat, writeFile, readFile } from 'fs/promises';
|
|
4
4
|
import { createServer } from 'http';
|
|
5
5
|
import { join, resolve, normalize, sep, extname } from 'path';
|
|
6
6
|
import axe from 'axe-core';
|
|
@@ -333,6 +333,302 @@ ${domExcerpt}`
|
|
|
333
333
|
}
|
|
334
334
|
};
|
|
335
335
|
|
|
336
|
+
// src/compare/diff.ts
|
|
337
|
+
var matchesGlob = (pattern, value) => {
|
|
338
|
+
if (!pattern.includes("*")) return pattern === value;
|
|
339
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
340
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
341
|
+
};
|
|
342
|
+
var multisetMissing = (from, to) => {
|
|
343
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
344
|
+
for (const value of to) remaining.set(value, (remaining.get(value) ?? 0) + 1);
|
|
345
|
+
const missing = [];
|
|
346
|
+
for (const value of from) {
|
|
347
|
+
const count = remaining.get(value) ?? 0;
|
|
348
|
+
if (count > 0) remaining.set(value, count - 1);
|
|
349
|
+
else missing.push(value);
|
|
350
|
+
}
|
|
351
|
+
return missing;
|
|
352
|
+
};
|
|
353
|
+
var signature = (regionKey, tokens) => `${regionKey}::${[...tokens].sort().join("|")}`;
|
|
354
|
+
var regionRemovalRule = (rules, key) => rules.find(
|
|
355
|
+
(rule) => rule.value === void 0 && rule.region !== void 0 && matchesGlob(rule.region, key)
|
|
356
|
+
);
|
|
357
|
+
var valueRuleMatches = (rule, key, value) => rule.value !== void 0 && matchesGlob(rule.value, value) && (rule.region === void 0 || matchesGlob(rule.region, key));
|
|
358
|
+
var removedFinding = (region, rule) => ({
|
|
359
|
+
regionKey: region.key,
|
|
360
|
+
kind: "region-removed",
|
|
361
|
+
valueSignature: signature(region.key, region.values),
|
|
362
|
+
message: rule === void 0 ? `region "${region.label}" was removed` : `region "${region.label}" removed as expected`,
|
|
363
|
+
severity: rule === void 0 ? "serious" : "minor",
|
|
364
|
+
...rule !== void 0 && { suppressed: { reason: rule.reason } }
|
|
365
|
+
});
|
|
366
|
+
var degradedFinding = (region) => ({
|
|
367
|
+
regionKey: region.key,
|
|
368
|
+
kind: "region-degraded",
|
|
369
|
+
valueSignature: signature(region.key, [region.state]),
|
|
370
|
+
message: `region "${region.label}" degraded to ${region.state} state \u2014 not compared as value loss`,
|
|
371
|
+
severity: "minor"
|
|
372
|
+
});
|
|
373
|
+
var addedFinding = (region) => ({
|
|
374
|
+
regionKey: region.key,
|
|
375
|
+
kind: "region-added",
|
|
376
|
+
valueSignature: signature(region.key, []),
|
|
377
|
+
message: `region "${region.label}" is new`,
|
|
378
|
+
severity: "minor"
|
|
379
|
+
});
|
|
380
|
+
var diffRegionValues = (oldRegion, newRegion, rules) => {
|
|
381
|
+
const missing = multisetMissing(oldRegion.values, newRegion.values);
|
|
382
|
+
if (missing.length === 0) return [];
|
|
383
|
+
const active = [];
|
|
384
|
+
const suppressed = [];
|
|
385
|
+
const reasons = /* @__PURE__ */ new Set();
|
|
386
|
+
for (const value of missing) {
|
|
387
|
+
const rule = rules.find((r) => valueRuleMatches(r, newRegion.key, value));
|
|
388
|
+
if (rule === void 0) active.push(value);
|
|
389
|
+
else {
|
|
390
|
+
suppressed.push(value);
|
|
391
|
+
reasons.add(rule.reason);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const findings = [];
|
|
395
|
+
if (active.length > 0) {
|
|
396
|
+
findings.push({
|
|
397
|
+
regionKey: newRegion.key,
|
|
398
|
+
kind: "value-missing",
|
|
399
|
+
valueSignature: signature(newRegion.key, active),
|
|
400
|
+
message: `region "${newRegion.label}" is missing ${active.length} value(s)`,
|
|
401
|
+
severity: "moderate",
|
|
402
|
+
detail: active.join(", ")
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
if (suppressed.length > 0) {
|
|
406
|
+
findings.push({
|
|
407
|
+
regionKey: newRegion.key,
|
|
408
|
+
kind: "value-missing",
|
|
409
|
+
valueSignature: signature(newRegion.key, suppressed),
|
|
410
|
+
message: `region "${newRegion.label}" is missing ${suppressed.length} expected value(s)`,
|
|
411
|
+
severity: "minor",
|
|
412
|
+
detail: suppressed.join(", "),
|
|
413
|
+
suppressed: { reason: [...reasons].join("; ") }
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return findings;
|
|
417
|
+
};
|
|
418
|
+
var diffSnapshots = (oldSnap, newSnap, config = {}) => {
|
|
419
|
+
const rules = config.expectedAbsent ?? [];
|
|
420
|
+
const newByKey = new Map(newSnap.regions.map((region) => [region.key, region]));
|
|
421
|
+
const oldKeys = new Set(oldSnap.regions.map((region) => region.key));
|
|
422
|
+
const findings = [];
|
|
423
|
+
for (const oldRegion of oldSnap.regions) {
|
|
424
|
+
const newRegion = newByKey.get(oldRegion.key);
|
|
425
|
+
if (newRegion === void 0) {
|
|
426
|
+
findings.push(removedFinding(oldRegion, regionRemovalRule(rules, oldRegion.key)));
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (oldRegion.state === "ok" && newRegion.state !== "ok") {
|
|
430
|
+
findings.push(degradedFinding(newRegion));
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
findings.push(...diffRegionValues(oldRegion, newRegion, rules));
|
|
434
|
+
}
|
|
435
|
+
for (const newRegion of newSnap.regions) {
|
|
436
|
+
if (!oldKeys.has(newRegion.key)) findings.push(addedFinding(newRegion));
|
|
437
|
+
}
|
|
438
|
+
return findings;
|
|
439
|
+
};
|
|
440
|
+
var comparePass = (findings) => !findings.some(
|
|
441
|
+
(f) => f.suppressed === void 0 && (f.severity === "serious" || f.severity === "critical")
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// src/compare/regions.ts
|
|
445
|
+
var CANDIDATE_SELECTOR = [
|
|
446
|
+
"[data-region]",
|
|
447
|
+
"main",
|
|
448
|
+
"nav",
|
|
449
|
+
"header",
|
|
450
|
+
"aside",
|
|
451
|
+
"footer",
|
|
452
|
+
"section[aria-label]",
|
|
453
|
+
"[role=main]",
|
|
454
|
+
"[role=navigation]",
|
|
455
|
+
"[role=banner]",
|
|
456
|
+
"[role=complementary]",
|
|
457
|
+
"[role=contentinfo]",
|
|
458
|
+
"[role=region]"
|
|
459
|
+
].join(",");
|
|
460
|
+
var DEFAULT_EMPTY_MARKERS = [
|
|
461
|
+
"no data",
|
|
462
|
+
"no results",
|
|
463
|
+
"nothing to show",
|
|
464
|
+
"nothing here",
|
|
465
|
+
"get started"
|
|
466
|
+
];
|
|
467
|
+
var DEFAULT_ERROR_MARKERS = [
|
|
468
|
+
"something went wrong",
|
|
469
|
+
"failed to load",
|
|
470
|
+
"could not load",
|
|
471
|
+
"unavailable",
|
|
472
|
+
"try again"
|
|
473
|
+
];
|
|
474
|
+
var extractRegions = async (page, config = {}) => {
|
|
475
|
+
const emptyMarkers = [...DEFAULT_EMPTY_MARKERS, ...config.emptyStateMarkers ?? []];
|
|
476
|
+
const errorMarkers = [...DEFAULT_ERROR_MARKERS, ...config.errorStateMarkers ?? []];
|
|
477
|
+
return page.evaluate(
|
|
478
|
+
({ selector, emptyMarkers: emptyMarkers2, errorMarkers: errorMarkers2 }) => {
|
|
479
|
+
const STATES = ["ok", "empty", "error", "loading"];
|
|
480
|
+
const isState = (value) => STATES.includes(value);
|
|
481
|
+
const slugify = (raw) => raw.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "region";
|
|
482
|
+
const roleOf = (el) => {
|
|
483
|
+
const explicit = el.getAttribute("role");
|
|
484
|
+
if (explicit !== null && explicit !== "") return explicit;
|
|
485
|
+
const tag = el.tagName.toLowerCase();
|
|
486
|
+
if (tag === "main") return "main";
|
|
487
|
+
if (tag === "nav") return "navigation";
|
|
488
|
+
if (tag === "aside") return "complementary";
|
|
489
|
+
if (tag === "header" || tag === "footer") {
|
|
490
|
+
if (el.closest("main,nav,section,article,aside") !== null) return void 0;
|
|
491
|
+
return tag === "header" ? "banner" : "contentinfo";
|
|
492
|
+
}
|
|
493
|
+
return void 0;
|
|
494
|
+
};
|
|
495
|
+
const headingOf = (el) => {
|
|
496
|
+
const heading = el.querySelector("h1,h2,h3,h4,h5,h6");
|
|
497
|
+
const text = heading?.textContent?.trim();
|
|
498
|
+
return text !== void 0 && text !== "" ? text : void 0;
|
|
499
|
+
};
|
|
500
|
+
const keyOf = (el) => {
|
|
501
|
+
const region = el.getAttribute("data-region");
|
|
502
|
+
if (region !== null && region !== "") return slugify(region);
|
|
503
|
+
const testid = el.getAttribute("data-testid");
|
|
504
|
+
if (testid !== null && testid !== "") return slugify(testid);
|
|
505
|
+
const role = roleOf(el);
|
|
506
|
+
if (role !== void 0) return role;
|
|
507
|
+
const label = el.getAttribute("aria-label");
|
|
508
|
+
if (label !== null && label !== "") return slugify(label);
|
|
509
|
+
const heading = headingOf(el);
|
|
510
|
+
if (heading !== void 0) return slugify(heading);
|
|
511
|
+
return slugify(el.tagName);
|
|
512
|
+
};
|
|
513
|
+
const labelOf = (el, key) => {
|
|
514
|
+
const label = el.getAttribute("aria-label");
|
|
515
|
+
if (label !== null && label.trim() !== "") return label.trim();
|
|
516
|
+
const heading = headingOf(el);
|
|
517
|
+
if (heading !== void 0) return heading;
|
|
518
|
+
const testid = el.getAttribute("data-testid");
|
|
519
|
+
if (testid !== null && testid !== "") return testid;
|
|
520
|
+
return key;
|
|
521
|
+
};
|
|
522
|
+
const textOf = (el) => (el.innerText || el.textContent || "").trim();
|
|
523
|
+
const stateOf = (el, text) => {
|
|
524
|
+
const explicit = (el.getAttribute("data-state") ?? "").toLowerCase();
|
|
525
|
+
if (isState(explicit)) return explicit;
|
|
526
|
+
const lower = text.toLowerCase();
|
|
527
|
+
if (errorMarkers2.some((marker) => lower.includes(marker))) return "error";
|
|
528
|
+
if (lower.includes("loading")) return "loading";
|
|
529
|
+
if (emptyMarkers2.some((marker) => lower.includes(marker))) return "empty";
|
|
530
|
+
return "ok";
|
|
531
|
+
};
|
|
532
|
+
const valuesOf = (text) => (text.match(/-?\$?\s?\d[\d,]*(?:\.\d+)?%?/g) ?? []).map((token) => token.replace(/[$\s,]/g, "")).filter((token) => token !== "" && token !== "-");
|
|
533
|
+
const qualifies = (el) => {
|
|
534
|
+
const dataRegion = el.getAttribute("data-region");
|
|
535
|
+
if (dataRegion !== null && dataRegion !== "") {
|
|
536
|
+
const parent = el.parentElement;
|
|
537
|
+
return parent === null || parent.closest("[data-region]") === null;
|
|
538
|
+
}
|
|
539
|
+
return el.querySelector(selector) === null && el.closest("[data-region]") === null;
|
|
540
|
+
};
|
|
541
|
+
const candidates = Array.from(document.querySelectorAll(selector));
|
|
542
|
+
const regions = candidates.filter(qualifies);
|
|
543
|
+
const elements = regions.length > 0 ? regions : [document.body];
|
|
544
|
+
const used = {};
|
|
545
|
+
const uniqueKey = (key) => {
|
|
546
|
+
const count = (used[key] ?? 0) + 1;
|
|
547
|
+
used[key] = count;
|
|
548
|
+
return count === 1 ? key : `${key}-${count}`;
|
|
549
|
+
};
|
|
550
|
+
return elements.map((el) => {
|
|
551
|
+
const text = textOf(el);
|
|
552
|
+
const key = uniqueKey(keyOf(el));
|
|
553
|
+
return {
|
|
554
|
+
key,
|
|
555
|
+
label: labelOf(el, key),
|
|
556
|
+
state: stateOf(el, text),
|
|
557
|
+
values: valuesOf(text)
|
|
558
|
+
};
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
{ selector: CANDIDATE_SELECTOR, emptyMarkers, errorMarkers }
|
|
562
|
+
);
|
|
563
|
+
};
|
|
564
|
+
var REGION_STATES = ["ok", "empty", "error", "loading"];
|
|
565
|
+
var Region = z.object({
|
|
566
|
+
/** Stable id: data-region → data-testid → role → aria-label → heading slug. */
|
|
567
|
+
key: z.string(),
|
|
568
|
+
/** Human label for the report. */
|
|
569
|
+
label: z.string(),
|
|
570
|
+
state: z.enum(REGION_STATES),
|
|
571
|
+
/** Normalized numeric / currency / percent tokens found in the region. */
|
|
572
|
+
values: z.array(z.string())
|
|
573
|
+
});
|
|
574
|
+
var CompareSnapshot = z.object({
|
|
575
|
+
/** Route the snapshot was captured at, e.g. `/financials`. */
|
|
576
|
+
route: z.string(),
|
|
577
|
+
regions: z.array(Region)
|
|
578
|
+
}).refine(
|
|
579
|
+
(snap) => new Set(snap.regions.map((region) => region.key)).size === snap.regions.length,
|
|
580
|
+
{
|
|
581
|
+
message: "region keys must be unique"
|
|
582
|
+
}
|
|
583
|
+
);
|
|
584
|
+
var ExpectedAbsentRule = z.object({
|
|
585
|
+
region: z.string().optional(),
|
|
586
|
+
value: z.string().optional(),
|
|
587
|
+
reason: z.string().min(1)
|
|
588
|
+
}).refine((rule) => rule.region !== void 0 || rule.value !== void 0, {
|
|
589
|
+
message: "expectedAbsent rule needs at least one of `region` or `value`"
|
|
590
|
+
});
|
|
591
|
+
var CompareConfig = z.object({
|
|
592
|
+
expectedAbsent: z.array(ExpectedAbsentRule).optional(),
|
|
593
|
+
emptyStateMarkers: z.array(z.string()).optional(),
|
|
594
|
+
errorStateMarkers: z.array(z.string()).optional()
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// src/compare/snapshot.ts
|
|
598
|
+
var captureSnapshot = async (page, route, config) => ({
|
|
599
|
+
route,
|
|
600
|
+
regions: await extractRegions(page, config)
|
|
601
|
+
});
|
|
602
|
+
var saveSnapshot = async (snapshot, path) => {
|
|
603
|
+
await writeFile(path, JSON.stringify(snapshot, null, 2));
|
|
604
|
+
};
|
|
605
|
+
var loadSnapshot = async (path) => {
|
|
606
|
+
const raw = await readFile(path, "utf8");
|
|
607
|
+
return CompareSnapshot.parse(JSON.parse(raw));
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
// src/axes/compare.ts
|
|
611
|
+
var skip2 = (message) => ({
|
|
612
|
+
axis: "compare",
|
|
613
|
+
pass: true,
|
|
614
|
+
findings: [{ message, severity: "minor" }]
|
|
615
|
+
});
|
|
616
|
+
var compareAxis = {
|
|
617
|
+
name: "compare",
|
|
618
|
+
run: async (context) => {
|
|
619
|
+
const { comparison, compareConfig, page } = context;
|
|
620
|
+
if (comparison === void 0) {
|
|
621
|
+
return skip2("no comparison snapshot provided; old-vs-new not assessed");
|
|
622
|
+
}
|
|
623
|
+
if (page === void 0) {
|
|
624
|
+
return skip2("compare needs a live page to extract the new snapshot");
|
|
625
|
+
}
|
|
626
|
+
const newSnapshot = await captureSnapshot(page, comparison.route, compareConfig);
|
|
627
|
+
const findings = diffSnapshots(comparison, newSnapshot, compareConfig);
|
|
628
|
+
return { axis: "compare", pass: comparePass(findings), findings };
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
336
632
|
// src/axes/runtime-health.ts
|
|
337
633
|
var ERROR_MARKERS = [
|
|
338
634
|
"something went wrong",
|
|
@@ -512,7 +808,8 @@ var ALL_AXES = [
|
|
|
512
808
|
runtimeHealthAxis,
|
|
513
809
|
accessibilityAxis,
|
|
514
810
|
visualBrandAxis,
|
|
515
|
-
briefConformanceAxis
|
|
811
|
+
briefConformanceAxis,
|
|
812
|
+
compareAxis
|
|
516
813
|
];
|
|
517
814
|
|
|
518
815
|
// src/aggregate.ts
|
|
@@ -556,6 +853,8 @@ var validate = async (options) => {
|
|
|
556
853
|
browser,
|
|
557
854
|
brief: options.brief,
|
|
558
855
|
baseline: options.baseline,
|
|
856
|
+
comparison: options.comparison,
|
|
857
|
+
compareConfig: options.compareConfig,
|
|
559
858
|
captureUnderBrand: async (brand) => {
|
|
560
859
|
const brandContext = await browser.newContext({
|
|
561
860
|
viewport: { width: viewport.width, height: viewport.height }
|
|
@@ -581,6 +880,93 @@ var validate = async (options) => {
|
|
|
581
880
|
}
|
|
582
881
|
};
|
|
583
882
|
|
|
584
|
-
|
|
883
|
+
// src/compare/dedup.ts
|
|
884
|
+
var groupKey = (finding) => `${finding.kind}::${finding.valueSignature}`;
|
|
885
|
+
var dedupeSharedChrome = (perRoute) => {
|
|
886
|
+
const routesByGroup = /* @__PURE__ */ new Map();
|
|
887
|
+
for (const { route, findings } of perRoute) {
|
|
888
|
+
for (const finding of findings) {
|
|
889
|
+
const key = groupKey(finding);
|
|
890
|
+
const routes = routesByGroup.get(key) ?? /* @__PURE__ */ new Set();
|
|
891
|
+
routes.add(route);
|
|
892
|
+
routesByGroup.set(key, routes);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const sharedGroups = new Set(
|
|
896
|
+
[...routesByGroup].filter(([, routes]) => routes.size >= 2).map(([key]) => key)
|
|
897
|
+
);
|
|
898
|
+
const sharedChrome = [];
|
|
899
|
+
const seenShared = /* @__PURE__ */ new Set();
|
|
900
|
+
const trimmedPerRoute = perRoute.map(({ route, findings }) => ({
|
|
901
|
+
route,
|
|
902
|
+
findings: findings.filter((finding) => !sharedGroups.has(groupKey(finding)))
|
|
903
|
+
}));
|
|
904
|
+
for (const { findings } of perRoute) {
|
|
905
|
+
for (const finding of findings) {
|
|
906
|
+
const key = groupKey(finding);
|
|
907
|
+
if (!sharedGroups.has(key) || seenShared.has(key)) continue;
|
|
908
|
+
seenShared.add(key);
|
|
909
|
+
const routes = [...routesByGroup.get(key) ?? []].sort();
|
|
910
|
+
sharedChrome.push({
|
|
911
|
+
...finding,
|
|
912
|
+
message: `${finding.message} (shared across ${routes.length} routes: ${routes.join(", ")})`
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return { sharedChrome, perRoute: trimmedPerRoute };
|
|
917
|
+
};
|
|
918
|
+
var DEFAULT_TIMEOUT_MS2 = 3e4;
|
|
919
|
+
var joinUrl = (base, route) => `${base.replace(/\/$/, "")}/${route.replace(/^\//, "")}`;
|
|
920
|
+
var routeSnapshotFile = (route) => `${route.replace(/^\//, "").replace(/\/+$/, "").replace(/\//g, "_") || "index"}.json`;
|
|
921
|
+
var compareRoutes = async (options) => {
|
|
922
|
+
if (options.oldBaseUrl === void 0 === (options.oldSnapshotDir === void 0)) {
|
|
923
|
+
throw new Error("compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir");
|
|
924
|
+
}
|
|
925
|
+
const viewport = options.viewport ?? DESKTOP_VIEWPORT;
|
|
926
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
927
|
+
const browser = options.browser ?? await chromium.launch();
|
|
928
|
+
const ownsBrowser = options.browser === void 0;
|
|
929
|
+
const { oldBaseUrl, oldSnapshotDir } = options;
|
|
930
|
+
const snapshotUrl = async (url, route) => {
|
|
931
|
+
const context = await browser.newContext({
|
|
932
|
+
viewport: { width: viewport.width, height: viewport.height }
|
|
933
|
+
});
|
|
934
|
+
const page = await context.newPage();
|
|
935
|
+
try {
|
|
936
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: timeoutMs });
|
|
937
|
+
await prepareForScreenshot(page);
|
|
938
|
+
return await captureSnapshot(page, route, options.config);
|
|
939
|
+
} finally {
|
|
940
|
+
await context.close();
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
const oldSnapshotFor = (route) => {
|
|
944
|
+
if (oldBaseUrl !== void 0) return snapshotUrl(joinUrl(oldBaseUrl, route), route);
|
|
945
|
+
if (oldSnapshotDir !== void 0) {
|
|
946
|
+
return loadSnapshot(join(oldSnapshotDir, routeSnapshotFile(route)));
|
|
947
|
+
}
|
|
948
|
+
throw new Error("compareRoutes: provide exactly one of oldBaseUrl or oldSnapshotDir");
|
|
949
|
+
};
|
|
950
|
+
try {
|
|
951
|
+
const perRoute = [];
|
|
952
|
+
for (const route of options.routes) {
|
|
953
|
+
const oldSnapshot = await oldSnapshotFor(route);
|
|
954
|
+
const newSnapshot = await snapshotUrl(joinUrl(options.newBaseUrl, route), route);
|
|
955
|
+
perRoute.push({ route, findings: diffSnapshots(oldSnapshot, newSnapshot, options.config) });
|
|
956
|
+
}
|
|
957
|
+
const { sharedChrome, perRoute: trimmed } = dedupeSharedChrome(perRoute);
|
|
958
|
+
const routes = trimmed.map(({ route, findings }) => ({
|
|
959
|
+
route,
|
|
960
|
+
findings,
|
|
961
|
+
pass: comparePass(findings)
|
|
962
|
+
}));
|
|
963
|
+
const pass = comparePass(sharedChrome) && routes.every((route) => route.pass);
|
|
964
|
+
return { routes, sharedChrome, pass };
|
|
965
|
+
} finally {
|
|
966
|
+
if (ownsBrowser) await browser.close();
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
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 };
|
|
585
971
|
//# sourceMappingURL=index.js.map
|
|
586
972
|
//# sourceMappingURL=index.js.map
|