styleproof 3.1.3 → 3.1.5
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/CHANGELOG.md +59 -1
- package/README.md +263 -38
- package/bin/styleproof-init.mjs +20 -2
- package/bin/styleproof-variants.mjs +97 -0
- package/dist/action-context.d.ts +38 -0
- package/dist/action-context.js +17 -0
- package/dist/capture.d.ts +18 -0
- package/dist/capture.js +71 -0
- package/dist/components.d.ts +41 -0
- package/dist/components.js +101 -0
- package/dist/coverage.d.ts +7 -6
- package/dist/coverage.js +7 -6
- package/dist/diff.js +22 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +36 -15
- package/dist/variant-crawler.d.ts +60 -0
- package/dist/variant-crawler.js +275 -0
- package/package.json +5 -3
package/dist/capture.d.ts
CHANGED
|
@@ -69,6 +69,16 @@ export type LiveRegionCandidate = {
|
|
|
69
69
|
ariaLive?: string;
|
|
70
70
|
ariaBusy?: string;
|
|
71
71
|
};
|
|
72
|
+
export type CapturedOverlay = {
|
|
73
|
+
path: string;
|
|
74
|
+
tag: string;
|
|
75
|
+
cls: string;
|
|
76
|
+
reason: string;
|
|
77
|
+
role?: string;
|
|
78
|
+
ariaModal?: string;
|
|
79
|
+
ariaLive?: string;
|
|
80
|
+
text?: string;
|
|
81
|
+
};
|
|
72
82
|
export type StyleMap = {
|
|
73
83
|
/** Optional runner-supplied context; ignored by the certification diff. */
|
|
74
84
|
metadata?: CaptureMetadata;
|
|
@@ -98,6 +108,14 @@ export type StyleMap = {
|
|
|
98
108
|
* regions that actually keep changing are auto-excluded via `volatile`.
|
|
99
109
|
*/
|
|
100
110
|
liveCandidates?: LiveRegionCandidate[];
|
|
111
|
+
/**
|
|
112
|
+
* Visible semantic overlay roots that were present in the captured DOM and
|
|
113
|
+
* whose paths are present in `elements`. This is diagnostic/proof metadata:
|
|
114
|
+
* dialogs, menus, listboxes, modal roots and toast roots are still certified by
|
|
115
|
+
* the normal computed-style map, while this list lets tests prove those states
|
|
116
|
+
* were actually reached and captured.
|
|
117
|
+
*/
|
|
118
|
+
overlays?: CapturedOverlay[];
|
|
101
119
|
/**
|
|
102
120
|
* Colour-valued `:root` custom properties (design/theme tokens), normalised to
|
|
103
121
|
* the same `rgb(...)` form the longhands resolve to — `{ "--red-200": "rgb(254,
|
package/dist/capture.js
CHANGED
|
@@ -283,6 +283,75 @@ function detectLiveCandidates({ ignore }) {
|
|
|
283
283
|
: [];
|
|
284
284
|
});
|
|
285
285
|
}
|
|
286
|
+
function detectOverlayCandidates({ ignore }) {
|
|
287
|
+
const pathOf = window.__spPathOf;
|
|
288
|
+
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
289
|
+
const visible = (el) => {
|
|
290
|
+
if (el.hidden || el.getAttribute('aria-hidden') === 'true')
|
|
291
|
+
return false;
|
|
292
|
+
const rect = el.getBoundingClientRect();
|
|
293
|
+
const style = getComputedStyle(el);
|
|
294
|
+
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
295
|
+
};
|
|
296
|
+
const textOf = (el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
297
|
+
const toastish = (el) => {
|
|
298
|
+
const haystack = [
|
|
299
|
+
el.id,
|
|
300
|
+
el.getAttribute('class'),
|
|
301
|
+
el.getAttribute('data-testid'),
|
|
302
|
+
el.getAttribute('data-hot-toast'),
|
|
303
|
+
el.getAttribute('data-sonner-toast'),
|
|
304
|
+
el.getAttribute('data-toast'),
|
|
305
|
+
]
|
|
306
|
+
.filter(Boolean)
|
|
307
|
+
.join(' ')
|
|
308
|
+
.toLowerCase();
|
|
309
|
+
return /\b(toast|hot-toast|sonner)\b/.test(haystack);
|
|
310
|
+
};
|
|
311
|
+
const reasonsFor = (el, role, ariaModal) => {
|
|
312
|
+
const reasons = [];
|
|
313
|
+
if (el instanceof HTMLDialogElement && el.open)
|
|
314
|
+
reasons.push('dialog[open]');
|
|
315
|
+
if (el.hasAttribute('popover'))
|
|
316
|
+
reasons.push('popover');
|
|
317
|
+
if (['dialog', 'alertdialog', 'menu', 'listbox', 'tooltip'].includes(role))
|
|
318
|
+
reasons.push(`role=${role}`);
|
|
319
|
+
if (ariaModal === 'true')
|
|
320
|
+
reasons.push('aria-modal=true');
|
|
321
|
+
if (toastish(el))
|
|
322
|
+
reasons.push('toast');
|
|
323
|
+
if (el.hasAttribute('data-hot-toast'))
|
|
324
|
+
reasons.push('data-hot-toast');
|
|
325
|
+
if (el.hasAttribute('data-sonner-toast'))
|
|
326
|
+
reasons.push('data-sonner-toast');
|
|
327
|
+
if ((role === 'status' || role === 'alert') && toastish(el))
|
|
328
|
+
reasons.push(`role=${role}`);
|
|
329
|
+
return [...new Set(reasons)];
|
|
330
|
+
};
|
|
331
|
+
return [document.documentElement, document.body, ...document.querySelectorAll('body *')]
|
|
332
|
+
.filter((el) => (!skipSel || !el.matches(skipSel)) && visible(el))
|
|
333
|
+
.flatMap((el) => {
|
|
334
|
+
const role = (el.getAttribute('role') ?? '').trim().toLowerCase();
|
|
335
|
+
const ariaModal = (el.getAttribute('aria-modal') ?? '').trim().toLowerCase();
|
|
336
|
+
const ariaLive = (el.getAttribute('aria-live') ?? '').trim().toLowerCase();
|
|
337
|
+
const reasons = reasonsFor(el, role, ariaModal);
|
|
338
|
+
if (!reasons.length)
|
|
339
|
+
return [];
|
|
340
|
+
const text = textOf(el);
|
|
341
|
+
return [
|
|
342
|
+
{
|
|
343
|
+
path: pathOf(el),
|
|
344
|
+
tag: el.tagName.toLowerCase(),
|
|
345
|
+
cls: el.getAttribute('class') || '',
|
|
346
|
+
reason: reasons.join(', '),
|
|
347
|
+
...(role ? { role } : {}),
|
|
348
|
+
...(ariaModal ? { ariaModal } : {}),
|
|
349
|
+
...(ariaLive ? { ariaLive } : {}),
|
|
350
|
+
...(text ? { text } : {}),
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
});
|
|
354
|
+
}
|
|
286
355
|
/** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
|
|
287
356
|
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
288
357
|
function snapSubtree({ selector, index }) {
|
|
@@ -650,6 +719,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
650
719
|
await page.addStyleTag({ content: FREEZE_CSS });
|
|
651
720
|
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
|
|
652
721
|
dropVolatile(base.elements, volatile);
|
|
722
|
+
const overlays = (await page.evaluate(detectOverlayCandidates, { ignore })).filter((overlay) => base.elements[overlay.path]);
|
|
653
723
|
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
|
654
724
|
mergeMotion(base.elements, motion.elements);
|
|
655
725
|
let states = {};
|
|
@@ -668,6 +738,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
668
738
|
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
669
739
|
...(volatile.length ? { volatile } : {}),
|
|
670
740
|
...(liveCandidates.length ? { liveCandidates } : {}),
|
|
741
|
+
...(overlays.length ? { overlays } : {}),
|
|
671
742
|
...(Object.keys(tokens).length ? { tokens } : {}),
|
|
672
743
|
};
|
|
673
744
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Surface } from './runner.js';
|
|
2
|
+
export type DiscoveredComponent = {
|
|
3
|
+
/** Stable surface key, e.g. `component-dashboard-pr-card`. */
|
|
4
|
+
key: string;
|
|
5
|
+
/** Path relative to `cwd`, using `/` separators. */
|
|
6
|
+
path: string;
|
|
7
|
+
};
|
|
8
|
+
export type DiscoverComponentFilesOptions = {
|
|
9
|
+
/** Project root. Defaults to the current working directory. */
|
|
10
|
+
cwd?: string;
|
|
11
|
+
/** Component roots to scan, relative to `cwd` unless absolute. */
|
|
12
|
+
roots: string[];
|
|
13
|
+
/** Capture-key prefix. Defaults to `component`. */
|
|
14
|
+
prefix?: string;
|
|
15
|
+
/** File extensions to include. Defaults to common JS + framework component files. */
|
|
16
|
+
extensions?: string[];
|
|
17
|
+
/** Extra regexes matched against the cwd-relative path. */
|
|
18
|
+
ignore?: RegExp[];
|
|
19
|
+
};
|
|
20
|
+
export type ComponentCatalogSurfaceOptions = {
|
|
21
|
+
/** Map a discovered component to the app-owned catalog URL that renders it. */
|
|
22
|
+
url?: (component: DiscoveredComponent) => string;
|
|
23
|
+
/** Viewport widths to apply to every generated component surface. */
|
|
24
|
+
widths?: number[];
|
|
25
|
+
/** Viewport height to apply to every generated component surface. */
|
|
26
|
+
height?: Surface['height'];
|
|
27
|
+
/** Selectors ignored on every generated component surface. */
|
|
28
|
+
ignore?: string[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Discover component files so apps can make StyleProof coverage explicit:
|
|
32
|
+
* map these keys to a Storybook/Ladle/custom catalog route, then pass the keys
|
|
33
|
+
* to `expected`. StyleProof inventories files; the app still owns rendering
|
|
34
|
+
* because props, providers, data, portals, and framework bootstraps are app-specific.
|
|
35
|
+
*/
|
|
36
|
+
export declare function discoverComponentFiles(options: DiscoverComponentFilesOptions): DiscoveredComponent[];
|
|
37
|
+
/**
|
|
38
|
+
* Turn discovered components into StyleProof surfaces for an app-owned catalog
|
|
39
|
+
* route. Default URL: `/styleproof/components/<component.key>`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function componentCatalogSurfaces(components: DiscoveredComponent[], options?: ComponentCatalogSurfaceOptions): Surface[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const DEFAULT_EXTENSIONS = ['.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
|
4
|
+
const DEFAULT_IGNORE = [
|
|
5
|
+
/(^|\/)__tests__(\/|$)/,
|
|
6
|
+
/(^|\/)(test|tests|fixtures|mocks)(\/|$)/,
|
|
7
|
+
/(^|\/)node_modules(\/|$)/,
|
|
8
|
+
/(^|\/)(dist|build|coverage|\.next|\.nuxt|\.svelte-kit)(\/|$)/,
|
|
9
|
+
/(^|\/)index\.[^/]+$/,
|
|
10
|
+
/\.(?:test|spec|stories|story)\.[^/]+$/,
|
|
11
|
+
/\.d\.ts$/,
|
|
12
|
+
];
|
|
13
|
+
function toSlash(file) {
|
|
14
|
+
return file.split(path.sep).join('/');
|
|
15
|
+
}
|
|
16
|
+
function componentKey(root, file, prefix) {
|
|
17
|
+
const rel = toSlash(path.relative(root, file))
|
|
18
|
+
.replace(/\.[^.]+$/, '')
|
|
19
|
+
.replace(/\/index$/, '')
|
|
20
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
21
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
22
|
+
.replace(/^-+|-+$/g, '')
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
return [prefix, rel].filter(Boolean).join('-');
|
|
25
|
+
}
|
|
26
|
+
function readDir(dir) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function assertComponentRoot(root, rootInput) {
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = fs.statSync(root);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error(`StyleProof component root not found: ${rootInput}`);
|
|
41
|
+
}
|
|
42
|
+
if (!stat.isDirectory()) {
|
|
43
|
+
throw new Error(`StyleProof component root is not a directory: ${rootInput}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Discover component files so apps can make StyleProof coverage explicit:
|
|
48
|
+
* map these keys to a Storybook/Ladle/custom catalog route, then pass the keys
|
|
49
|
+
* to `expected`. StyleProof inventories files; the app still owns rendering
|
|
50
|
+
* because props, providers, data, portals, and framework bootstraps are app-specific.
|
|
51
|
+
*/
|
|
52
|
+
export function discoverComponentFiles(options) {
|
|
53
|
+
const cwd = options.cwd ?? process.cwd();
|
|
54
|
+
const prefix = options.prefix ?? 'component';
|
|
55
|
+
const extensions = new Set(options.extensions ?? DEFAULT_EXTENSIONS);
|
|
56
|
+
const ignore = [...DEFAULT_IGNORE, ...(options.ignore ?? [])];
|
|
57
|
+
const components = [];
|
|
58
|
+
for (const rootInput of options.roots) {
|
|
59
|
+
const root = path.resolve(cwd, rootInput);
|
|
60
|
+
assertComponentRoot(root, rootInput);
|
|
61
|
+
const walk = (dir) => {
|
|
62
|
+
for (const entry of readDir(dir)) {
|
|
63
|
+
const file = path.join(dir, entry.name);
|
|
64
|
+
const rel = toSlash(path.relative(cwd, file));
|
|
65
|
+
if (ignore.some((pattern) => pattern.test(rel)))
|
|
66
|
+
continue;
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
walk(file);
|
|
69
|
+
}
|
|
70
|
+
else if (entry.isFile() && extensions.has(path.extname(entry.name))) {
|
|
71
|
+
components.push({ key: componentKey(root, file, prefix), path: rel });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(root);
|
|
76
|
+
}
|
|
77
|
+
const seen = new Map();
|
|
78
|
+
for (const component of components) {
|
|
79
|
+
const previous = seen.get(component.key);
|
|
80
|
+
if (previous) {
|
|
81
|
+
throw new Error(`StyleProof component key collision: ${component.key} from ${previous} and ${component.path}`);
|
|
82
|
+
}
|
|
83
|
+
seen.set(component.key, component.path);
|
|
84
|
+
}
|
|
85
|
+
return components.sort((a, b) => a.key.localeCompare(b.key));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Turn discovered components into StyleProof surfaces for an app-owned catalog
|
|
89
|
+
* route. Default URL: `/styleproof/components/<component.key>`.
|
|
90
|
+
*/
|
|
91
|
+
export function componentCatalogSurfaces(components, options = {}) {
|
|
92
|
+
return components.map((component) => ({
|
|
93
|
+
key: component.key,
|
|
94
|
+
go: async (page) => {
|
|
95
|
+
await page.goto(options.url?.(component) ?? `/styleproof/components/${component.key}`);
|
|
96
|
+
},
|
|
97
|
+
...(options.widths ? { widths: options.widths } : {}),
|
|
98
|
+
...(options.height ? { height: options.height } : {}),
|
|
99
|
+
...(options.ignore ? { ignore: options.ignore } : {}),
|
|
100
|
+
}));
|
|
101
|
+
}
|
package/dist/coverage.d.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* This is the one failure StyleProof can't catch from the captures alone, because
|
|
9
9
|
* it's about a capture that was never taken.
|
|
10
10
|
*
|
|
11
|
-
* `expected` closes the hole: a spec declares its full route/
|
|
12
|
-
* (e.g. an app's
|
|
13
|
-
* from what's actually captured — turning a silent coverage hole
|
|
14
|
-
* in the app's own suite, the moment the route is added.
|
|
11
|
+
* `expected` closes the hole: a spec declares its full route/view/state universe
|
|
12
|
+
* (e.g. an app's route + overlay-flow registry), and the guard fails when that
|
|
13
|
+
* universe drifts from what's actually captured — turning a silent coverage hole
|
|
14
|
+
* into a red test, in the app's own suite, the moment the route or flow is added.
|
|
15
15
|
*/
|
|
16
16
|
export type CoverageGaps = {
|
|
17
17
|
/** Expected surfaces that are neither captured nor explicitly excluded. */
|
|
@@ -25,8 +25,9 @@ export type CoverageGaps = {
|
|
|
25
25
|
*
|
|
26
26
|
* A surface is covered if it's captured OR listed in `exclude` (a deliberate,
|
|
27
27
|
* documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
|
|
28
|
-
* allowed:
|
|
29
|
-
* `landing-nav-open`
|
|
28
|
+
* allowed: a project may start by requiring only route keys, then tighten the
|
|
29
|
+
* universe with explicit state keys such as `landing-nav-open` or
|
|
30
|
+
* `dashboard-dialog-open`.
|
|
30
31
|
*
|
|
31
32
|
* Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
|
|
32
33
|
* it in a Playwright test that runs in the normal suite (not gated on a capture
|
package/dist/coverage.js
CHANGED
|
@@ -8,18 +8,19 @@
|
|
|
8
8
|
* This is the one failure StyleProof can't catch from the captures alone, because
|
|
9
9
|
* it's about a capture that was never taken.
|
|
10
10
|
*
|
|
11
|
-
* `expected` closes the hole: a spec declares its full route/
|
|
12
|
-
* (e.g. an app's
|
|
13
|
-
* from what's actually captured — turning a silent coverage hole
|
|
14
|
-
* in the app's own suite, the moment the route is added.
|
|
11
|
+
* `expected` closes the hole: a spec declares its full route/view/state universe
|
|
12
|
+
* (e.g. an app's route + overlay-flow registry), and the guard fails when that
|
|
13
|
+
* universe drifts from what's actually captured — turning a silent coverage hole
|
|
14
|
+
* into a red test, in the app's own suite, the moment the route or flow is added.
|
|
15
15
|
*/
|
|
16
16
|
/**
|
|
17
17
|
* Compare the captured surface keys against a declared `expected` universe.
|
|
18
18
|
*
|
|
19
19
|
* A surface is covered if it's captured OR listed in `exclude` (a deliberate,
|
|
20
20
|
* documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
|
|
21
|
-
* allowed:
|
|
22
|
-
* `landing-nav-open`
|
|
21
|
+
* allowed: a project may start by requiring only route keys, then tighten the
|
|
22
|
+
* universe with explicit state keys such as `landing-nav-open` or
|
|
23
|
+
* `dashboard-dialog-open`.
|
|
23
24
|
*
|
|
24
25
|
* Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
|
|
25
26
|
* it in a Playwright test that runs in the normal suite (not gated on a capture
|
package/dist/diff.js
CHANGED
|
@@ -19,6 +19,8 @@ const LAYOUT_EQUIVALENT_MARGIN_PROPS = new Set([
|
|
|
19
19
|
'margin-inline-start',
|
|
20
20
|
'margin-inline-end',
|
|
21
21
|
]);
|
|
22
|
+
const SUBPIXEL_ORIGIN_PROPS = new Set(['perspective-origin', 'transform-origin']);
|
|
23
|
+
const ORIGIN_EPSILON_PX = 0.05;
|
|
22
24
|
function sameRect(a, b) {
|
|
23
25
|
return !!a && !!b && a.every((v, i) => v === b[i]);
|
|
24
26
|
}
|
|
@@ -27,6 +29,24 @@ function dropLayoutEquivalentMarginProps(props, a, b) {
|
|
|
27
29
|
return props;
|
|
28
30
|
return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
|
|
29
31
|
}
|
|
32
|
+
function pxParts(value) {
|
|
33
|
+
const parts = value.trim().split(/\s+/);
|
|
34
|
+
if (parts.length < 2 || parts.length > 3)
|
|
35
|
+
return null;
|
|
36
|
+
const values = parts.map((part) => {
|
|
37
|
+
const match = /^(-?\d+(?:\.\d+)?)px$/.exec(part);
|
|
38
|
+
return match ? Number(match[1]) : Number.NaN;
|
|
39
|
+
});
|
|
40
|
+
return values.every(Number.isFinite) ? values : null;
|
|
41
|
+
}
|
|
42
|
+
function sameSubpixelOrigin(before, after) {
|
|
43
|
+
const a = pxParts(before);
|
|
44
|
+
const b = pxParts(after);
|
|
45
|
+
return !!a && !!b && a.length === b.length && a.every((v, i) => Math.abs(v - b[i]) <= ORIGIN_EPSILON_PX);
|
|
46
|
+
}
|
|
47
|
+
function dropSubpixelOriginProps(props) {
|
|
48
|
+
return props.filter((p) => !SUBPIXEL_ORIGIN_PROPS.has(p.prop) || !sameSubpixelOrigin(p.before, p.after));
|
|
49
|
+
}
|
|
30
50
|
/** Union of both captures' live-region paths — skipped by every diff layer so a
|
|
31
51
|
* region volatile on either side never reads as a change. */
|
|
32
52
|
function volatilePaths(a, b) {
|
|
@@ -95,7 +115,7 @@ export function diffStyleMaps(a, b) {
|
|
|
95
115
|
const pdefsA = pseudo ? (a.defaults[ea.tag + pseudo] ?? defsA) : defsA;
|
|
96
116
|
const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
|
|
97
117
|
const rawProps = diffProps(propsA, propsB, pdefsA, pdefsB, '(unset)', '(unset)');
|
|
98
|
-
const props = pseudo ? rawProps : dropLayoutEquivalentMarginProps(rawProps, ea, eb);
|
|
118
|
+
const props = dropSubpixelOriginProps(pseudo ? rawProps : dropLayoutEquivalentMarginProps(rawProps, ea, eb));
|
|
99
119
|
if (props.length)
|
|
100
120
|
findings.push({ kind: 'style', path: p, cls: ea.cls, pseudo, props });
|
|
101
121
|
}
|
|
@@ -131,7 +151,7 @@ export function diffStyleMaps(a, b) {
|
|
|
131
151
|
const db = sb[state] ?? {};
|
|
132
152
|
for (const sub of new Set([...Object.keys(da), ...Object.keys(db)])) {
|
|
133
153
|
const props = diffProps(da[sub] ?? {}, db[sub] ?? {}, {}, {}, '(state does not change it)', '(state no longer changes it)');
|
|
134
|
-
const filtered = dropLayoutEquivalentMarginProps(props, a.elements[sub], b.elements[sub]);
|
|
154
|
+
const filtered = dropSubpixelOriginProps(dropLayoutEquivalentMarginProps(props, a.elements[sub], b.elements[sub]));
|
|
135
155
|
if (filtered.length)
|
|
136
156
|
findings.push({ kind: 'state', path: p, cls, state, sub, props: filtered });
|
|
137
157
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
-
export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, Rect } from './capture.js';
|
|
2
|
+
export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
|
|
3
3
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
4
4
|
export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
|
|
5
5
|
export { coverageGaps } from './coverage.js';
|
|
@@ -7,8 +7,12 @@ export type { CoverageGaps } from './coverage.js';
|
|
|
7
7
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
8
8
|
export { discoverNextRoutes } from './routes.js';
|
|
9
9
|
export type { DiscoveredRoute } from './routes.js';
|
|
10
|
+
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
11
|
+
export type { DiscoveredComponent, DiscoverComponentFilesOptions, ComponentCatalogSurfaceOptions, } from './components.js';
|
|
10
12
|
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
11
13
|
export type { CrawlLink, LinkMatch, SelectLinksOptions } from './crawl.js';
|
|
14
|
+
export { harvestStyleVariants } from './variant-crawler.js';
|
|
15
|
+
export type { HarvestAction, HarvestedLiveState, HarvestedRoute, HarvestedVariant, HarvestRoute, HarvestSkip, VariantHarvest, VariantHarvestOptions, } from './variant-crawler.js';
|
|
12
16
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
13
17
|
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
14
18
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,8 @@ export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
|
3
3
|
export { coverageGaps } from './coverage.js';
|
|
4
4
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
5
5
|
export { discoverNextRoutes } from './routes.js';
|
|
6
|
+
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
6
7
|
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
8
|
+
export { harvestStyleVariants } from './variant-crawler.js';
|
|
7
9
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
8
10
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/runner.d.ts
CHANGED
|
@@ -80,8 +80,10 @@ export type PopupCaptureOptions = {
|
|
|
80
80
|
export type DefineOptions = {
|
|
81
81
|
surfaces: Surface[];
|
|
82
82
|
/**
|
|
83
|
-
* The full set of surface keys the app knows it has — its route/view
|
|
84
|
-
* typically derived from
|
|
83
|
+
* The full set of surface keys the app knows it has — its route/view/state
|
|
84
|
+
* universe, typically derived from registries (e.g. routes plus modal/menu
|
|
85
|
+
* flows). Include expanded variant keys such as `dashboard-dialog-open` when
|
|
86
|
+
* those states must be certified.
|
|
85
87
|
* When set, StyleProof emits a coverage-guard test (in the NORMAL suite, not
|
|
86
88
|
* gated on a capture dir) that fails if any expected key is neither captured (a
|
|
87
89
|
* surface) nor in `exclude`. This is what stops a newly added route from
|
package/dist/runner.js
CHANGED
|
@@ -68,11 +68,19 @@ const DEFAULT_POPUP_TRIGGERS = [
|
|
|
68
68
|
const DEFAULT_POPUP_OVERLAYS = [
|
|
69
69
|
'dialog[open]',
|
|
70
70
|
'[popover]',
|
|
71
|
+
'[aria-modal="true"]',
|
|
71
72
|
'[role="dialog"]',
|
|
72
73
|
'[role="alertdialog"]',
|
|
73
74
|
'[role="menu"]',
|
|
74
75
|
'[role="listbox"]',
|
|
75
76
|
'[role="tooltip"]',
|
|
77
|
+
'[data-hot-toast]',
|
|
78
|
+
'[data-sonner-toast]',
|
|
79
|
+
'[data-toast]',
|
|
80
|
+
'.hot-toast',
|
|
81
|
+
'[class*="toast" i]',
|
|
82
|
+
'[role="alert"]',
|
|
83
|
+
'[role="status"]',
|
|
76
84
|
'[data-state="open"]:not(button):not(a):not(summary)',
|
|
77
85
|
].join(', ');
|
|
78
86
|
export function resolvePopupCaptureOptions(input) {
|
|
@@ -124,8 +132,22 @@ async function popupDomSnapshot(page, options) {
|
|
|
124
132
|
}
|
|
125
133
|
return parts.join(' > ');
|
|
126
134
|
};
|
|
135
|
+
const popupKey = (el) => {
|
|
136
|
+
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
137
|
+
return [
|
|
138
|
+
pathOf(el),
|
|
139
|
+
el.getAttribute('role') ?? '',
|
|
140
|
+
el.getAttribute('aria-modal') ?? '',
|
|
141
|
+
el.getAttribute('aria-live') ?? '',
|
|
142
|
+
el.getAttribute('data-state') ?? '',
|
|
143
|
+
el.getAttribute('data-hot-toast') ?? '',
|
|
144
|
+
el.getAttribute('data-sonner-toast') ?? '',
|
|
145
|
+
el.getAttribute('data-toast') ?? '',
|
|
146
|
+
text,
|
|
147
|
+
].join('|');
|
|
148
|
+
};
|
|
127
149
|
const popups = qsa(popupSelector).filter(visible);
|
|
128
|
-
const keys = popups.map(
|
|
150
|
+
const keys = popups.map(popupKey);
|
|
129
151
|
if (!triggerSelector || !attr)
|
|
130
152
|
return { keys, indexes: [] };
|
|
131
153
|
const safeTrigger = (el) => {
|
|
@@ -175,16 +197,17 @@ function expandOne(surface, variant, variantKind) {
|
|
|
175
197
|
export function expandSurfaceVariants(surface) {
|
|
176
198
|
const variants = surface.variants ?? [];
|
|
177
199
|
const liveStates = surface.liveStates ?? [];
|
|
200
|
+
const { variants: _variants, liveStates: _liveStates, ...base } = surface;
|
|
201
|
+
const baseSurface = { ...base, metadata: { surfaceKey: surface.key } };
|
|
202
|
+
void _variants;
|
|
203
|
+
void _liveStates;
|
|
178
204
|
if (!variants.length && !liveStates.length) {
|
|
179
|
-
|
|
180
|
-
void _variants;
|
|
181
|
-
void _liveStates;
|
|
182
|
-
return [{ ...base, metadata: { surfaceKey: surface.key } }];
|
|
205
|
+
return [baseSurface];
|
|
183
206
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
];
|
|
207
|
+
const expandedVariants = variants.map((variant) => expandOne(surface, variant, 'variant'));
|
|
208
|
+
if (!liveStates.length)
|
|
209
|
+
return [baseSurface, ...expandedVariants];
|
|
210
|
+
return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
|
|
188
211
|
}
|
|
189
212
|
/**
|
|
190
213
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
@@ -302,13 +325,12 @@ async function assertPopupDeterministic(page, surface, width, height, options, c
|
|
|
302
325
|
throw new Error(selfCheckErrorMessage(`${surface.key}-${popupId}`, drift, first.volatile, first.liveCandidates));
|
|
303
326
|
}
|
|
304
327
|
}
|
|
305
|
-
async function capturePopupCandidate(page, surface, width, height, s, options, candidate
|
|
328
|
+
async function capturePopupCandidate(page, surface, width, height, s, options, candidate) {
|
|
306
329
|
const requests = trackInflightRequests(page);
|
|
307
330
|
try {
|
|
308
331
|
const popupKey = await openPopupCandidate(page, surface, width, height, options, candidate);
|
|
309
|
-
if (!popupKey
|
|
332
|
+
if (!popupKey)
|
|
310
333
|
return;
|
|
311
|
-
seen.add(popupKey);
|
|
312
334
|
const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
|
|
313
335
|
const map = await captureOpenedPopupMap(page, surface, s, requests.pending, popupId);
|
|
314
336
|
if (s.selfCheck)
|
|
@@ -328,9 +350,8 @@ async function capturePopupSurfaces(page, surface, width, height, s) {
|
|
|
328
350
|
return;
|
|
329
351
|
await surface.go(page);
|
|
330
352
|
const candidates = await markPopupCandidates(page, options);
|
|
331
|
-
const seen = new Set();
|
|
332
353
|
for (const candidate of candidates) {
|
|
333
|
-
await capturePopupCandidate(page, surface, width, height, s, options, candidate
|
|
354
|
+
await capturePopupCandidate(page, surface, width, height, s, options, candidate);
|
|
334
355
|
}
|
|
335
356
|
}
|
|
336
357
|
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
|
@@ -441,7 +462,7 @@ export function defineStyleMapCapture(options) {
|
|
|
441
462
|
if (expected) {
|
|
442
463
|
test.describe('styleproof coverage', () => {
|
|
443
464
|
test('every expected surface is captured or explicitly excluded', () => {
|
|
444
|
-
const { uncovered, staleExclusions } = coverageGaps(
|
|
465
|
+
const { uncovered, staleExclusions } = coverageGaps(captureSurfaces.map((s) => s.key), expected, exclude);
|
|
445
466
|
expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
|
|
446
467
|
`nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
|
|
447
468
|
`Missing: ${uncovered.join(', ')}`).toEqual([]);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import { type CaptureOptions } from './capture.js';
|
|
3
|
+
export type HarvestRoute = {
|
|
4
|
+
/** Stable route/surface key in the generated manifest. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** Absolute URL or path resolved against `baseUrl`. */
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
export type HarvestAction = 'click' | 'select-option' | 'submit-empty';
|
|
10
|
+
export type HarvestedVariant = {
|
|
11
|
+
key: string;
|
|
12
|
+
action: HarvestAction;
|
|
13
|
+
selector: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
label: string;
|
|
16
|
+
findings: number;
|
|
17
|
+
diffHash: string;
|
|
18
|
+
value?: string;
|
|
19
|
+
};
|
|
20
|
+
export type HarvestedLiveState = {
|
|
21
|
+
key: string;
|
|
22
|
+
selector: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
label: string;
|
|
25
|
+
fixtureRequired: true;
|
|
26
|
+
role?: string;
|
|
27
|
+
ariaLive?: string;
|
|
28
|
+
ariaBusy?: string;
|
|
29
|
+
};
|
|
30
|
+
export type HarvestSkip = {
|
|
31
|
+
reason: 'unsafe-label' | 'navigated' | 'action-failed';
|
|
32
|
+
selector: string;
|
|
33
|
+
label: string;
|
|
34
|
+
detail?: string;
|
|
35
|
+
};
|
|
36
|
+
export type HarvestedRoute = {
|
|
37
|
+
key: string;
|
|
38
|
+
url: string;
|
|
39
|
+
variants: HarvestedVariant[];
|
|
40
|
+
liveStates: HarvestedLiveState[];
|
|
41
|
+
skipped: HarvestSkip[];
|
|
42
|
+
};
|
|
43
|
+
export type VariantHarvest = {
|
|
44
|
+
routes: HarvestedRoute[];
|
|
45
|
+
};
|
|
46
|
+
export type VariantHarvestOptions = {
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
routes: HarvestRoute[];
|
|
49
|
+
/** Max attempted actions per route. Default 40. */
|
|
50
|
+
maxActionsPerRoute?: number;
|
|
51
|
+
/** Extra selectors to skip during capture. */
|
|
52
|
+
ignore?: string[];
|
|
53
|
+
/** Forwarded to the cheap discovery captures; forced states stay off here. */
|
|
54
|
+
stabilize?: CaptureOptions['stabilize'];
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Discover one-step UI states by trying semantic controls and keeping only
|
|
58
|
+
* actions whose rendered computed-style map differs from the route baseline.
|
|
59
|
+
*/
|
|
60
|
+
export declare function harvestStyleVariants(page: Page, options: VariantHarvestOptions): Promise<VariantHarvest>;
|