styleproof 3.18.0 → 3.20.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 +191 -0
- package/README.md +62 -6
- package/bin/styleproof-capture.mjs +4 -1
- package/bin/styleproof-diff.mjs +41 -32
- package/bin/styleproof-init.mjs +49 -20
- 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/cli-errors.js +7 -2
- 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 +67 -7
- package/dist/danger.d.ts +13 -0
- package/dist/danger.js +13 -0
- package/dist/diff.js +38 -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 +159 -35
- package/dist/variant-crawler.js +16 -4
- package/package.json +2 -2
package/dist/diff.js
CHANGED
|
@@ -63,14 +63,51 @@ const ORIGIN_EPSILON_PX = 0.05;
|
|
|
63
63
|
function sameRect(a, b) {
|
|
64
64
|
return !!a && !!b && a.every((v, i) => v === b[i]);
|
|
65
65
|
}
|
|
66
|
+
const HORIZONTAL_MARGIN_PAIRS = [
|
|
67
|
+
['margin-left', 'margin-right'],
|
|
68
|
+
['margin-inline-start', 'margin-inline-end'],
|
|
69
|
+
];
|
|
70
|
+
function marginPxDelta(p) {
|
|
71
|
+
const before = pxParts(p.before);
|
|
72
|
+
const after = pxParts(p.after);
|
|
73
|
+
return before?.length === 1 && after?.length === 1 ? after[0] - before[0] : null;
|
|
74
|
+
}
|
|
75
|
+
// True when one horizontal side moved by a different px amount than its
|
|
76
|
+
// opposite. Such a change would shift the box on its own, so an *identical*
|
|
77
|
+
// rect means something else compensated — a real restyle, not layout-equivalent
|
|
78
|
+
// drift. Non-px or state-sentinel values (`(state no longer changes it)`) aren't
|
|
79
|
+
// demonstrable, so they fall through to the balanced (drop) path unchanged.
|
|
80
|
+
function marginChangeHasPxImbalance(props) {
|
|
81
|
+
const delta = new Map();
|
|
82
|
+
for (const p of props) {
|
|
83
|
+
if (LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop))
|
|
84
|
+
delta.set(p.prop, marginPxDelta(p));
|
|
85
|
+
}
|
|
86
|
+
for (const [start, end] of HORIZONTAL_MARGIN_PAIRS) {
|
|
87
|
+
const ds = delta.has(start) ? delta.get(start) : 0;
|
|
88
|
+
const de = delta.has(end) ? delta.get(end) : 0;
|
|
89
|
+
if (ds !== null && de !== null && ds !== de)
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
66
94
|
function dropLayoutEquivalentMarginProps(props, a, b) {
|
|
67
95
|
if (!sameRect(a?.rect, b?.rect))
|
|
68
96
|
return props;
|
|
97
|
+
// ponytail: a balanced margin change with an unchanged rect is treated as
|
|
98
|
+
// layout-equivalent from computed style alone. That still drops the rare case
|
|
99
|
+
// where a *balanced* change was held in place by external compensation — a
|
|
100
|
+
// consciously-deferred, low-reach soundness corner; closing it needs
|
|
101
|
+
// cross-element layout reasoning. The common one-sided case is caught here.
|
|
102
|
+
if (marginChangeHasPxImbalance(props))
|
|
103
|
+
return props;
|
|
69
104
|
return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
|
|
70
105
|
}
|
|
71
106
|
function pxParts(value) {
|
|
72
107
|
const parts = value.trim().split(/\s+/);
|
|
73
|
-
|
|
108
|
+
// 1–3 components: a single-value origin (`50px`) jitters the same way as the
|
|
109
|
+
// 2/3-component form and must be suppressed identically.
|
|
110
|
+
if (parts.length < 1 || parts.length > 3)
|
|
74
111
|
return null;
|
|
75
112
|
const values = parts.map((part) => {
|
|
76
113
|
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';
|
|
@@ -104,7 +104,7 @@ export function resolvePopupCaptureOptions(input) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
async function popupDomSnapshot(page, options) {
|
|
107
|
-
return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0 }) => {
|
|
107
|
+
return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0, relocatePath, relocateLabel }) => {
|
|
108
108
|
const qsa = (sel) => {
|
|
109
109
|
try {
|
|
110
110
|
return [...document.querySelectorAll(sel)];
|
|
@@ -133,6 +133,13 @@ async function popupDomSnapshot(page, options) {
|
|
|
133
133
|
}
|
|
134
134
|
return parts.join(' > ');
|
|
135
135
|
};
|
|
136
|
+
// The trigger's accessible name — mirrors crawl-surfaces' labelFor (aria-label
|
|
137
|
+
// || name || textContent || title). `path` is positional for an id-less trigger;
|
|
138
|
+
// this pins identity so a shifted same-tag sibling can't silently steal the bind.
|
|
139
|
+
const labelOf = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || el.getAttribute('title') || '')
|
|
140
|
+
.replace(/\s+/g, ' ')
|
|
141
|
+
.trim()
|
|
142
|
+
.slice(0, 80) || el.tagName.toLowerCase();
|
|
136
143
|
const popupKey = (el) => {
|
|
137
144
|
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
138
145
|
return [
|
|
@@ -150,7 +157,15 @@ async function popupDomSnapshot(page, options) {
|
|
|
150
157
|
const popups = qsa(popupSelector).filter(visible);
|
|
151
158
|
const keys = popups.map(popupKey);
|
|
152
159
|
if (!triggerSelector || !attr)
|
|
153
|
-
return { keys,
|
|
160
|
+
return { keys, candidates: [], found: false };
|
|
161
|
+
for (const el of qsa(`[${attr}]`))
|
|
162
|
+
el.removeAttribute(attr);
|
|
163
|
+
if (relocatePath) {
|
|
164
|
+
const target = qsa(triggerSelector).find((el) => pathOf(el) === relocatePath && labelOf(el) === relocateLabel);
|
|
165
|
+
if (target)
|
|
166
|
+
target.setAttribute(attr, 'target');
|
|
167
|
+
return { keys, candidates: [], found: Boolean(target) };
|
|
168
|
+
}
|
|
154
169
|
const safeTrigger = (el) => {
|
|
155
170
|
const tag = el.tagName.toLowerCase();
|
|
156
171
|
if (el.matches(':disabled, [aria-disabled="true"]'))
|
|
@@ -161,24 +176,30 @@ async function popupDomSnapshot(page, options) {
|
|
|
161
176
|
return el.getAttribute('href')?.startsWith('#') ?? false;
|
|
162
177
|
return true;
|
|
163
178
|
};
|
|
164
|
-
|
|
165
|
-
el.
|
|
166
|
-
|
|
167
|
-
candidates.
|
|
168
|
-
return {
|
|
179
|
+
const candidates = qsa(triggerSelector)
|
|
180
|
+
.filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el))
|
|
181
|
+
.slice(0, max);
|
|
182
|
+
candidates.forEach((el, index) => el.setAttribute(attr, String(index)));
|
|
183
|
+
return {
|
|
184
|
+
keys,
|
|
185
|
+
candidates: candidates.map((el, index) => ({ index, path: pathOf(el), label: labelOf(el) })),
|
|
186
|
+
found: false,
|
|
187
|
+
};
|
|
169
188
|
}, options);
|
|
170
189
|
}
|
|
171
190
|
async function visiblePopupKeys(page, selector) {
|
|
172
191
|
return (await popupDomSnapshot(page, { popupSelector: selector })).keys;
|
|
173
192
|
}
|
|
193
|
+
/** Enumerate + mark the surface's popup triggers ONCE, and record the pristine
|
|
194
|
+
* overlay keys in the same DOM snapshot — the reset baseline every reopen is
|
|
195
|
+
* verified against. */
|
|
174
196
|
async function markPopupCandidates(page, options) {
|
|
175
|
-
|
|
197
|
+
return popupDomSnapshot(page, {
|
|
176
198
|
triggerSelector: options.triggers,
|
|
177
199
|
popupSelector: options.overlays,
|
|
178
200
|
attr: POPUP_TRIGGER_ATTR,
|
|
179
201
|
max: options.max,
|
|
180
202
|
});
|
|
181
|
-
return snapshot.indexes.map((index) => ({ index }));
|
|
182
203
|
}
|
|
183
204
|
function expandOne(surface, variant, variantKind) {
|
|
184
205
|
return {
|
|
@@ -210,6 +231,36 @@ export function expandSurfaceVariants(surface) {
|
|
|
210
231
|
return [baseSurface, ...expandedVariants];
|
|
211
232
|
return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
|
|
212
233
|
}
|
|
234
|
+
/** Human-readable origin of an expanded surface for a collision message. */
|
|
235
|
+
function expandedOrigin(s) {
|
|
236
|
+
const surfaceKey = s.metadata?.surfaceKey ?? s.key;
|
|
237
|
+
const variantKey = s.metadata?.variantKey;
|
|
238
|
+
return variantKey ? `surface '${surfaceKey}' variant '${variantKey}'` : `surface '${surfaceKey}'`;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Fail LOUDLY on two expanded surfaces sharing a capture key.
|
|
242
|
+
*
|
|
243
|
+
* The expanded key is `surface.key-variant.key`, and that key is the map filename
|
|
244
|
+
* (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
|
|
245
|
+
* change without breaking backward compatibility. But the `-` join is ambiguous:
|
|
246
|
+
* surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
|
|
247
|
+
* `a-b-c`, and the second capture would silently overwrite the first, dropping a
|
|
248
|
+
* surface with no error. Rather than mangle the public key format, we assert
|
|
249
|
+
* uniqueness up front and name BOTH origins so the author can rename one.
|
|
250
|
+
*/
|
|
251
|
+
export function assertUniqueExpandedKeys(surfaces) {
|
|
252
|
+
const byKey = new Map();
|
|
253
|
+
for (const s of surfaces) {
|
|
254
|
+
const prior = byKey.get(s.key);
|
|
255
|
+
if (prior) {
|
|
256
|
+
throw new Error(`styleproof: capture key '${s.key}' is produced by two surfaces — ` +
|
|
257
|
+
`${expandedOrigin(prior)} collides with ${expandedOrigin(s)}. ` +
|
|
258
|
+
`Keys must expand uniquely (they name the map files and report entries); ` +
|
|
259
|
+
`rename one surface or variant.`);
|
|
260
|
+
}
|
|
261
|
+
byKey.set(s.key, s);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
213
264
|
/**
|
|
214
265
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
215
266
|
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
@@ -278,16 +329,37 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
|
|
|
278
329
|
throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
|
|
279
330
|
}
|
|
280
331
|
}
|
|
281
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Reset the surface (Escape + `go()`), then open ONE candidate's popup.
|
|
334
|
+
*
|
|
335
|
+
* Two guarantees the naive open loop lacked:
|
|
336
|
+
* - the reset is VERIFIED against the pristine overlay keys, not assumed —
|
|
337
|
+
* Escape is not a universal close, and a non-navigating `go()` clears nothing;
|
|
338
|
+
* - the trigger is re-bound by the (path, label) identity recorded at first
|
|
339
|
+
* enumeration, never by its position in a fresh enumeration (the trigger set can
|
|
340
|
+
* shift between opens and silently key the popup under a different trigger; the
|
|
341
|
+
* label pins identity where the path alone is still positional for id-less triggers).
|
|
342
|
+
* Either check failing is reported for the caller to skip loudly.
|
|
343
|
+
*/
|
|
344
|
+
async function openPopupCandidate(page, surface, width, height, options, candidate, pristine) {
|
|
282
345
|
await page.setViewportSize({ width, height });
|
|
283
346
|
await page.keyboard.press('Escape').catch(() => { });
|
|
284
347
|
await surface.go(page);
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
348
|
+
const snapshot = await popupDomSnapshot(page, {
|
|
349
|
+
popupSelector: options.overlays,
|
|
350
|
+
triggerSelector: options.triggers,
|
|
351
|
+
attr: POPUP_TRIGGER_ATTR,
|
|
352
|
+
relocatePath: candidate.path,
|
|
353
|
+
relocateLabel: candidate.label,
|
|
354
|
+
});
|
|
355
|
+
const leaked = snapshot.keys.filter((key) => !pristine.has(key));
|
|
356
|
+
if (leaked.length)
|
|
357
|
+
return { status: 'leaked', leaked };
|
|
358
|
+
if (!snapshot.found)
|
|
359
|
+
return { status: 'missing' };
|
|
360
|
+
const before = new Set(snapshot.keys);
|
|
289
361
|
await page
|
|
290
|
-
.locator(`[${POPUP_TRIGGER_ATTR}="
|
|
362
|
+
.locator(`[${POPUP_TRIGGER_ATTR}="target"]`)
|
|
291
363
|
.first()
|
|
292
364
|
.click({ timeout: Math.max(500, options.timeoutMs), noWaitAfter: true })
|
|
293
365
|
.catch(() => undefined);
|
|
@@ -295,10 +367,19 @@ async function openPopupCandidate(page, surface, width, height, options, candida
|
|
|
295
367
|
do {
|
|
296
368
|
const opened = (await visiblePopupKeys(page, options.overlays)).find((key) => !before.has(key));
|
|
297
369
|
if (opened)
|
|
298
|
-
return opened;
|
|
370
|
+
return { status: 'opened', key: opened };
|
|
299
371
|
await page.waitForTimeout(50);
|
|
300
372
|
} while (Date.now() < deadline);
|
|
301
|
-
return
|
|
373
|
+
return { status: 'none' };
|
|
374
|
+
}
|
|
375
|
+
/** A popup candidate skipped instead of captured wrong must be NAMED — a silent
|
|
376
|
+
* skip reads as "nothing to capture" when the truth is "couldn't capture safely". */
|
|
377
|
+
function warnPopupSkipped(surface, popupId, width, reason) {
|
|
378
|
+
// eslint-disable-next-line no-console
|
|
379
|
+
console.warn(`styleproof: skipped ${surface.key}-${popupId}@${width} — ${reason}`);
|
|
380
|
+
}
|
|
381
|
+
function leakedOverlaysDesc(leaked) {
|
|
382
|
+
return `overlay(s) the reset (Escape + go()) could not clear: ${leaked.join('; ')}`;
|
|
302
383
|
}
|
|
303
384
|
function popupMetadata(surface, popupId) {
|
|
304
385
|
return {
|
|
@@ -316,26 +397,52 @@ async function captureOpenedPopupMap(page, surface, s, pending, popupId) {
|
|
|
316
397
|
metadata: popupMetadata(surface, popupId),
|
|
317
398
|
});
|
|
318
399
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
400
|
+
/** Reopen the popup and throw on drift/no-reopen. Returns the leaked overlay keys
|
|
401
|
+
* instead when the popup itself defeats the reset (e.g. it IS a toast Escape can't
|
|
402
|
+
* dismiss) — the reopen can't run, so the caller discards the capture loudly
|
|
403
|
+
* rather than saving a map whose determinism was never proven. */
|
|
404
|
+
async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending, pristine) {
|
|
405
|
+
const reopened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
|
|
406
|
+
if (reopened.status === 'leaked')
|
|
407
|
+
return reopened.leaked;
|
|
408
|
+
if (reopened.status !== 'opened') {
|
|
409
|
+
throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen` +
|
|
410
|
+
(reopened.status === 'missing' ? ' (its trigger disappeared from the DOM or changed identity)' : ''));
|
|
411
|
+
}
|
|
323
412
|
const again = await captureOpenedPopupMap(page, surface, s, pending, popupId);
|
|
324
413
|
const drift = diffStyleMaps(first, again);
|
|
325
414
|
if (drift.length) {
|
|
326
415
|
throw new Error(selfCheckErrorMessage(`${surface.key}-${popupId}`, drift, first.volatile, first.liveCandidates));
|
|
327
416
|
}
|
|
417
|
+
return undefined;
|
|
328
418
|
}
|
|
329
|
-
async function capturePopupCandidate(page, surface, width, height, s, options, candidate) {
|
|
419
|
+
async function capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine) {
|
|
330
420
|
const requests = trackInflightRequests(page);
|
|
421
|
+
const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
|
|
331
422
|
try {
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
423
|
+
const opened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
|
|
424
|
+
if (opened.status === 'none')
|
|
425
|
+
return;
|
|
426
|
+
if (opened.status === 'leaked') {
|
|
427
|
+
warnPopupSkipped(surface, popupId, width, `${leakedOverlaysDesc(opened.leaked)} — capturing now would include the previous ` +
|
|
428
|
+
`popup's residue. Dismiss it in the surface's go(), or capture it as an explicit variant.`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (opened.status === 'missing') {
|
|
432
|
+
warnPopupSkipped(surface, popupId, width, `its originally-enumerated trigger is no longer identifiable after the reset (Escape + go()) — ` +
|
|
433
|
+
`gone from the DOM, or a shifted same-tag sibling no longer matches its recorded label; ` +
|
|
434
|
+
`skipping rather than re-binding to a different trigger.`);
|
|
334
435
|
return;
|
|
335
|
-
|
|
436
|
+
}
|
|
336
437
|
const map = await captureOpenedPopupMap(page, surface, s, requests.pending, popupId);
|
|
337
|
-
if (s.selfCheck)
|
|
338
|
-
await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending);
|
|
438
|
+
if (s.selfCheck) {
|
|
439
|
+
const leaked = await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending, pristine);
|
|
440
|
+
if (leaked) {
|
|
441
|
+
warnPopupSkipped(surface, popupId, width, `reopening for the self-check found ${leakedOverlaysDesc(leaked)} — the popup itself ` +
|
|
442
|
+
`defeats the reset, so its determinism can't be verified and the capture is discarded.`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
339
446
|
const stem = path.join(s.baseDir, s.dir, `${surface.key}-${popupId}@${width}`);
|
|
340
447
|
saveStyleMap(`${stem}.json.gz`, map);
|
|
341
448
|
if (s.screenshots)
|
|
@@ -350,9 +457,12 @@ async function capturePopupSurfaces(page, surface, width, height, s) {
|
|
|
350
457
|
if (!options.enabled || options.max === 0)
|
|
351
458
|
return;
|
|
352
459
|
await surface.go(page);
|
|
353
|
-
const candidates = await markPopupCandidates(page, options);
|
|
460
|
+
const { keys, candidates } = await markPopupCandidates(page, options);
|
|
461
|
+
// Overlays legitimately visible in the surface's settled state (e.g. a permanent
|
|
462
|
+
// status region) — every reopen is verified back to this baseline before capture.
|
|
463
|
+
const pristine = new Set(keys);
|
|
354
464
|
for (const candidate of candidates) {
|
|
355
|
-
await capturePopupCandidate(page, surface, width, height, s, options, candidate);
|
|
465
|
+
await capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine);
|
|
356
466
|
}
|
|
357
467
|
}
|
|
358
468
|
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
|
@@ -459,7 +569,7 @@ function resolveSettings(c) {
|
|
|
459
569
|
* `expected: null` records that the spec declared no registry, so a green can only
|
|
460
570
|
* certify the captured surfaces. Runs on a capture run (dir set) only.
|
|
461
571
|
*/
|
|
462
|
-
function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
572
|
+
function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfaces) {
|
|
463
573
|
test('styleproof coverage ledger', () => {
|
|
464
574
|
const outDir = path.join(settings.baseDir, dir);
|
|
465
575
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -470,7 +580,11 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
|
470
580
|
: settings.replayFrom
|
|
471
581
|
? 'replayed'
|
|
472
582
|
: 'unproven';
|
|
473
|
-
|
|
583
|
+
// Pre-translate the declared universe into the keys actually captured to disk, so
|
|
584
|
+
// the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
|
|
585
|
+
// compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
|
|
586
|
+
const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
|
|
587
|
+
const ledger = { version: 1, expected: ledgerExpected, exclude, determinism };
|
|
474
588
|
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
475
589
|
});
|
|
476
590
|
}
|
|
@@ -489,6 +603,7 @@ export function defineStyleMapCapture(options) {
|
|
|
489
603
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
490
604
|
const settings = resolveSettings(options);
|
|
491
605
|
const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
|
|
606
|
+
assertUniqueExpandedKeys(captureSurfaces);
|
|
492
607
|
// Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
|
|
493
608
|
// a route added without a surface fails the app's own tests — long before, and
|
|
494
609
|
// independent of, a capture run. This is the one gap captures can't catch: a
|
|
@@ -497,7 +612,10 @@ export function defineStyleMapCapture(options) {
|
|
|
497
612
|
if (expected) {
|
|
498
613
|
test.describe('styleproof coverage', () => {
|
|
499
614
|
test('every expected surface is captured or explicitly excluded', () => {
|
|
500
|
-
const { uncovered, staleExclusions } = coverageGaps(
|
|
615
|
+
const { uncovered, staleExclusions } = coverageGaps(
|
|
616
|
+
// A liveStates surface is captured only as its `-loading`/`-loaded` splits;
|
|
617
|
+
// map each back to the declared base key so the split satisfies `expected`.
|
|
618
|
+
coverageKeys(captureSurfaces), expected, exclude);
|
|
501
619
|
expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
|
|
502
620
|
`nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
|
|
503
621
|
`Missing: ${uncovered.join(', ')}`).toEqual([]);
|
|
@@ -509,7 +627,7 @@ export function defineStyleMapCapture(options) {
|
|
|
509
627
|
test.describe('styleproof capture', () => {
|
|
510
628
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
511
629
|
if (dir)
|
|
512
|
-
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
630
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, captureSurfaces);
|
|
513
631
|
if (dir)
|
|
514
632
|
writeBrowserBuildTest(settings, dir);
|
|
515
633
|
for (const surface of captureSurfaces) {
|
|
@@ -623,8 +741,13 @@ export function defineCrawlCapture(options) {
|
|
|
623
741
|
// captures what the nav links to, and can't prove that's every route). With
|
|
624
742
|
// `expected` the crawl reconciles the DISCOVERED link set against it below and the
|
|
625
743
|
// ledger travels with the declared universe.
|
|
744
|
+
// The crawl applies the SAME variants/liveStates to every discovered link, so the
|
|
745
|
+
// expansion of a declared key is knowable up front (before discovery): expand each
|
|
746
|
+
// `expected` key with this crawl's variants/liveStates to get the keys captured to
|
|
747
|
+
// disk, so a liveStates crawl's ledger is pre-translated like the spec-driven one.
|
|
748
|
+
const ledgerSurfaces = (expected ?? []).flatMap((key) => expandSurfaceVariants({ key, go: async () => { }, variants, liveStates }));
|
|
626
749
|
if (dir)
|
|
627
|
-
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
750
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, ledgerSurfaces);
|
|
628
751
|
if (dir)
|
|
629
752
|
writeBrowserBuildTest(settings, dir);
|
|
630
753
|
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
@@ -654,6 +777,7 @@ export function defineCrawlCapture(options) {
|
|
|
654
777
|
liveStates,
|
|
655
778
|
popups,
|
|
656
779
|
}));
|
|
780
|
+
assertUniqueExpandedKeys(captureSurfaces);
|
|
657
781
|
// Budget the whole sweep up front: one test captures every surface, and
|
|
658
782
|
// captureSurface no longer sets its own timeout, so size it to the work found.
|
|
659
783
|
// 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.20.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",
|
|
@@ -90,6 +90,6 @@
|
|
|
90
90
|
"typescript": "^6.0.3",
|
|
91
91
|
"typescript-eslint": "^8.18.0",
|
|
92
92
|
"husky": "^9.1.7",
|
|
93
|
-
"fallow": "^2.
|
|
93
|
+
"fallow": "^3.2.0"
|
|
94
94
|
}
|
|
95
95
|
}
|