styleproof 3.17.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 +212 -0
- package/README.md +72 -7
- package/bin/styleproof-capture.mjs +19 -4
- package/bin/styleproof-diff.mjs +41 -23
- package/bin/styleproof-init.mjs +7 -0
- package/bin/styleproof-map.mjs +22 -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-url.js +6 -1
- package/dist/capture.js +40 -26
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.js +54 -0
- package/dist/crawl-surfaces.d.ts +5 -1
- package/dist/crawl-surfaces.js +29 -16
- package/dist/crawl.d.ts +51 -1
- package/dist/crawl.js +83 -7
- package/dist/danger.d.ts +13 -0
- package/dist/danger.js +13 -0
- package/dist/diff.d.ts +26 -0
- package/dist/diff.js +57 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/inventory.js +9 -3
- package/dist/map-store.d.ts +15 -0
- package/dist/map-store.js +45 -3
- package/dist/report.js +38 -13
- package/dist/runner.d.ts +40 -19
- package/dist/runner.js +143 -59
- package/dist/variant-crawler.js +16 -4
- package/package.json +1 -1
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,97 @@ 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)
|
|
94
139
|
continue;
|
|
95
|
-
|
|
96
|
-
|
|
140
|
+
const id = dedupIdentity(link.url);
|
|
141
|
+
if (seen.has(id))
|
|
142
|
+
continue;
|
|
143
|
+
seen.add(id);
|
|
144
|
+
push(link);
|
|
97
145
|
}
|
|
98
146
|
return out;
|
|
99
147
|
}
|
|
148
|
+
export function crawlCoverageGaps(discoveredKeys, expected, exclude = {}) {
|
|
149
|
+
const discovered = new Set(discoveredKeys);
|
|
150
|
+
const expectedSet = new Set(expected);
|
|
151
|
+
const missing = [...expectedSet].filter((k) => !discovered.has(k) && !(k in exclude));
|
|
152
|
+
const unexpected = [...discovered].filter((k) => !expectedSet.has(k) && !(k in exclude));
|
|
153
|
+
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k) && !discovered.has(k));
|
|
154
|
+
return { missing, unexpected, staleExclusions };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
|
|
158
|
+
* render the failure message, or `null` when the nav reconciles. `from` names the crawl
|
|
159
|
+
* root in the message. Kept pure and out of the capture test so the wording is
|
|
160
|
+
* unit-testable and {@link defineCrawlCapture} just throws what this returns.
|
|
161
|
+
*/
|
|
162
|
+
export function crawlCoverageError(from, discoveredKeys, expected, exclude = {}) {
|
|
163
|
+
const { missing, unexpected, staleExclusions } = crawlCoverageGaps(discoveredKeys, expected, exclude);
|
|
164
|
+
const problems = [];
|
|
165
|
+
if (missing.length)
|
|
166
|
+
problems.push(`nav regression — expected route(s) no longer linked from ${from}: ${missing.join(', ')}. ` +
|
|
167
|
+
`Restore the link, or move the key to \`exclude\` with a reason.`);
|
|
168
|
+
if (unexpected.length)
|
|
169
|
+
problems.push(`new route(s) with no owner — link(s) rendered at ${from} but absent from \`expected\`: ` +
|
|
170
|
+
`${unexpected.join(', ')}. Add each to \`expected\`, or to \`exclude\` with a reason.`);
|
|
171
|
+
if (staleExclusions.length)
|
|
172
|
+
problems.push(`stale \`exclude\` — key(s) in neither \`expected\` nor the rendered nav ` +
|
|
173
|
+
`(renamed or removed?): ${staleExclusions.join(', ')}.`);
|
|
174
|
+
return problems.length ? `styleproof crawl coverage gap:\n${problems.join('\n')}` : null;
|
|
175
|
+
}
|
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.d.ts
CHANGED
|
@@ -9,6 +9,32 @@ export type PropChange = {
|
|
|
9
9
|
before: string;
|
|
10
10
|
after: string;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* The before dir carries a bundle MANIFEST but ZERO captures while the after dir
|
|
14
|
+
* held some — a restore or capture that claims success yet delivered no maps (a
|
|
15
|
+
* corrupt bundle, a wrong --base-dir pointed at a manifest-only dir). Without
|
|
16
|
+
* this guard every after surface diffs as `missing: 'before'` (exit 3, "only new
|
|
17
|
+
* surfaces") and a whole app of regressions becomes one approvable "🆕 all new"
|
|
18
|
+
* report. The CLIs map this to exit 2 — a hard error, never the rubber-stampable
|
|
19
|
+
* exit 3. A truly BARE base dir (no manifest, no maps) is different: it means
|
|
20
|
+
* "never captured — no baseline exists yet", the first-adoption flow where the
|
|
21
|
+
* base commit predates the capture spec, and it keeps the exit-3 review path.
|
|
22
|
+
* (Both dirs empty stays the plain "no captures found" throw.)
|
|
23
|
+
*/
|
|
24
|
+
export declare class MissingBaseMapError extends Error {
|
|
25
|
+
constructor();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The mirror case: the AFTER (head) dir held ZERO captures while the before dir
|
|
29
|
+
* held some — a head capture or restore that produced nothing. Without this
|
|
30
|
+
* guard every base surface marks `missing: 'after'`, the CLI's new-surface count
|
|
31
|
+
* (which tallies BOTH directions) exits 3, and a head that rendered nothing
|
|
32
|
+
* becomes an approvable "all new surfaces" report — and, once approved, the
|
|
33
|
+
* next base. Same exit-2 path via the CLIs' existing catch.
|
|
34
|
+
*/
|
|
35
|
+
export declare class MissingHeadMapError extends Error {
|
|
36
|
+
constructor();
|
|
37
|
+
}
|
|
12
38
|
export type Finding = {
|
|
13
39
|
kind: 'dom';
|
|
14
40
|
path: string;
|
package/dist/diff.js
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { loadStyleMap, isUnder } from './capture.js';
|
|
4
|
-
import { isMapFile } from './map-store.js';
|
|
4
|
+
import { isMapFile, MAP_MANIFEST } from './map-store.js';
|
|
5
5
|
import { styleValuesEqual } from './canonicalize.js';
|
|
6
|
+
/**
|
|
7
|
+
* The before dir carries a bundle MANIFEST but ZERO captures while the after dir
|
|
8
|
+
* held some — a restore or capture that claims success yet delivered no maps (a
|
|
9
|
+
* corrupt bundle, a wrong --base-dir pointed at a manifest-only dir). Without
|
|
10
|
+
* this guard every after surface diffs as `missing: 'before'` (exit 3, "only new
|
|
11
|
+
* surfaces") and a whole app of regressions becomes one approvable "🆕 all new"
|
|
12
|
+
* report. The CLIs map this to exit 2 — a hard error, never the rubber-stampable
|
|
13
|
+
* exit 3. A truly BARE base dir (no manifest, no maps) is different: it means
|
|
14
|
+
* "never captured — no baseline exists yet", the first-adoption flow where the
|
|
15
|
+
* base commit predates the capture spec, and it keeps the exit-3 review path.
|
|
16
|
+
* (Both dirs empty stays the plain "no captures found" throw.)
|
|
17
|
+
*/
|
|
18
|
+
export class MissingBaseMapError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('base map missing: restore it from the map store or recapture both sides — refusing to treat every surface as new. ' +
|
|
21
|
+
'Next: run styleproof-map --restore --sha <base>, or let CI recapture both sides.');
|
|
22
|
+
this.name = 'MissingBaseMapError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The mirror case: the AFTER (head) dir held ZERO captures while the before dir
|
|
27
|
+
* held some — a head capture or restore that produced nothing. Without this
|
|
28
|
+
* guard every base surface marks `missing: 'after'`, the CLI's new-surface count
|
|
29
|
+
* (which tallies BOTH directions) exits 3, and a head that rendered nothing
|
|
30
|
+
* becomes an approvable "all new surfaces" report — and, once approved, the
|
|
31
|
+
* next base. Same exit-2 path via the CLIs' existing catch.
|
|
32
|
+
*/
|
|
33
|
+
export class MissingHeadMapError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super('head map missing: the head capture produced zero surfaces — recapture the head side; refusing to treat every surface as removed/new. ' +
|
|
36
|
+
'Next: re-run styleproof-map on the head commit, or let CI recapture both sides.');
|
|
37
|
+
this.name = 'MissingHeadMapError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
6
40
|
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
7
41
|
const changed = [];
|
|
8
42
|
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
@@ -36,7 +70,9 @@ function dropLayoutEquivalentMarginProps(props, a, b) {
|
|
|
36
70
|
}
|
|
37
71
|
function pxParts(value) {
|
|
38
72
|
const parts = value.trim().split(/\s+/);
|
|
39
|
-
|
|
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)
|
|
40
76
|
return null;
|
|
41
77
|
const values = parts.map((part) => {
|
|
42
78
|
const match = /^(-?\d+(?:\.\d+)?)px$/.exec(part);
|
|
@@ -189,6 +225,25 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
189
225
|
const names = [...new Set([...Object.keys(indexA), ...Object.keys(indexB)])].sort();
|
|
190
226
|
if (names.length === 0)
|
|
191
227
|
throw new Error(`no .json(.gz) captures found in ${dirA} or ${dirB}`);
|
|
228
|
+
// A whole side with zero captures is a missing MAP, not a set of genuinely
|
|
229
|
+
// new/removed surfaces — either way every surface would carry a `missing`
|
|
230
|
+
// marker and the run would read as "all new" (exit 3, approvable). Refuse
|
|
231
|
+
// each direction loudly with its own named cause — with one exception:
|
|
232
|
+
//
|
|
233
|
+
// Base side: only when the dir carries a bundle manifest. Manifest + zero maps
|
|
234
|
+
// means a restore/capture that claims success yet delivered nothing (a corrupt
|
|
235
|
+
// bundle) — breakage. A BARE dir (no manifest either) means no baseline was
|
|
236
|
+
// ever captured — the first-adoption flow, where the recapture fallback checks
|
|
237
|
+
// out a base commit that predates the capture spec. That legitimately yields
|
|
238
|
+
// zero surfaces and must keep the exit-3 "new surfaces, review before
|
|
239
|
+
// baselining" onboarding path, so it falls through.
|
|
240
|
+
if (Object.keys(indexA).length === 0 && fs.existsSync(path.join(dirA, MAP_MANIFEST)))
|
|
241
|
+
throw new MissingBaseMapError();
|
|
242
|
+
// Head side: UNCONDITIONAL (bare or manifest-present). The onboarding
|
|
243
|
+
// asymmetry only exists on the base side — the head is the commit under test,
|
|
244
|
+
// so a head that produced zero captures is always breakage, never a review flow.
|
|
245
|
+
if (Object.keys(indexB).length === 0)
|
|
246
|
+
throw new MissingHeadMapError();
|
|
192
247
|
const surfaces = [];
|
|
193
248
|
const counts = { dom: 0, style: 0, state: 0 };
|
|
194
249
|
let volatile = 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,13 +15,13 @@ export { discoverNextRoutes } from './routes.js';
|
|
|
15
15
|
export type { DiscoveredRoute } from './routes.js';
|
|
16
16
|
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
17
17
|
export type { DiscoveredComponent, DiscoverComponentFilesOptions, ComponentCatalogSurfaceOptions, } from './components.js';
|
|
18
|
-
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
19
|
-
export type { CrawlLink, LinkMatch, SelectLinksOptions } from './crawl.js';
|
|
18
|
+
export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError } from './crawl.js';
|
|
19
|
+
export type { CrawlLink, LinkMatch, SelectLinksOptions, CrawlCoverageGaps } from './crawl.js';
|
|
20
20
|
export { harvestStyleVariants } from './variant-crawler.js';
|
|
21
21
|
export type { HarvestAction, HarvestedLiveState, HarvestedRoute, HarvestedVariant, HarvestRoute, HarvestSkip, VariantHarvest, VariantHarvestOptions, } from './variant-crawler.js';
|
|
22
22
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
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
|
@@ -8,8 +8,8 @@ export { coverageGaps } from './coverage.js';
|
|
|
8
8
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
9
9
|
export { discoverNextRoutes } from './routes.js';
|
|
10
10
|
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
11
|
-
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
11
|
+
export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError } from './crawl.js';
|
|
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
|
@@ -3,6 +3,10 @@ export declare const DEFAULT_MAP_LABEL = "current";
|
|
|
3
3
|
export declare const DEFAULT_MAP_STORE_BRANCH = "styleproof-maps";
|
|
4
4
|
export declare const DEFAULT_REMOTE = "origin";
|
|
5
5
|
export declare const MAP_MANIFEST = "styleproof-manifest.json";
|
|
6
|
+
/** Sidecar written during a capture run (where a browser handle is in scope) recording
|
|
7
|
+
* the real browser build (`browser().version()`). `writeMapManifest` runs after Playwright
|
|
8
|
+
* has exited — no browser — so it reads the build back from here. Not a surface map. */
|
|
9
|
+
export declare const BROWSER_BUILD_SIDECAR = "styleproof-browser.json";
|
|
6
10
|
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
7
11
|
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
8
12
|
* these, or a sidecar reads as a phantom "new surface". */
|
|
@@ -21,6 +25,10 @@ export interface MapManifest {
|
|
|
21
25
|
lockfile?: string;
|
|
22
26
|
lockfileHash?: string;
|
|
23
27
|
playwrightVersion?: string;
|
|
28
|
+
/** Real browser build (`browser().version()`), recorded at capture time. The npm
|
|
29
|
+
* `@playwright/test` version can hold constant while this changes (re-download, a
|
|
30
|
+
* different browser store, a CI image bump), so this is what actually gates a compare. */
|
|
31
|
+
browserVersion?: string;
|
|
24
32
|
platform: string;
|
|
25
33
|
arch: string;
|
|
26
34
|
nodeMajor: string;
|
|
@@ -39,6 +47,13 @@ export interface CachedCaptureDirs {
|
|
|
39
47
|
compatibilityKey: string;
|
|
40
48
|
tmpRoot: string;
|
|
41
49
|
}
|
|
50
|
+
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
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. */
|
|
56
|
+
export declare function writeBrowserBuildSidecar(dir: string, browserVersion: string | undefined): void;
|
|
42
57
|
export declare function expectedCompatibilityKey(options?: {
|
|
43
58
|
cwd?: string;
|
|
44
59
|
spec?: string;
|
package/dist/map-store.js
CHANGED
|
@@ -11,11 +11,19 @@ export const DEFAULT_MAP_LABEL = 'current';
|
|
|
11
11
|
export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
|
|
12
12
|
export const DEFAULT_REMOTE = 'origin';
|
|
13
13
|
export const MAP_MANIFEST = 'styleproof-manifest.json';
|
|
14
|
+
/** Sidecar written during a capture run (where a browser handle is in scope) recording
|
|
15
|
+
* the real browser build (`browser().version()`). `writeMapManifest` runs after Playwright
|
|
16
|
+
* has exited — no browser — so it reads the build back from here. Not a surface map. */
|
|
17
|
+
export const BROWSER_BUILD_SIDECAR = 'styleproof-browser.json';
|
|
14
18
|
const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
|
|
15
19
|
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
16
20
|
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
17
21
|
* these, or a sidecar reads as a phantom "new surface". */
|
|
18
|
-
export const RESERVED_BUNDLE_FILES = new Set([
|
|
22
|
+
export const RESERVED_BUNDLE_FILES = new Set([
|
|
23
|
+
MAP_MANIFEST,
|
|
24
|
+
COVERAGE_LEDGER,
|
|
25
|
+
BROWSER_BUILD_SIDECAR,
|
|
26
|
+
]);
|
|
19
27
|
/** True for a captured surface map (`<key>@<width>.json[.gz]`), false for metadata. */
|
|
20
28
|
export function isMapFile(name) {
|
|
21
29
|
return !RESERVED_BUNDLE_FILES.has(name) && /\.json(\.gz)?$/.test(name);
|
|
@@ -71,6 +79,30 @@ function detectLockfile(cwd) {
|
|
|
71
79
|
}
|
|
72
80
|
return {};
|
|
73
81
|
}
|
|
82
|
+
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
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. */
|
|
88
|
+
export function writeBrowserBuildSidecar(dir, browserVersion) {
|
|
89
|
+
const sidecar = path.join(dir, BROWSER_BUILD_SIDECAR);
|
|
90
|
+
if (!browserVersion) {
|
|
91
|
+
fs.rmSync(sidecar, { force: true });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
fs.writeFileSync(sidecar, JSON.stringify({ browserVersion }, null, 2));
|
|
96
|
+
}
|
|
97
|
+
function readBrowserBuildSidecar(dir) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, BROWSER_BUILD_SIDECAR), 'utf8'));
|
|
100
|
+
return parsed.browserVersion;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
74
106
|
function hasHar(dir) {
|
|
75
107
|
if (!fs.existsSync(dir))
|
|
76
108
|
return false;
|
|
@@ -137,6 +169,7 @@ export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
|
|
|
137
169
|
export function writeMapManifest(options) {
|
|
138
170
|
const cwd = options.cwd ?? process.cwd();
|
|
139
171
|
const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
|
|
172
|
+
const browserVersion = readBrowserBuildSidecar(options.dir);
|
|
140
173
|
const manifest = {
|
|
141
174
|
version: 1,
|
|
142
175
|
packageVersion: input.packageVersion,
|
|
@@ -147,6 +180,7 @@ export function writeMapManifest(options) {
|
|
|
147
180
|
...(input.lockfile ? { lockfile: input.lockfile } : {}),
|
|
148
181
|
...(input.lockfileHash ? { lockfileHash: input.lockfileHash } : {}),
|
|
149
182
|
...(input.playwrightVersion ? { playwrightVersion: input.playwrightVersion } : {}),
|
|
183
|
+
...(browserVersion ? { browserVersion } : {}),
|
|
150
184
|
platform: input.platform,
|
|
151
185
|
arch: input.arch,
|
|
152
186
|
nodeMajor: input.nodeMajor,
|
|
@@ -186,12 +220,20 @@ export function assertCompatibleMapDirs(beforeDir, afterDir) {
|
|
|
186
220
|
playwrightVersion: after.playwrightVersion ?? '',
|
|
187
221
|
baseUrl: after.baseUrl ?? '',
|
|
188
222
|
};
|
|
223
|
+
// Browser build is the actual renderer, but it's optional: only compare when BOTH sides
|
|
224
|
+
// carry it, so bundles cached before this field existed stay comparable to each other.
|
|
225
|
+
// A field on one side only can't be a proven mismatch.
|
|
226
|
+
if (before.browserVersion && after.browserVersion) {
|
|
227
|
+
beforeRuntime.browserVersion = before.browserVersion;
|
|
228
|
+
afterRuntime.browserVersion = after.browserVersion;
|
|
229
|
+
}
|
|
189
230
|
if (JSON.stringify(beforeRuntime) === JSON.stringify(afterRuntime))
|
|
190
231
|
return;
|
|
232
|
+
const build = (m) => (m.browserVersion ? `, browser ${m.browserVersion}` : '');
|
|
191
233
|
throw new MapStoreError([
|
|
192
234
|
'maps were captured in different runtime environments',
|
|
193
|
-
`before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'})`,
|
|
194
|
-
`after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'})`,
|
|
235
|
+
`before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'}${build(before)})`,
|
|
236
|
+
`after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'}${build(after)})`,
|
|
195
237
|
'Next: rebuild one side with styleproof-map in the same environment, or let CI recapture both maps.',
|
|
196
238
|
].join('\n'));
|
|
197
239
|
}
|
package/dist/report.js
CHANGED
|
@@ -385,6 +385,14 @@ export function prettyLabel(p, cls) {
|
|
|
385
385
|
const first = cls.split(/\s+/)[0] ?? '';
|
|
386
386
|
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
387
387
|
}
|
|
388
|
+
// Surface keys originate from artifact filenames — attacker-controlled in the
|
|
389
|
+
// fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
|
|
390
|
+
// (the Action slices report.md above the first `### `). Strip the Markdown/HTML
|
|
391
|
+
// control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
|
|
392
|
+
// or table into that bot comment. Escaping at the render boundary — the keys stay
|
|
393
|
+
// legible; only the injection surface is removed. (Crop FILENAMES are separately
|
|
394
|
+
// restricted to [a-z0-9-]; this is the display-side equivalent.)
|
|
395
|
+
const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
|
|
388
396
|
const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
389
397
|
const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
390
398
|
function pushSurfaceWidth(byBase, base, surface) {
|
|
@@ -396,7 +404,7 @@ function renderSurfaceGroups(byBase) {
|
|
|
396
404
|
return [...byBase]
|
|
397
405
|
.map(([base, ws]) => {
|
|
398
406
|
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
399
|
-
return widths.length ? `${base} @ ${widths.join(', ')}` : base;
|
|
407
|
+
return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
|
|
400
408
|
})
|
|
401
409
|
.join(' · ');
|
|
402
410
|
}
|
|
@@ -502,9 +510,26 @@ function regionHeading(regionPaths, findings) {
|
|
|
502
510
|
const label = anchors.length > 1 ? `\`${head}\` + ${anchors.length - 1} more` : `\`${head}\``;
|
|
503
511
|
return `${label} · ${groupTitle(findings)}`;
|
|
504
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
|
+
}
|
|
505
530
|
// A "no value here" marker renders as an em dash; colours render as `#hex` so the
|
|
506
531
|
// table cell shows GitHub's live swatch.
|
|
507
|
-
const cell = (v) => (isNonValue(v) ? '—' :
|
|
532
|
+
const cell = (v) => (isNonValue(v) ? '—' : codeValue(toHex(v)));
|
|
508
533
|
// Long values (gradients, data URIs) would swamp the table, but truncating each
|
|
509
534
|
// side independently can show two IDENTICAL cells for a real diff: both
|
|
510
535
|
// sides of a gradient rendered as the same rgba while the actual change — a
|
|
@@ -537,7 +562,7 @@ function cellPair(before, after) {
|
|
|
537
562
|
if (isNonValue(before) || isNonValue(after))
|
|
538
563
|
return [cell(before), cell(after)];
|
|
539
564
|
const [b, a] = excerptPair(before, after);
|
|
540
|
-
return [
|
|
565
|
+
return [codeValue(toHex(b)), codeValue(toHex(a))];
|
|
541
566
|
}
|
|
542
567
|
function beforeAfterTable(rows) {
|
|
543
568
|
return [
|
|
@@ -545,14 +570,14 @@ function beforeAfterTable(rows) {
|
|
|
545
570
|
'| --- | --- | --- |',
|
|
546
571
|
...rows.map((r) => {
|
|
547
572
|
const [b, a] = cellPair(r.before, r.after);
|
|
548
|
-
return `|
|
|
573
|
+
return `| ${codeValue(r.prop)} | ${b} | ${a} |`;
|
|
549
574
|
}),
|
|
550
575
|
];
|
|
551
576
|
}
|
|
552
577
|
// A brand-new element has no meaningful "before", so its resting style renders
|
|
553
578
|
// value-only (the After column), mirroring the added-element interaction-states table.
|
|
554
579
|
function valueTable(rows) {
|
|
555
|
-
return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `|
|
|
580
|
+
return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| ${codeValue(r.prop)} | ${cell(r.after)} |`)];
|
|
556
581
|
}
|
|
557
582
|
/** `Button (variant=primary, size=sm)` — the React component + sanitized props
|
|
558
583
|
* the element captured (advisory; present only with captureComponent). */
|
|
@@ -579,8 +604,8 @@ function statesSection(states, added) {
|
|
|
579
604
|
for (const c of summarizeProps(st.props)) {
|
|
580
605
|
const [b, a] = cellPair(c.before, c.after);
|
|
581
606
|
rows.push(added
|
|
582
|
-
? `|
|
|
583
|
-
: `|
|
|
607
|
+
? `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${cell(c.after)} |`
|
|
608
|
+
: `| ${codeValue(`:${st.state}`)} | ${codeValue(c.prop)} | ${b} → ${a} |`);
|
|
584
609
|
}
|
|
585
610
|
if (!rows.length)
|
|
586
611
|
return [];
|
|
@@ -821,7 +846,7 @@ function renderContentSurface(ctx, surface, changes, seq) {
|
|
|
821
846
|
const mapB = loadStyleMap(findCapture(ctx.afterDir, surface));
|
|
822
847
|
const pngA = readPng(path.join(ctx.beforeDir, `${surface}.png`));
|
|
823
848
|
const pngB = readPng(path.join(ctx.afterDir, `${surface}.png`));
|
|
824
|
-
const md = ['', `### \`${surface}\` · ${changes.length} content change(s)`];
|
|
849
|
+
const md = ['', `### \`${safeKey(surface)}\` · ${changes.length} content change(s)`];
|
|
825
850
|
for (const c of changes) {
|
|
826
851
|
seq++;
|
|
827
852
|
md.push('', `**\`${prettyLabel(c.path, c.cls)}\`**`, '', `- before: \`${clipText(c.before) || '(empty)'}\``, `- after: \`${clipText(c.after) || '(empty)'}\``, ...contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq));
|
|
@@ -888,7 +913,7 @@ function coverageLine(cov) {
|
|
|
888
913
|
if (cov.basis === 'complete')
|
|
889
914
|
return `- **Coverage** — ✓ complete (all ${cov.registrySize} registered surface(s) captured)`;
|
|
890
915
|
if (cov.basis === 'incomplete')
|
|
891
|
-
return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.join(', ')})`;
|
|
916
|
+
return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.map(safeKey).join(', ')})`;
|
|
892
917
|
return '- **Coverage** — ⚠ not asserted (no `expected` registry; certifies only the captured surfaces)';
|
|
893
918
|
}
|
|
894
919
|
function determinismLine(det) {
|
|
@@ -900,7 +925,7 @@ function determinismLine(det) {
|
|
|
900
925
|
}
|
|
901
926
|
function inventoryLine(inv) {
|
|
902
927
|
if (inv.unexplained.length > 0) {
|
|
903
|
-
const keys = inv.unexplained.map((i) => i.key);
|
|
928
|
+
const keys = inv.unexplained.map((i) => safeKey(i.key));
|
|
904
929
|
return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
|
|
905
930
|
}
|
|
906
931
|
if (inv.delta.removed.length > 0)
|
|
@@ -1176,7 +1201,7 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
1176
1201
|
const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
|
|
1177
1202
|
const md = [
|
|
1178
1203
|
'',
|
|
1179
|
-
`### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`,
|
|
1204
|
+
`### \`${safeKey(p.sd.surface)}\` · new surface ${NEW_SURFACE_MARKER}`,
|
|
1180
1205
|
'',
|
|
1181
1206
|
`_${formatSurfaceWithContext(p.sd.surface, map)}_`,
|
|
1182
1207
|
];
|
|
@@ -1214,7 +1239,7 @@ function cappedNoticeLines(budget) {
|
|
|
1214
1239
|
* name (and how many surfaces share the identical change) · change count · a crop
|
|
1215
1240
|
* link so the reviewer can still see it without opening report.json. */
|
|
1216
1241
|
function compactChangeSummary(cg, json, img) {
|
|
1217
|
-
const surface = cg.rep.sd.surface;
|
|
1242
|
+
const surface = safeKey(cg.rep.sd.surface);
|
|
1218
1243
|
const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
|
|
1219
1244
|
const regions = json.regions ?? [];
|
|
1220
1245
|
const composite = regions[0]?.images?.composite;
|
|
@@ -1317,7 +1342,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1317
1342
|
const r = renderNewSurface(p, ctx, cropSeq);
|
|
1318
1343
|
json.push(r.json);
|
|
1319
1344
|
cropSeq = r.cropSeq;
|
|
1320
|
-
emitDetail(r.md, `- \`${p.sd.surface}\` · new surface`);
|
|
1345
|
+
emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
|
|
1321
1346
|
}
|
|
1322
1347
|
md.push(...contentSection.md);
|
|
1323
1348
|
const reportMdPath = path.join(outDir, 'report.md');
|