styleproof 3.18.0 → 3.19.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/CHANGELOG.md +114 -0
- package/README.md +42 -1
- package/bin/styleproof-capture.mjs +4 -1
- package/bin/styleproof-diff.mjs +41 -32
- package/bin/styleproof-map.mjs +8 -0
- package/dist/affected-surfaces.d.ts +24 -0
- package/dist/affected-surfaces.js +115 -17
- package/dist/capture-url.d.ts +1 -1
- package/dist/capture.js +40 -26
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.js +54 -0
- package/dist/crawl-surfaces.js +14 -5
- package/dist/crawl.d.ts +16 -1
- package/dist/crawl.js +55 -7
- package/dist/danger.d.ts +13 -0
- package/dist/danger.js +13 -0
- package/dist/diff.js +3 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/inventory.js +9 -3
- package/dist/map-store.d.ts +5 -1
- package/dist/map-store.js +10 -3
- package/dist/report.js +23 -6
- package/dist/runner.d.ts +17 -0
- package/dist/runner.js +50 -6
- package/dist/variant-crawler.js +16 -4
- package/package.json +1 -1
package/dist/coverage.js
CHANGED
|
@@ -34,6 +34,60 @@ export function coverageGaps(capturedKeys, expected, exclude = {}) {
|
|
|
34
34
|
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
|
|
35
35
|
return { uncovered, staleExclusions };
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Translate captured surface keys into the DECLARED keys they satisfy for coverage.
|
|
39
|
+
*
|
|
40
|
+
* `expected` is stated in base surface keys (`home`), but a surface with `liveStates`
|
|
41
|
+
* is captured ONLY as its split expansions (`home-loading`, `home-loaded`) — the bare
|
|
42
|
+
* base key is dropped by design (the base live state is fuzzy). Comparing the expanded
|
|
43
|
+
* keys literally against `expected` would report the declared `home` as uncovered on a
|
|
44
|
+
* fully-captured app. Each expansion still carries its originating `surfaceKey`, so the
|
|
45
|
+
* declared base key is exactly recoverable: a capture satisfies its own key AND its
|
|
46
|
+
* `surfaceKey`. This is precise (it maps only real expansions back to their real base),
|
|
47
|
+
* not a suffix heuristic — an unrelated `home-banner` never satisfies an uncaptured `home`.
|
|
48
|
+
*/
|
|
49
|
+
export function coverageKeys(captured) {
|
|
50
|
+
const keys = new Set();
|
|
51
|
+
for (const c of captured) {
|
|
52
|
+
keys.add(c.key);
|
|
53
|
+
if (c.metadata?.surfaceKey)
|
|
54
|
+
keys.add(c.metadata.surfaceKey);
|
|
55
|
+
}
|
|
56
|
+
return [...keys];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rewrite a declared `expected` universe (base keys) into the keys that are actually
|
|
60
|
+
* captured to disk, so the GATE — which reads expanded map filenames (`home-loading`)
|
|
61
|
+
* and can't see each capture's `surfaceKey` metadata — can compare literally.
|
|
62
|
+
*
|
|
63
|
+
* A declared key `K` is replaced by its captured liveState expansions when `K` is NOT
|
|
64
|
+
* itself a captured key but expansions carrying `surfaceKey === K` exist. A directly
|
|
65
|
+
* captured `K` is kept; a genuinely uncaptured `K` is kept verbatim so the gate still
|
|
66
|
+
* flags it. This is the write-time half of {@link coverageKeys}: the ledger travels
|
|
67
|
+
* pre-translated, so `auditCoverage` needs no metadata at gate time.
|
|
68
|
+
*/
|
|
69
|
+
export function translateExpected(expected, captured) {
|
|
70
|
+
const capturedKeys = new Set();
|
|
71
|
+
const expansionsByBase = new Map();
|
|
72
|
+
for (const c of captured) {
|
|
73
|
+
capturedKeys.add(c.key);
|
|
74
|
+
const base = c.metadata?.surfaceKey;
|
|
75
|
+
if (base && base !== c.key) {
|
|
76
|
+
const list = expansionsByBase.get(base) ?? [];
|
|
77
|
+
list.push(c.key);
|
|
78
|
+
expansionsByBase.set(base, list);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const out = new Set();
|
|
82
|
+
for (const k of expected) {
|
|
83
|
+
if (capturedKeys.has(k) || !expansionsByBase.has(k))
|
|
84
|
+
out.add(k);
|
|
85
|
+
else
|
|
86
|
+
for (const exp of expansionsByBase.get(k))
|
|
87
|
+
out.add(exp);
|
|
88
|
+
}
|
|
89
|
+
return [...out];
|
|
90
|
+
}
|
|
37
91
|
// ── coverage provenance (the gate-level completeness assertion) ──────────────────
|
|
38
92
|
// The guard above runs in the app's SUITE. That fails a build when the spec forgets a
|
|
39
93
|
// route, but the GATE (styleproof-diff, reading captured maps) never learns whether
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
5
|
import { detectViewportWidths } from './breakpoints.js';
|
|
6
|
+
import { DANGER_SOURCE } from './danger.js';
|
|
6
7
|
// Exhaustive by default — these ceilings are safety backstops, not budgets.
|
|
7
8
|
export const CRAWL_DEFAULTS = {
|
|
8
9
|
height: 900,
|
|
@@ -44,11 +45,14 @@ function deriveKey(steps, used) {
|
|
|
44
45
|
used.add(key);
|
|
45
46
|
return key;
|
|
46
47
|
}
|
|
47
|
-
/** Runs in the browser: every visible, enabled, non-navigating control worth trying.
|
|
48
|
+
/** Runs in the browser: every visible, enabled, non-navigating control worth trying.
|
|
49
|
+
* `dangerSource` is the shared destructive-label pattern (see {@link DANGER_SOURCE}),
|
|
50
|
+
* passed in because this function is serialized into the browser and can't close over
|
|
51
|
+
* a Node `RegExp`. */
|
|
48
52
|
/* c8 ignore start */ // fallow-ignore-next-line complexity
|
|
49
|
-
function collectClickable() {
|
|
53
|
+
function collectClickable(dangerSource) {
|
|
50
54
|
const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
|
|
51
|
-
const DANGER =
|
|
55
|
+
const DANGER = new RegExp(dangerSource, 'i');
|
|
52
56
|
const esc = (v) => CSS.escape(v);
|
|
53
57
|
const quote = (v) => JSON.stringify(v);
|
|
54
58
|
const visible = (el) => {
|
|
@@ -81,7 +85,12 @@ function collectClickable() {
|
|
|
81
85
|
}
|
|
82
86
|
return pathSelector(el);
|
|
83
87
|
};
|
|
84
|
-
const labelFor = (el) =>
|
|
88
|
+
const labelFor = (el) =>
|
|
89
|
+
// `title` is included so an icon-only control (no text, no aria-label) that
|
|
90
|
+
// announces itself via a native tooltip — `<button title="Delete">🗑</button>` —
|
|
91
|
+
// still yields a meaningful label. Without it such a button labels as "button"
|
|
92
|
+
// and slips past the destructive guard below, which mapping must never click.
|
|
93
|
+
(el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || el.getAttribute('title') || '')
|
|
85
94
|
.replace(/\s+/g, ' ')
|
|
86
95
|
.trim()
|
|
87
96
|
.slice(0, 80) || el.tagName.toLowerCase();
|
|
@@ -684,7 +693,7 @@ function sweepWorkList(entry, all, opts, st, freshOnly = false, excludeIds = new
|
|
|
684
693
|
* in place — reaching that surface via a forward click is reliable, so its deep
|
|
685
694
|
* descendants are captured on the first visit instead of via a later reset. */
|
|
686
695
|
async function sweepCandidatesHere(page, opts, entry, st, sink, freshOnly = false, excludeIds = new Set()) {
|
|
687
|
-
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
696
|
+
const all = await page.evaluate(collectClickable, DANGER_SOURCE).catch(() => []);
|
|
688
697
|
const work = sweepWorkList(entry, all, opts, st, freshOnly, excludeIds);
|
|
689
698
|
// Controls present HERE — passed to each child's in-place descent as its
|
|
690
699
|
// exclude set, so the descent skips this surface's mode-switchers/chrome and
|
package/dist/crawl.d.ts
CHANGED
|
@@ -49,6 +49,12 @@ export type SelectLinksOptions = {
|
|
|
49
49
|
* multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
|
|
50
50
|
* dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
|
|
51
51
|
* project needs a different scheme.
|
|
52
|
+
*
|
|
53
|
+
* Params are sorted by name before their values are joined, so the SAME logical
|
|
54
|
+
* route keys identically regardless of the order the nav happened to render its
|
|
55
|
+
* query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the
|
|
56
|
+
* key flaps with render order and the coverage guard reports phantom
|
|
57
|
+
* nav-regressions / unowned routes for a route that never changed.
|
|
52
58
|
*/
|
|
53
59
|
export declare function defaultLinkKey(url: URL): string;
|
|
54
60
|
/**
|
|
@@ -56,8 +62,17 @@ export declare function defaultLinkKey(url: URL): string;
|
|
|
56
62
|
*
|
|
57
63
|
* Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
|
|
58
64
|
* same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
|
|
59
|
-
* survivors are deduped by path+query
|
|
65
|
+
* survivors are deduped by path+query (trailing slash normalized — `/about` and
|
|
66
|
+
* `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
|
|
60
67
|
* the capture order is the nav's order — stable across runs.
|
|
68
|
+
*
|
|
69
|
+
* Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
|
|
70
|
+
* collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
|
|
71
|
+
* would otherwise both write `<key>@<width>.json.gz` and the second would silently
|
|
72
|
+
* overwrite the first — a captured surface vanishing without a trace. Instead the
|
|
73
|
+
* second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
|
|
74
|
+
* survive as distinct maps. Trailing-slash duplicates never reach here — they're
|
|
75
|
+
* already deduped to one surface above — so this only fires on real collisions.
|
|
61
76
|
*/
|
|
62
77
|
export declare function selectCrawlLinks(hrefs: Iterable<string | null | undefined>, opts: SelectLinksOptions): CrawlLink[];
|
|
63
78
|
/**
|
package/dist/crawl.js
CHANGED
|
@@ -23,10 +23,19 @@
|
|
|
23
23
|
* multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
|
|
24
24
|
* dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
|
|
25
25
|
* project needs a different scheme.
|
|
26
|
+
*
|
|
27
|
+
* Params are sorted by name before their values are joined, so the SAME logical
|
|
28
|
+
* route keys identically regardless of the order the nav happened to render its
|
|
29
|
+
* query string (`/?tab=a&x=b` and `/?x=b&tab=a` both → `a-b`). Without this the
|
|
30
|
+
* key flaps with render order and the coverage guard reports phantom
|
|
31
|
+
* nav-regressions / unowned routes for a route that never changed.
|
|
26
32
|
*/
|
|
27
33
|
export function defaultLinkKey(url) {
|
|
28
34
|
const segs = url.pathname.split('/').filter(Boolean);
|
|
29
|
-
const values = [...url.searchParams]
|
|
35
|
+
const values = [...url.searchParams]
|
|
36
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
37
|
+
.map(([, v]) => v)
|
|
38
|
+
.filter(Boolean);
|
|
30
39
|
const slug = [...segs, ...values]
|
|
31
40
|
.join('-')
|
|
32
41
|
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
@@ -70,30 +79,69 @@ function toLink(href, base, keyFor, match) {
|
|
|
70
79
|
url.hash = ''; // navigate the surface, not a scroll anchor within it
|
|
71
80
|
return { key: keyFor(url), url: path };
|
|
72
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Dedup identity for a navigable path+query: a trailing slash on the path is not a
|
|
84
|
+
* distinct surface (`/about` and `/about/` render the same route), so it's stripped
|
|
85
|
+
* from the identity — but never from the root `/` itself, and never from the query.
|
|
86
|
+
* The navigable url the caller returns keeps its original form; only the SET
|
|
87
|
+
* membership test is normalized, so the first-seen href still wins.
|
|
88
|
+
*/
|
|
89
|
+
function dedupIdentity(pathAndSearch) {
|
|
90
|
+
const q = pathAndSearch.indexOf('?');
|
|
91
|
+
const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
|
|
92
|
+
const search = q === -1 ? '' : pathAndSearch.slice(q);
|
|
93
|
+
const normPath = path.length > 1 ? path.replace(/\/+$/, '') || '/' : path;
|
|
94
|
+
return normPath + search;
|
|
95
|
+
}
|
|
73
96
|
/**
|
|
74
97
|
* Turn a page's raw `<a href>` values into a deduped, keyed surface list.
|
|
75
98
|
*
|
|
76
99
|
* Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
|
|
77
100
|
* same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
|
|
78
|
-
* survivors are deduped by path+query
|
|
101
|
+
* survivors are deduped by path+query (trailing slash normalized — `/about` and
|
|
102
|
+
* `/about/` are one surface, not two). Order follows first appearance in `hrefs`, so
|
|
79
103
|
* the capture order is the nav's order — stable across runs.
|
|
104
|
+
*
|
|
105
|
+
* Keys are then disambiguated: two GENUINELY different surfaces whose derived keys
|
|
106
|
+
* collide (e.g. `/a?tab=x` and `/b?tab=x` both → `x` under {@link defaultLinkKey})
|
|
107
|
+
* would otherwise both write `<key>@<width>.json.gz` and the second would silently
|
|
108
|
+
* overwrite the first — a captured surface vanishing without a trace. Instead the
|
|
109
|
+
* second gets a `-2` suffix (mirroring the surface crawler's `deriveKey`), so both
|
|
110
|
+
* survive as distinct maps. Trailing-slash duplicates never reach here — they're
|
|
111
|
+
* already deduped to one surface above — so this only fires on real collisions.
|
|
80
112
|
*/
|
|
81
113
|
export function selectCrawlLinks(hrefs, opts) {
|
|
82
114
|
const base = new URL(opts.base);
|
|
83
115
|
const keyFor = opts.key ?? defaultLinkKey;
|
|
84
116
|
const seen = new Set();
|
|
117
|
+
const usedKeys = new Set();
|
|
85
118
|
const out = [];
|
|
119
|
+
// Disambiguate a key against those already emitted, mirroring deriveKey: first
|
|
120
|
+
// wins bare, the next collider gets `-2`, `-3`, … — deterministic in nav order.
|
|
121
|
+
const uniqueKey = (key) => {
|
|
122
|
+
let k = key;
|
|
123
|
+
for (let i = 2; usedKeys.has(k); i++)
|
|
124
|
+
k = `${key}-${i}`;
|
|
125
|
+
usedKeys.add(k);
|
|
126
|
+
return k;
|
|
127
|
+
};
|
|
128
|
+
const push = (link) => {
|
|
129
|
+
out.push({ key: uniqueKey(link.key), url: link.url });
|
|
130
|
+
};
|
|
86
131
|
if (opts.includeSelf) {
|
|
87
132
|
const selfUrl = base.pathname + base.search;
|
|
88
|
-
seen.add(selfUrl);
|
|
89
|
-
|
|
133
|
+
seen.add(dedupIdentity(selfUrl));
|
|
134
|
+
push({ key: keyFor(base), url: selfUrl });
|
|
90
135
|
}
|
|
91
136
|
for (const href of hrefs) {
|
|
92
137
|
const link = href ? toLink(href, base, keyFor, opts.match) : null;
|
|
93
|
-
if (!link
|
|
138
|
+
if (!link)
|
|
139
|
+
continue;
|
|
140
|
+
const id = dedupIdentity(link.url);
|
|
141
|
+
if (seen.has(id))
|
|
94
142
|
continue;
|
|
95
|
-
seen.add(
|
|
96
|
-
|
|
143
|
+
seen.add(id);
|
|
144
|
+
push(link);
|
|
97
145
|
}
|
|
98
146
|
return out;
|
|
99
147
|
}
|
package/dist/danger.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE destructive-action guard, shared by every surface-discovery crawler
|
|
3
|
+
* (the exhaustive surface crawler in `crawl-surfaces.ts` and the one-step variant
|
|
4
|
+
* harvester in `variant-crawler.ts`). Mapping must never mutate: a control whose
|
|
5
|
+
* label matches this pattern is recorded but never clicked.
|
|
6
|
+
*
|
|
7
|
+
* Kept as a plain string (not a `RegExp`) because both crawlers build their
|
|
8
|
+
* candidate list inside `page.evaluate` — the classifier function is serialized
|
|
9
|
+
* into the browser, so it cannot close over a `RegExp` from Node. The source is
|
|
10
|
+
* passed in as an argument and recompiled in the browser; this module is the
|
|
11
|
+
* single source of truth for what "destructive" means.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DANGER_SOURCE = "\\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\\b";
|
package/dist/danger.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE destructive-action guard, shared by every surface-discovery crawler
|
|
3
|
+
* (the exhaustive surface crawler in `crawl-surfaces.ts` and the one-step variant
|
|
4
|
+
* harvester in `variant-crawler.ts`). Mapping must never mutate: a control whose
|
|
5
|
+
* label matches this pattern is recorded but never clicked.
|
|
6
|
+
*
|
|
7
|
+
* Kept as a plain string (not a `RegExp`) because both crawlers build their
|
|
8
|
+
* candidate list inside `page.evaluate` — the classifier function is serialized
|
|
9
|
+
* into the browser, so it cannot close over a `RegExp` from Node. The source is
|
|
10
|
+
* passed in as an argument and recompiled in the browser; this module is the
|
|
11
|
+
* single source of truth for what "destructive" means.
|
|
12
|
+
*/
|
|
13
|
+
export const DANGER_SOURCE = '\\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\\b';
|
package/dist/diff.js
CHANGED
|
@@ -70,7 +70,9 @@ function dropLayoutEquivalentMarginProps(props, a, b) {
|
|
|
70
70
|
}
|
|
71
71
|
function pxParts(value) {
|
|
72
72
|
const parts = value.trim().split(/\s+/);
|
|
73
|
-
|
|
73
|
+
// 1–3 components: a single-value origin (`50px`) jitters the same way as the
|
|
74
|
+
// 2/3-component form and must be suppressed identically.
|
|
75
|
+
if (parts.length < 1 || parts.length > 3)
|
|
74
76
|
return null;
|
|
75
77
|
const values = parts.map((part) => {
|
|
76
78
|
const match = /^(-?\d+(?:\.\d+)?)px$/.exec(part);
|
package/dist/index.d.ts
CHANGED
|
@@ -23,5 +23,5 @@ export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, find
|
|
|
23
23
|
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
24
24
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
25
25
|
export type { ReportOptions, ReportResult } from './report.js';
|
|
26
|
-
export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
|
|
26
|
+
export { affectedSurfaces, classifyStyleChange, explainAffectedSurfaces } from './affected-surfaces.js';
|
|
27
27
|
export type { ModuleEdge, AffectedSurfacesInput, AffectedSurfaces } from './affected-surfaces.js';
|
package/dist/index.js
CHANGED
|
@@ -12,4 +12,4 @@ export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError
|
|
|
12
12
|
export { harvestStyleVariants } from './variant-crawler.js';
|
|
13
13
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
14
14
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
|
15
|
-
export { affectedSurfaces, classifyStyleChange } from './affected-surfaces.js';
|
|
15
|
+
export { affectedSurfaces, classifyStyleChange, explainAffectedSurfaces } from './affected-surfaces.js';
|
package/dist/inventory.js
CHANGED
|
@@ -50,7 +50,9 @@ export function collectNavAffordances() {
|
|
|
50
50
|
};
|
|
51
51
|
const nameOf = (el) => (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
52
52
|
const internalPath = (el) => {
|
|
53
|
-
|
|
53
|
+
// SVG anchors carry the target in `xlink:href` (legacy) or `href`; fall back so
|
|
54
|
+
// an <svg><a> nav link resolves like an HTML one.
|
|
55
|
+
const raw = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
|
|
54
56
|
if (!raw || raw.startsWith('#'))
|
|
55
57
|
return null;
|
|
56
58
|
try {
|
|
@@ -67,14 +69,18 @@ export function collectNavAffordances() {
|
|
|
67
69
|
// Erring broad is correct here: a stray non-nav button is harmless noise, but a
|
|
68
70
|
// MISSED nav item defeats the guard. Prefer semantic markup (role=tablist) for
|
|
69
71
|
// fully reliable harvesting; see docs/inventory-guard.md.
|
|
70
|
-
|
|
72
|
+
// `a[*|href]` (any-namespace href) not `a[href]`: an SVG anchor may carry only the
|
|
73
|
+
// XLink-namespaced `xlink:href`, which `a[href]` never selects — so the xlink:href
|
|
74
|
+
// fallback in internalPath would be dead without it.
|
|
75
|
+
const SEL = 'a[*|href], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], nav button, [role="navigation"] button, [role="tablist"] button, [class*="navtab" i] button, [class*="nav-tab" i] button, [class*="subnav" i] button, [class*="subtab" i] button, [class*="tabs" i] button';
|
|
71
76
|
return Array.from(document.querySelectorAll(SEL))
|
|
72
77
|
.filter(visible)
|
|
73
78
|
.map((el) => ({
|
|
74
79
|
tag: el.tagName.toLowerCase(),
|
|
75
80
|
role: (el.getAttribute('role') || '').toLowerCase(),
|
|
76
81
|
name: nameOf(el),
|
|
77
|
-
|
|
82
|
+
// SVG anchors report tagName `a` (lowercase), HTML reports `A` — match either.
|
|
83
|
+
internalPath: el.tagName.toLowerCase() === 'a' ? internalPath(el) : null,
|
|
78
84
|
testId: el.getAttribute('data-testid'),
|
|
79
85
|
domId: el.getAttribute('id'),
|
|
80
86
|
controls: el.getAttribute('aria-controls'),
|
package/dist/map-store.d.ts
CHANGED
|
@@ -48,7 +48,11 @@ export interface CachedCaptureDirs {
|
|
|
48
48
|
tmpRoot: string;
|
|
49
49
|
}
|
|
50
50
|
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
51
|
-
* Playwright browser handle is in scope.
|
|
51
|
+
* Playwright browser handle is in scope. Write-or-CLEAR semantics: an undefined version
|
|
52
|
+
* REMOVES any existing sidecar rather than leaving it, so a reused capture dir (e.g. the
|
|
53
|
+
* default `.styleproof/maps/current`) can never carry a PRIOR run's build into this run's
|
|
54
|
+
* manifest — that would stamp a false browser-build fingerprint the compatibility guard
|
|
55
|
+
* then trusts. Best-effort: the delete is forced and ignores a missing file. */
|
|
52
56
|
export declare function writeBrowserBuildSidecar(dir: string, browserVersion: string | undefined): void;
|
|
53
57
|
export declare function expectedCompatibilityKey(options?: {
|
|
54
58
|
cwd?: string;
|
package/dist/map-store.js
CHANGED
|
@@ -80,12 +80,19 @@ function detectLockfile(cwd) {
|
|
|
80
80
|
return {};
|
|
81
81
|
}
|
|
82
82
|
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
83
|
-
* Playwright browser handle is in scope.
|
|
83
|
+
* Playwright browser handle is in scope. Write-or-CLEAR semantics: an undefined version
|
|
84
|
+
* REMOVES any existing sidecar rather than leaving it, so a reused capture dir (e.g. the
|
|
85
|
+
* default `.styleproof/maps/current`) can never carry a PRIOR run's build into this run's
|
|
86
|
+
* manifest — that would stamp a false browser-build fingerprint the compatibility guard
|
|
87
|
+
* then trusts. Best-effort: the delete is forced and ignores a missing file. */
|
|
84
88
|
export function writeBrowserBuildSidecar(dir, browserVersion) {
|
|
85
|
-
|
|
89
|
+
const sidecar = path.join(dir, BROWSER_BUILD_SIDECAR);
|
|
90
|
+
if (!browserVersion) {
|
|
91
|
+
fs.rmSync(sidecar, { force: true });
|
|
86
92
|
return;
|
|
93
|
+
}
|
|
87
94
|
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
-
fs.writeFileSync(
|
|
95
|
+
fs.writeFileSync(sidecar, JSON.stringify({ browserVersion }, null, 2));
|
|
89
96
|
}
|
|
90
97
|
function readBrowserBuildSidecar(dir) {
|
|
91
98
|
try {
|
package/dist/report.js
CHANGED
|
@@ -510,9 +510,26 @@ function regionHeading(regionPaths, findings) {
|
|
|
510
510
|
const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
|
|
511
511
|
return `${label} · ${groupTitle(findings)}`;
|
|
512
512
|
}
|
|
513
|
+
// CSS values are author/attacker-influenced (content:"…", url("…"), font-family
|
|
514
|
+
// strings), so at the render boundary they get their OWN escaper — distinct from
|
|
515
|
+
// safeKey, which strips control chars from surface keys. Values must stay READABLE
|
|
516
|
+
// (a mangled url(…) is useless), so we ESCAPE rather than strip:
|
|
517
|
+
// • `|` → `\|` — an unescaped pipe splits the table row (GitHub honours the
|
|
518
|
+
// backslash even inside a code span).
|
|
519
|
+
// • backticks — a bare backtick would close the code span and leak live
|
|
520
|
+
// Markdown; widen the fence to one more backtick than the
|
|
521
|
+
// value's longest run, padding a space when it touches an edge
|
|
522
|
+
// (GitHub's rule for a code span that starts/ends with a tick).
|
|
523
|
+
function codeValue(v) {
|
|
524
|
+
const escaped = v.replace(/\|/g, '\\|');
|
|
525
|
+
const longestRun = Math.max(0, ...(escaped.match(/`+/g) ?? []).map((r) => r.length));
|
|
526
|
+
const fence = '`'.repeat(longestRun + 1);
|
|
527
|
+
const pad = /^`|`$/.test(escaped) ? ' ' : '';
|
|
528
|
+
return `${fence}${pad}${escaped}${pad}${fence}`;
|
|
529
|
+
}
|
|
513
530
|
// A "no value here" marker renders as an em dash; colours render as `#hex` so the
|
|
514
531
|
// table cell shows GitHub's live swatch.
|
|
515
|
-
const cell = (v) => (isNonValue(v) ? '—' :
|
|
532
|
+
const cell = (v) => (isNonValue(v) ? '—' : codeValue(toHex(v)));
|
|
516
533
|
// Long values (gradients, data URIs) would swamp the table, but truncating each
|
|
517
534
|
// side independently can show two IDENTICAL cells for a real diff: both
|
|
518
535
|
// sides of a gradient rendered as the same rgba while the actual change — a
|
|
@@ -545,7 +562,7 @@ function cellPair(before, after) {
|
|
|
545
562
|
if (isNonValue(before) || isNonValue(after))
|
|
546
563
|
return [cell(before), cell(after)];
|
|
547
564
|
const [b, a] = excerptPair(before, after);
|
|
548
|
-
return [
|
|
565
|
+
return [codeValue(toHex(b)), codeValue(toHex(a))];
|
|
549
566
|
}
|
|
550
567
|
function beforeAfterTable(rows) {
|
|
551
568
|
return [
|
|
@@ -553,14 +570,14 @@ function beforeAfterTable(rows) {
|
|
|
553
570
|
'| --- | --- | --- |',
|
|
554
571
|
...rows.map((r) => {
|
|
555
572
|
const [b, a] = cellPair(r.before, r.after);
|
|
556
|
-
return `|
|
|
573
|
+
return `| ${codeValue(r.prop)} | ${b} | ${a} |`;
|
|
557
574
|
}),
|
|
558
575
|
];
|
|
559
576
|
}
|
|
560
577
|
// A brand-new element has no meaningful "before", so its resting style renders
|
|
561
578
|
// value-only (the After column), mirroring the added-element interaction-states table.
|
|
562
579
|
function valueTable(rows) {
|
|
563
|
-
return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `|
|
|
580
|
+
return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| ${codeValue(r.prop)} | ${cell(r.after)} |`)];
|
|
564
581
|
}
|
|
565
582
|
/** `Button (variant=primary, size=sm)` — the React component + sanitized props
|
|
566
583
|
* the element captured (advisory; present only with captureComponent). */
|
|
@@ -587,8 +604,8 @@ function statesSection(states, added) {
|
|
|
587
604
|
for (const c of summarizeProps(st.props)) {
|
|
588
605
|
const [b, a] = cellPair(c.before, c.after);
|
|
589
606
|
rows.push(added
|
|
590
|
-
? `|
|
|
591
|
-
: `|
|
|
607
|
+
? `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${cell(c.after)} |`
|
|
608
|
+
: `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${b} → ${a} |`);
|
|
592
609
|
}
|
|
593
610
|
if (!rows.length)
|
|
594
611
|
return [];
|
package/dist/runner.d.ts
CHANGED
|
@@ -185,6 +185,23 @@ type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
|
|
|
185
185
|
metadata?: CaptureMetadata;
|
|
186
186
|
};
|
|
187
187
|
export declare function expandSurfaceVariants(surface: Surface): ExpandedSurface[];
|
|
188
|
+
/** The identity fields of an expanded surface a collision check needs. */
|
|
189
|
+
type ExpandedKeyed = {
|
|
190
|
+
key: string;
|
|
191
|
+
metadata?: CaptureMetadata;
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Fail LOUDLY on two expanded surfaces sharing a capture key.
|
|
195
|
+
*
|
|
196
|
+
* The expanded key is `surface.key-variant.key`, and that key is the map filename
|
|
197
|
+
* (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
|
|
198
|
+
* change without breaking backward compatibility. But the `-` join is ambiguous:
|
|
199
|
+
* surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
|
|
200
|
+
* `a-b-c`, and the second capture would silently overwrite the first, dropping a
|
|
201
|
+
* surface with no error. Rather than mangle the public key format, we assert
|
|
202
|
+
* uniqueness up front and name BOTH origins so the author can rename one.
|
|
203
|
+
*/
|
|
204
|
+
export declare function assertUniqueExpandedKeys(surfaces: ExpandedKeyed[]): void;
|
|
188
205
|
/**
|
|
189
206
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
190
207
|
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
package/dist/runner.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
|
-
import { coverageGaps, COVERAGE_LEDGER } from './coverage.js';
|
|
6
|
+
import { coverageGaps, coverageKeys, translateExpected, COVERAGE_LEDGER, } from './coverage.js';
|
|
7
7
|
import { writeBrowserBuildSidecar } from './map-store.js';
|
|
8
8
|
import { detectViewportWidths } from './breakpoints.js';
|
|
9
9
|
import { selectCrawlLinks, crawlCoverageError } from './crawl.js';
|
|
@@ -210,6 +210,36 @@ export function expandSurfaceVariants(surface) {
|
|
|
210
210
|
return [baseSurface, ...expandedVariants];
|
|
211
211
|
return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
|
|
212
212
|
}
|
|
213
|
+
/** Human-readable origin of an expanded surface for a collision message. */
|
|
214
|
+
function expandedOrigin(s) {
|
|
215
|
+
const surfaceKey = s.metadata?.surfaceKey ?? s.key;
|
|
216
|
+
const variantKey = s.metadata?.variantKey;
|
|
217
|
+
return variantKey ? `surface '${surfaceKey}' variant '${variantKey}'` : `surface '${surfaceKey}'`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Fail LOUDLY on two expanded surfaces sharing a capture key.
|
|
221
|
+
*
|
|
222
|
+
* The expanded key is `surface.key-variant.key`, and that key is the map filename
|
|
223
|
+
* (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
|
|
224
|
+
* change without breaking backward compatibility. But the `-` join is ambiguous:
|
|
225
|
+
* surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
|
|
226
|
+
* `a-b-c`, and the second capture would silently overwrite the first, dropping a
|
|
227
|
+
* surface with no error. Rather than mangle the public key format, we assert
|
|
228
|
+
* uniqueness up front and name BOTH origins so the author can rename one.
|
|
229
|
+
*/
|
|
230
|
+
export function assertUniqueExpandedKeys(surfaces) {
|
|
231
|
+
const byKey = new Map();
|
|
232
|
+
for (const s of surfaces) {
|
|
233
|
+
const prior = byKey.get(s.key);
|
|
234
|
+
if (prior) {
|
|
235
|
+
throw new Error(`styleproof: capture key '${s.key}' is produced by two surfaces — ` +
|
|
236
|
+
`${expandedOrigin(prior)} collides with ${expandedOrigin(s)}. ` +
|
|
237
|
+
`Keys must expand uniquely (they name the map files and report entries); ` +
|
|
238
|
+
`rename one surface or variant.`);
|
|
239
|
+
}
|
|
240
|
+
byKey.set(s.key, s);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
213
243
|
/**
|
|
214
244
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
215
245
|
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
@@ -459,7 +489,7 @@ function resolveSettings(c) {
|
|
|
459
489
|
* `expected: null` records that the spec declared no registry, so a green can only
|
|
460
490
|
* certify the captured surfaces. Runs on a capture run (dir set) only.
|
|
461
491
|
*/
|
|
462
|
-
function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
492
|
+
function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfaces) {
|
|
463
493
|
test('styleproof coverage ledger', () => {
|
|
464
494
|
const outDir = path.join(settings.baseDir, dir);
|
|
465
495
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -470,7 +500,11 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
|
470
500
|
: settings.replayFrom
|
|
471
501
|
? 'replayed'
|
|
472
502
|
: 'unproven';
|
|
473
|
-
|
|
503
|
+
// Pre-translate the declared universe into the keys actually captured to disk, so
|
|
504
|
+
// the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
|
|
505
|
+
// compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
|
|
506
|
+
const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
|
|
507
|
+
const ledger = { version: 1, expected: ledgerExpected, exclude, determinism };
|
|
474
508
|
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
475
509
|
});
|
|
476
510
|
}
|
|
@@ -489,6 +523,7 @@ export function defineStyleMapCapture(options) {
|
|
|
489
523
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
490
524
|
const settings = resolveSettings(options);
|
|
491
525
|
const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
|
|
526
|
+
assertUniqueExpandedKeys(captureSurfaces);
|
|
492
527
|
// Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
|
|
493
528
|
// a route added without a surface fails the app's own tests — long before, and
|
|
494
529
|
// independent of, a capture run. This is the one gap captures can't catch: a
|
|
@@ -497,7 +532,10 @@ export function defineStyleMapCapture(options) {
|
|
|
497
532
|
if (expected) {
|
|
498
533
|
test.describe('styleproof coverage', () => {
|
|
499
534
|
test('every expected surface is captured or explicitly excluded', () => {
|
|
500
|
-
const { uncovered, staleExclusions } = coverageGaps(
|
|
535
|
+
const { uncovered, staleExclusions } = coverageGaps(
|
|
536
|
+
// A liveStates surface is captured only as its `-loading`/`-loaded` splits;
|
|
537
|
+
// map each back to the declared base key so the split satisfies `expected`.
|
|
538
|
+
coverageKeys(captureSurfaces), expected, exclude);
|
|
501
539
|
expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
|
|
502
540
|
`nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
|
|
503
541
|
`Missing: ${uncovered.join(', ')}`).toEqual([]);
|
|
@@ -509,7 +547,7 @@ export function defineStyleMapCapture(options) {
|
|
|
509
547
|
test.describe('styleproof capture', () => {
|
|
510
548
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
511
549
|
if (dir)
|
|
512
|
-
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
550
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, captureSurfaces);
|
|
513
551
|
if (dir)
|
|
514
552
|
writeBrowserBuildTest(settings, dir);
|
|
515
553
|
for (const surface of captureSurfaces) {
|
|
@@ -623,8 +661,13 @@ export function defineCrawlCapture(options) {
|
|
|
623
661
|
// captures what the nav links to, and can't prove that's every route). With
|
|
624
662
|
// `expected` the crawl reconciles the DISCOVERED link set against it below and the
|
|
625
663
|
// ledger travels with the declared universe.
|
|
664
|
+
// The crawl applies the SAME variants/liveStates to every discovered link, so the
|
|
665
|
+
// expansion of a declared key is knowable up front (before discovery): expand each
|
|
666
|
+
// `expected` key with this crawl's variants/liveStates to get the keys captured to
|
|
667
|
+
// disk, so a liveStates crawl's ledger is pre-translated like the spec-driven one.
|
|
668
|
+
const ledgerSurfaces = (expected ?? []).flatMap((key) => expandSurfaceVariants({ key, go: async () => { }, variants, liveStates }));
|
|
626
669
|
if (dir)
|
|
627
|
-
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
670
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, ledgerSurfaces);
|
|
628
671
|
if (dir)
|
|
629
672
|
writeBrowserBuildTest(settings, dir);
|
|
630
673
|
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
@@ -654,6 +697,7 @@ export function defineCrawlCapture(options) {
|
|
|
654
697
|
liveStates,
|
|
655
698
|
popups,
|
|
656
699
|
}));
|
|
700
|
+
assertUniqueExpandedKeys(captureSurfaces);
|
|
657
701
|
// Budget the whole sweep up front: one test captures every surface, and
|
|
658
702
|
// captureSurface no longer sets its own timeout, so size it to the work found.
|
|
659
703
|
// With auto-width the band count isn't known until each surface renders, so
|
package/dist/variant-crawler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { captureStyleMap } from './capture.js';
|
|
3
3
|
import { diffStyleMaps } from './diff.js';
|
|
4
|
+
import { DANGER_SOURCE } from './danger.js';
|
|
4
5
|
function slug(value) {
|
|
5
6
|
return (value
|
|
6
7
|
.toLowerCase()
|
|
@@ -39,8 +40,11 @@ function liveKey(candidate) {
|
|
|
39
40
|
function liveSelector(candidate) {
|
|
40
41
|
return candidate.path;
|
|
41
42
|
}
|
|
43
|
+
// `dangerSource` is the shared destructive-label pattern (see {@link DANGER_SOURCE}),
|
|
44
|
+
// passed in because this function is serialized into the browser and can't close over
|
|
45
|
+
// a Node `RegExp`.
|
|
42
46
|
// fallow-ignore-next-line complexity
|
|
43
|
-
function collectCandidates() {
|
|
47
|
+
function collectCandidates(dangerSource) {
|
|
44
48
|
const controls = [
|
|
45
49
|
'[aria-expanded]',
|
|
46
50
|
'[aria-haspopup]',
|
|
@@ -53,7 +57,7 @@ function collectCandidates() {
|
|
|
53
57
|
'select',
|
|
54
58
|
'form',
|
|
55
59
|
].join(',');
|
|
56
|
-
const dangerous =
|
|
60
|
+
const dangerous = new RegExp(dangerSource, 'i');
|
|
57
61
|
const esc = (value) => CSS.escape(value);
|
|
58
62
|
const quote = (value) => JSON.stringify(value);
|
|
59
63
|
const visible = (el) => {
|
|
@@ -93,7 +97,15 @@ function collectCandidates() {
|
|
|
93
97
|
return pathSelector(el);
|
|
94
98
|
};
|
|
95
99
|
const labelFor = (el) => {
|
|
96
|
-
|
|
100
|
+
// Include `title` so an icon-only control (no text, no aria-label) announcing
|
|
101
|
+
// itself via a native tooltip — `<button title="Delete">🗑</button>` — still
|
|
102
|
+
// yields a real label. Without it the label is "button", slipping past the
|
|
103
|
+
// destructive guard below that this harvester's clicks must respect.
|
|
104
|
+
const own = (el.getAttribute('aria-label') ||
|
|
105
|
+
el.getAttribute('name') ||
|
|
106
|
+
el.textContent ||
|
|
107
|
+
el.getAttribute('title') ||
|
|
108
|
+
'').trim();
|
|
97
109
|
return own.replace(/\s+/g, ' ').slice(0, 80) || el.tagName.toLowerCase();
|
|
98
110
|
};
|
|
99
111
|
const reasonFor = (el) => {
|
|
@@ -144,7 +156,7 @@ function collectCandidates() {
|
|
|
144
156
|
return out;
|
|
145
157
|
}
|
|
146
158
|
async function discoverCandidates(page) {
|
|
147
|
-
return page.evaluate(collectCandidates);
|
|
159
|
+
return page.evaluate(collectCandidates, DANGER_SOURCE);
|
|
148
160
|
}
|
|
149
161
|
async function perform(page, candidate) {
|
|
150
162
|
const target = page.locator(candidate.selector).first();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|