styleproof 4.1.0 → 4.2.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 +41 -0
- package/README.md +11 -5
- package/bin/styleproof-capture.mjs +121 -45
- package/bin/styleproof-diff.mjs +7 -1
- package/dist/capture-url.d.ts +3 -0
- package/dist/capture-url.js +4 -0
- package/dist/crawl-surfaces.d.ts +6 -0
- package/dist/crawl-surfaces.js +15 -5
- package/dist/crawl.d.ts +17 -0
- package/dist/crawl.js +1 -1
- package/dist/map-store.js +30 -12
- package/dist/report.js +155 -32
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [4.2.0] - 2026-07-13
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`styleproof-capture --crawl` now follows same-origin nav links.** Every
|
|
15
|
+
page the site links to is crawled like the entry page, keyed by its route
|
|
16
|
+
(`about`, `pricing`, …), with class coverage aggregated across the pages that
|
|
17
|
+
share stylesheets. Previously the CLI crawl drove controls but silently
|
|
18
|
+
dropped links, so a multi-page site reported "1/1 surfaces, coverage ✓" while
|
|
19
|
+
losing every other page. `--no-follow-links` restores the entry-page-only
|
|
20
|
+
sweep.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **A non-head checkout in CI is no longer stamped with the pull request's head
|
|
25
|
+
SHA.** `currentGitSha` now trusts `git rev-parse HEAD` and relabels only a
|
|
26
|
+
checkout of the synthetic `GITHUB_SHA` merge commit, so a base-branch capture
|
|
27
|
+
(the scaffolded cache-miss job) can never publish a base-tree map under the
|
|
28
|
+
head's store key — a store poisoning that made later restores diff
|
|
29
|
+
base-vs-base and report a false green. `pull_request_target` payloads are no
|
|
30
|
+
longer trusted for relabeling at all, and a malformed `STYLEPROOF_SHA`
|
|
31
|
+
override now errors instead of silently falling back.
|
|
32
|
+
- **Annotation move-suppression now requires provable displacement.** A
|
|
33
|
+
cross-path annotation match may suppress its magenta boxes only when the
|
|
34
|
+
container where the two paths diverge gained or lost captured children, or
|
|
35
|
+
when the element slid into a vacated slot in its own container. This fixes
|
|
36
|
+
three truthfulness bugs at once: a size-changing duplicate restyle swap no
|
|
37
|
+
longer loses all visual proof, a sibling insertion into a uniform-shell list
|
|
38
|
+
(menus, navs) no longer re-boxes every unchanged displaced item, and an
|
|
39
|
+
independent removal + identical addition in different containers no longer
|
|
40
|
+
cancel each other's annotations to zero.
|
|
41
|
+
- **`styleproof-diff` no longer misreports fresh ad-hoc captures as predating
|
|
42
|
+
the determinism ledger.** The unknown-basis warning now names the real cause:
|
|
43
|
+
ad-hoc `styleproof-capture` output records no ledger; spec-driven captures
|
|
44
|
+
self-check and do.
|
|
45
|
+
|
|
10
46
|
## [4.1.0] - 2026-07-12
|
|
11
47
|
|
|
12
48
|
### Fixed
|
|
@@ -21,6 +57,11 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
21
57
|
and published report links point to the immutable report commit.
|
|
22
58
|
- **Certify-mode report comments now carry their source head marker**, so stale
|
|
23
59
|
comments can be distinguished from the current PR head.
|
|
60
|
+
- **Annotation reconciliation preserves duplicate additions, removals, and
|
|
61
|
+
forced-state changes.** Matching is one-to-one within a structural
|
|
62
|
+
neighborhood, and indistinguishable duplicate provenance is reported as a
|
|
63
|
+
deterministic unmatched occurrence rather than guessed. Ambiguous duplicate
|
|
64
|
+
restyle swaps retain visual proof on both sides.
|
|
24
65
|
|
|
25
66
|
## [4.0.2] - 2026-07-11
|
|
26
67
|
|
package/README.md
CHANGED
|
@@ -217,11 +217,15 @@ summary, and the exact property change folded under a toggle. A change too small
|
|
|
217
217
|
to see at 1:1 (say a 2px icon tweak) also gets a magnified zoom crop, so a
|
|
218
218
|
sub-pixel change can't slip past a reviewer.
|
|
219
219
|
|
|
220
|
-
Annotation boxes reconcile exact-equivalent elements
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
220
|
+
Annotation boxes reconcile exact-equivalent elements one-to-one within a structural
|
|
221
|
+
neighborhood. When an unkeyed sibling insertion shifts unchanged descendants, the
|
|
222
|
+
clean comparison still shows the complete rendered result and the audit keeps every
|
|
223
|
+
structural finding, but magenta boxes mark only unmatched additions, removals, and
|
|
224
|
+
restyles instead of painting the displaced subtree as changed. Visually identical
|
|
225
|
+
duplicates have no provable physical provenance, so the report marks a deterministic
|
|
226
|
+
unmatched occurrence rather than claiming which duplicate moved. When duplicate
|
|
227
|
+
restyles could also be explained as a swap, the report keeps both sides annotated
|
|
228
|
+
because the captured data cannot prove a move.
|
|
225
229
|
|
|
226
230
|
When one change appears both on an ordinary page and in an open popup, the report
|
|
227
231
|
chooses a representative where the changed element is visibly exposed, then prefers
|
|
@@ -757,6 +761,8 @@ You watch one number as you implement: the diff starts large and shrinks toward
|
|
|
757
761
|
|
|
758
762
|
A design is mostly _behind clicks_ — modals, drawers, popovers, tabs that don't exist in the DOM until you open them. A single capture sees only the landing state. `--crawl` maps the rest for you: point it at the URL and it drives every non-destructive control, keeps whatever opens a structurally new surface, and recurses into it — a modal's tabs, a drawer's sub-views, a popover's panels — capturing each under a derived key. No spec, no selectors, no hand-holding.
|
|
759
763
|
|
|
764
|
+
It follows the nav too: every same-origin page the site links to is crawled the same way, keyed by its route (`about`, `pricing`, `blog-post`, …), with class coverage aggregated across the pages that share stylesheets. `--no-follow-links` keeps the sweep to the entry page's interactive surface only.
|
|
765
|
+
|
|
760
766
|
```bash
|
|
761
767
|
styleproof-capture https://example.com --crawl --out design # maps every reachable surface
|
|
762
768
|
styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
|
|
@@ -20,6 +20,7 @@ import { chromium } from '@playwright/test';
|
|
|
20
20
|
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
21
21
|
import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
|
|
22
22
|
import { crawlAndCapture } from '../dist/crawl-surfaces.js';
|
|
23
|
+
import { selectCrawlLinks, dedupIdentity } from '../dist/crawl.js';
|
|
23
24
|
import { writeCaptureManifest } from '../dist/map-store.js';
|
|
24
25
|
|
|
25
26
|
const COMMAND = 'styleproof-capture';
|
|
@@ -56,6 +57,9 @@ whole surface: --crawl
|
|
|
56
57
|
fulfilled with 500 → error render)
|
|
57
58
|
--workers <n> concurrent sweep workers (default 4); same surface set as a
|
|
58
59
|
serial crawl — pass 1 for byte-stable key attribution
|
|
60
|
+
--no-follow-links crawl the entry page's interactive surface only. By default
|
|
61
|
+
every same-origin page the nav links to is crawled too,
|
|
62
|
+
each keyed by its route (about, pricing, blog-post, ...)
|
|
59
63
|
--until-covered stop the crawl early the moment every stylesheet class has
|
|
60
64
|
been rendered — a coverage-oriented sweep for design mockups
|
|
61
65
|
--max-depth <n> throttle recursion depth (default: 16 — backstop for
|
|
@@ -95,61 +99,133 @@ try {
|
|
|
95
99
|
throw e;
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
// Read the freshly-loaded page's same-origin nav links, keyed by route.
|
|
103
|
+
async function harvestPageLinks(page, url) {
|
|
104
|
+
await page.goto(url, { waitUntil: 'load' });
|
|
105
|
+
const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href'))).catch(() => []);
|
|
106
|
+
return selectCrawlLinks(hrefs, { base: page.url() });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printCoverage(cov, label) {
|
|
110
|
+
const unreadable = cov.unreadable ?? [];
|
|
111
|
+
if (unreadable.length > 0) {
|
|
112
|
+
console.log(
|
|
113
|
+
`⚠ coverage${label}: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
|
|
114
|
+
`(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (cov.missing.length === 0) {
|
|
118
|
+
if (unreadable.length === 0)
|
|
119
|
+
console.log(
|
|
120
|
+
`✓ coverage${label}: all ${cov.defined} stylesheet classes rendered in at least one captured surface`,
|
|
121
|
+
);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(
|
|
124
|
+
`⚠ coverage${label}: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
|
|
125
|
+
`(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pageCrawlOptions(browser, url, prefix, statesLeft) {
|
|
131
|
+
return {
|
|
132
|
+
url,
|
|
133
|
+
out: opts.out,
|
|
134
|
+
widths: opts.widths, // empty = auto-detect the page's real breakpoints
|
|
135
|
+
ignore: opts.ignore,
|
|
136
|
+
height: opts.height,
|
|
137
|
+
screenshots: opts.screenshots,
|
|
138
|
+
waitSelector: opts.waitSelector,
|
|
139
|
+
maxDepth: opts.maxDepth,
|
|
140
|
+
maxActionsPerState: opts.maxActionsPerState,
|
|
141
|
+
maxStates: statesLeft,
|
|
142
|
+
resetStorage: opts.resetStorage,
|
|
143
|
+
setup: setupSteps,
|
|
144
|
+
dataStates: opts.dataStates,
|
|
145
|
+
stopWhenCovered: opts.untilCovered,
|
|
146
|
+
workers: opts.workers,
|
|
147
|
+
keyPrefix: prefix,
|
|
148
|
+
// each worker page in its OWN context, so storage resets can't interfere
|
|
149
|
+
newPage: async () => (await browser.newContext()).newPage(),
|
|
150
|
+
// Stream each surface as it is captured, so progress is visible live and an
|
|
151
|
+
// interrupted run still shows exactly what it mapped.
|
|
152
|
+
onSurface: (s, ok) =>
|
|
153
|
+
console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Aggregate coverage: pages share stylesheets, so a class unrendered on one
|
|
158
|
+
// page but rendered on another IS covered. defined = rendered ∪ missing.
|
|
159
|
+
function aggregateCoverage(reports) {
|
|
160
|
+
const rendered = new Set(reports.flatMap((r) => r.coverage.renderedClasses));
|
|
161
|
+
const missing = [...new Set(reports.flatMap((r) => r.coverage.missing))].filter((c) => !rendered.has(c)).sort();
|
|
162
|
+
const unreadable = [...new Set(reports.flatMap((r) => r.coverage.unreadable ?? []))];
|
|
163
|
+
return { defined: rendered.size + missing.length, rendered: rendered.size, missing, unreadable };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function printCrawlSummary(reports) {
|
|
167
|
+
const surfaces = reports.reduce((n, r) => n + r.surfaces.length, 0);
|
|
168
|
+
const captured = reports.reduce((n, r) => n + r.captured, 0);
|
|
169
|
+
const tried = reports.reduce((n, r) => n + r.actionsTried, 0);
|
|
170
|
+
const skipped = reports.reduce((n, r) => n + r.skipped, 0);
|
|
171
|
+
const failed = reports.flatMap((r) => r.failed);
|
|
172
|
+
const widths = opts.widths.length ? `${opts.widths.length} width(s)` : 'auto widths';
|
|
173
|
+
console.log(
|
|
174
|
+
`✓ ${captured}/${surfaces} surface(s) across ${reports.length} page(s) × ${widths} → ${opts.out} ` +
|
|
175
|
+
`(${tried} actions tried, ${skipped} skipped${failed.length ? `, ${failed.length} capture-failed` : ''})`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Enqueue every not-yet-seen page the just-crawled page links to, giving each a
|
|
180
|
+
// unique route-key prefix ('base' is reserved for the entry crawl's root).
|
|
181
|
+
function enqueueLinkedPages(links, sweep) {
|
|
182
|
+
for (const link of links) {
|
|
183
|
+
const id = dedupIdentity(link.url);
|
|
184
|
+
if (sweep.seenPages.has(id)) continue;
|
|
185
|
+
sweep.seenPages.add(id);
|
|
186
|
+
let prefix = link.key;
|
|
187
|
+
for (let i = 2; sweep.usedPrefixes.has(prefix); i++) prefix = `${link.key}-${i}`;
|
|
188
|
+
sweep.usedPrefixes.add(prefix);
|
|
189
|
+
sweep.queue.push({ url: new URL(link.url, sweep.entry).href, prefix });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
98
193
|
async function runCrawl() {
|
|
99
194
|
const browser = await chromium.launch();
|
|
100
195
|
try {
|
|
101
196
|
const page = await browser.newPage();
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
maxActionsPerState: opts.maxActionsPerState,
|
|
112
|
-
maxStates: opts.maxStates,
|
|
113
|
-
resetStorage: opts.resetStorage,
|
|
114
|
-
setup: setupSteps,
|
|
115
|
-
dataStates: opts.dataStates,
|
|
116
|
-
stopWhenCovered: opts.untilCovered,
|
|
117
|
-
workers: opts.workers,
|
|
118
|
-
// each worker page in its OWN context, so storage resets can't interfere
|
|
119
|
-
newPage: async () => (await browser.newContext()).newPage(),
|
|
120
|
-
// Stream each surface as it is captured, so progress is visible live and an
|
|
121
|
-
// interrupted run still shows exactly what it mapped.
|
|
122
|
-
onSurface: (s, ok) =>
|
|
123
|
-
console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
|
|
197
|
+
// Page-level breadth-first sweep: crawl the entry page's whole interactive
|
|
198
|
+
// surface, then every same-origin page its nav links to (and theirs), each
|
|
199
|
+
// namespaced by its route key so a shared --out directory never collides.
|
|
200
|
+
const entry = new URL(opts.url);
|
|
201
|
+
const sweep = {
|
|
202
|
+
entry,
|
|
203
|
+
queue: [{ url: opts.url, prefix: '' }],
|
|
204
|
+
seenPages: new Set([dedupIdentity(entry.pathname + entry.search)]),
|
|
205
|
+
usedPrefixes: new Set(['base']), // 'base' is the entry crawl's root key
|
|
124
206
|
};
|
|
125
|
-
const
|
|
207
|
+
const reports = [];
|
|
208
|
+
let statesLeft = opts.maxStates;
|
|
209
|
+
|
|
210
|
+
while (sweep.queue.length > 0 && statesLeft > 0) {
|
|
211
|
+
const { url, prefix } = sweep.queue.shift();
|
|
212
|
+
const report = await crawlAndCapture(page, pageCrawlOptions(browser, url, prefix, statesLeft));
|
|
213
|
+
reports.push(report);
|
|
214
|
+
statesLeft -= report.surfaces.length;
|
|
215
|
+
if (opts.followLinks) enqueueLinkedPages(await harvestPageLinks(page, url), sweep);
|
|
216
|
+
}
|
|
217
|
+
if (sweep.queue.length > 0)
|
|
218
|
+
console.log(`⚠ --max-states reached: ${sweep.queue.length} linked page(s) left uncrawled — raise --max-states`);
|
|
219
|
+
|
|
126
220
|
// Stamp a manifest so a two-directory diff against this crawl output has the
|
|
127
221
|
// same-environment guard on both sides (v4 refuses a manifest-less side).
|
|
128
222
|
writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
);
|
|
133
|
-
const cov = report.coverage;
|
|
134
|
-
const unreadable = cov.unreadable ?? [];
|
|
135
|
-
if (unreadable.length > 0) {
|
|
136
|
-
console.log(
|
|
137
|
-
`⚠ coverage: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
|
|
138
|
-
`(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
if (cov.missing.length === 0) {
|
|
142
|
-
if (unreadable.length === 0)
|
|
143
|
-
console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
|
|
144
|
-
} else {
|
|
145
|
-
console.log(
|
|
146
|
-
`⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
|
|
147
|
-
`(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
223
|
+
printCrawlSummary(reports);
|
|
224
|
+
const cov = aggregateCoverage(reports);
|
|
225
|
+
printCoverage(cov, reports.length > 1 ? ` (${reports.length} pages)` : '');
|
|
150
226
|
// Residue under --require-full-coverage → exit 4: a never-seen class OR an
|
|
151
227
|
// unreadable sheet (whose vocabulary can't be proven covered at all).
|
|
152
|
-
if (opts.requireFullCoverage && (cov.missing.length > 0 || unreadable.length > 0)) process.exit(4);
|
|
228
|
+
if (opts.requireFullCoverage && (cov.missing.length > 0 || cov.unreadable.length > 0)) process.exit(4);
|
|
153
229
|
} finally {
|
|
154
230
|
await browser.close();
|
|
155
231
|
}
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -238,7 +238,13 @@ function printDeterminismVerdict(v) {
|
|
|
238
238
|
return false;
|
|
239
239
|
}
|
|
240
240
|
if (v.status === 'unknown') {
|
|
241
|
-
|
|
241
|
+
// No ledger at all = an ad-hoc `styleproof-capture` output (which doesn't
|
|
242
|
+
// self-check) or a pre-3.10 bundle; a spec capture records the basis.
|
|
243
|
+
console.log(
|
|
244
|
+
'\n⚠ determinism basis unknown — a side carries no determinism ledger (an ad-hoc styleproof-capture\n' +
|
|
245
|
+
' output, or a capture from before the ledger existed). A spec-driven capture (styleproof-map)\n' +
|
|
246
|
+
' self-checks and records it; ad-hoc captures are compared as-is.',
|
|
247
|
+
);
|
|
242
248
|
return false;
|
|
243
249
|
}
|
|
244
250
|
console.log(
|
package/dist/capture-url.d.ts
CHANGED
|
@@ -72,6 +72,9 @@ export type CaptureUrlOptions = {
|
|
|
72
72
|
dataStates: boolean;
|
|
73
73
|
/** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
|
|
74
74
|
workers: number;
|
|
75
|
+
/** crawl: also crawl every same-origin page the nav links to (default true).
|
|
76
|
+
* Off = the entry page's interactive surface only. */
|
|
77
|
+
followLinks: boolean;
|
|
75
78
|
};
|
|
76
79
|
/**
|
|
77
80
|
* Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
|
package/dist/capture-url.js
CHANGED
|
@@ -40,6 +40,7 @@ const DEFAULTS = {
|
|
|
40
40
|
untilCovered: false,
|
|
41
41
|
dataStates: true,
|
|
42
42
|
workers: 4,
|
|
43
|
+
followLinks: true,
|
|
43
44
|
};
|
|
44
45
|
function positiveNumber(raw, flag) {
|
|
45
46
|
const n = Number(raw);
|
|
@@ -81,6 +82,8 @@ const BOOL_FLAGS = {
|
|
|
81
82
|
'--until-covered': (o) => (o.untilCovered = true),
|
|
82
83
|
'--data-states': (o) => (o.dataStates = true),
|
|
83
84
|
'--no-data-states': (o) => (o.dataStates = false),
|
|
85
|
+
'--follow-links': (o) => (o.followLinks = true),
|
|
86
|
+
'--no-follow-links': (o) => (o.followLinks = false),
|
|
84
87
|
};
|
|
85
88
|
// Apply one argv token to the accumulator; returns the index to resume from
|
|
86
89
|
// (advanced past a consumed `--flag value` pair). Flat early-returns so the
|
|
@@ -131,6 +134,7 @@ export function parseCaptureUrlArgs(argv) {
|
|
|
131
134
|
setupFile: undefined,
|
|
132
135
|
dataStates: DEFAULTS.dataStates,
|
|
133
136
|
workers: DEFAULTS.workers,
|
|
137
|
+
followLinks: DEFAULTS.followLinks,
|
|
134
138
|
};
|
|
135
139
|
const positional = [];
|
|
136
140
|
for (let i = 0; i < argv.length; i++)
|
package/dist/crawl-surfaces.d.ts
CHANGED
|
@@ -53,6 +53,9 @@ export type CrawlCoverage = {
|
|
|
53
53
|
rendered: number;
|
|
54
54
|
missing: string[];
|
|
55
55
|
unreadable: string[];
|
|
56
|
+
/** The class names that WERE rendered — so a multi-page caller can aggregate
|
|
57
|
+
* coverage across crawls sharing stylesheets (defined = rendered ∪ missing). */
|
|
58
|
+
renderedClasses: string[];
|
|
56
59
|
};
|
|
57
60
|
export type CrawlReport = {
|
|
58
61
|
surfaces: CrawledSurface[];
|
|
@@ -122,6 +125,9 @@ export type SurfaceCrawlOptions = {
|
|
|
122
125
|
/** Factory for worker pages — create each in its OWN browser context so
|
|
123
126
|
* storage resets cannot interfere across concurrent sweeps. */
|
|
124
127
|
newPage?: () => Promise<Page>;
|
|
128
|
+
/** Namespace for every derived surface key (multi-page sweeps): the root
|
|
129
|
+
* surface keys as the prefix itself, sub-states as `<prefix>-<label>`. */
|
|
130
|
+
keyPrefix?: string;
|
|
125
131
|
};
|
|
126
132
|
export declare const CRAWL_DEFAULTS: {
|
|
127
133
|
height: number;
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -37,8 +37,12 @@ function pathAndSearch(url) {
|
|
|
37
37
|
return url;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
function deriveKey(steps, used) {
|
|
41
|
-
const
|
|
40
|
+
function deriveKey(steps, used, prefix = '') {
|
|
41
|
+
const bare = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
|
|
42
|
+
// A prefixed crawl (one page of a multi-page sweep) keys its root surface as
|
|
43
|
+
// the prefix itself and namespaces every sub-state under it, so two pages'
|
|
44
|
+
// surfaces can never overwrite each other in a shared --out directory.
|
|
45
|
+
const base = prefix === '' ? bare : bare === 'base' ? prefix : `${prefix}-${bare}`;
|
|
42
46
|
let key = base;
|
|
43
47
|
for (let i = 2; used.has(key); i++)
|
|
44
48
|
key = `${base}-${i}`;
|
|
@@ -500,7 +504,7 @@ function stateKey(steps) {
|
|
|
500
504
|
/** Record a newly-found surface, capture it in place (page is already there), and
|
|
501
505
|
* queue it for its own sweep. Streams progress via onSurface. */
|
|
502
506
|
async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
|
|
503
|
-
const key = deriveKey(newPath, st.used);
|
|
507
|
+
const key = deriveKey(newPath, st.used, opts.keyPrefix ?? '');
|
|
504
508
|
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
505
509
|
st.surfaces.push(surface);
|
|
506
510
|
let addsVocab = false;
|
|
@@ -753,7 +757,7 @@ async function recordDataState(page, opts, mode, st) {
|
|
|
753
757
|
st.seen.add(fp.sig);
|
|
754
758
|
for (const c of fp.classes)
|
|
755
759
|
st.classes.add(c);
|
|
756
|
-
const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used);
|
|
760
|
+
const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used, opts.keyPrefix ?? '');
|
|
757
761
|
const surface = { key, depth: 0, path: [], elements: fp.elements };
|
|
758
762
|
st.surfaces.push(surface);
|
|
759
763
|
await captureAndReport(page, opts, surface, st);
|
|
@@ -894,7 +898,13 @@ async function discover(page, opts) {
|
|
|
894
898
|
skipped: counters.skipped,
|
|
895
899
|
captured: st.captured,
|
|
896
900
|
failed: st.failed,
|
|
897
|
-
coverage: {
|
|
901
|
+
coverage: {
|
|
902
|
+
defined: defined.length,
|
|
903
|
+
rendered: defined.length - missing.length,
|
|
904
|
+
missing,
|
|
905
|
+
unreadable,
|
|
906
|
+
renderedClasses: defined.filter((c) => st.classes.has(c)).sort(),
|
|
907
|
+
},
|
|
898
908
|
};
|
|
899
909
|
}
|
|
900
910
|
/**
|
package/dist/crawl.d.ts
CHANGED
|
@@ -57,6 +57,23 @@ export type SelectLinksOptions = {
|
|
|
57
57
|
* nav-regressions / unowned routes for a route that never changed.
|
|
58
58
|
*/
|
|
59
59
|
export declare function defaultLinkKey(url: URL): string;
|
|
60
|
+
/**
|
|
61
|
+
* Dedup identity for a navigable path+query. Two forms of the same route must share
|
|
62
|
+
* one identity, or a static multi-page site (whose nav links the `.html` files) gets
|
|
63
|
+
* captured twice as byte-near-identical maps, doubling the work and duplicating every
|
|
64
|
+
* finding in the diff:
|
|
65
|
+
*
|
|
66
|
+
* - A trailing slash isn't a distinct surface (`/about` and `/about/` render the same
|
|
67
|
+
* route), so it's stripped — but never from the root `/` itself, nor from the query.
|
|
68
|
+
* - A trailing `index.html` is the directory's index (`/index.html` IS `/`, and
|
|
69
|
+
* `/docs/index.html` IS `/docs/`), so it collapses to the directory path. Only the
|
|
70
|
+
* literal `index.html` filename normalizes — a real `about.html` is left untouched
|
|
71
|
+
* and stays a distinct surface from `about`.
|
|
72
|
+
*
|
|
73
|
+
* The navigable url the caller returns keeps its original form; only the SET
|
|
74
|
+
* membership test is normalized, so the first-seen href still wins.
|
|
75
|
+
*/
|
|
76
|
+
export declare function dedupIdentity(pathAndSearch: string): string;
|
|
60
77
|
/**
|
|
61
78
|
* Turn a page's raw `<a href>` values into a deduped, keyed surface list.
|
|
62
79
|
*
|
package/dist/crawl.js
CHANGED
|
@@ -95,7 +95,7 @@ function toLink(href, base, keyFor, match) {
|
|
|
95
95
|
* The navigable url the caller returns keeps its original form; only the SET
|
|
96
96
|
* membership test is normalized, so the first-seen href still wins.
|
|
97
97
|
*/
|
|
98
|
-
function dedupIdentity(pathAndSearch) {
|
|
98
|
+
export function dedupIdentity(pathAndSearch) {
|
|
99
99
|
const q = pathAndSearch.indexOf('?');
|
|
100
100
|
const path = q === -1 ? pathAndSearch : pathAndSearch.slice(0, q);
|
|
101
101
|
const search = q === -1 ? '' : pathAndSearch.slice(q);
|
package/dist/map-store.js
CHANGED
|
@@ -140,8 +140,11 @@ export function expectedCompatibilityKey(options = {}) {
|
|
|
140
140
|
}
|
|
141
141
|
export function currentGitSha(cwd = process.cwd(), env = process.env) {
|
|
142
142
|
const fromEvent = (() => {
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
// pull_request_target is deliberately absent: there GITHUB_SHA *is* the base
|
|
144
|
+
// tip, so its default checkout would be relabeled to the fork's (attacker-
|
|
145
|
+
// chosen) head. A pull_request_target job that really checks out the head
|
|
146
|
+
// gets the right SHA from `git rev-parse HEAD` with no relabel needed.
|
|
147
|
+
if (!env.GITHUB_EVENT_PATH || !['pull_request', 'workflow_run'].includes(env.GITHUB_EVENT_NAME ?? '')) {
|
|
145
148
|
return undefined;
|
|
146
149
|
}
|
|
147
150
|
try {
|
|
@@ -152,16 +155,31 @@ export function currentGitSha(cwd = process.cwd(), env = process.env) {
|
|
|
152
155
|
return undefined;
|
|
153
156
|
}
|
|
154
157
|
})();
|
|
155
|
-
// STYLEPROOF_SHA/GITHUB_HEAD_SHA are explicit overrides
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
158
|
+
// STYLEPROOF_SHA/GITHUB_HEAD_SHA are explicit overrides: they always win, and
|
|
159
|
+
// a malformed value errors instead of silently falling through to a wrong label.
|
|
160
|
+
const explicit = env.STYLEPROOF_SHA || env.GITHUB_HEAD_SHA;
|
|
161
|
+
if (explicit) {
|
|
162
|
+
if (!/^[0-9a-f]{7,40}$/i.test(explicit)) {
|
|
163
|
+
throw new MapStoreError(`STYLEPROOF_SHA/GITHUB_HEAD_SHA is not a commit SHA: ${explicit}`);
|
|
164
|
+
}
|
|
165
|
+
return explicit;
|
|
166
|
+
}
|
|
167
|
+
const head = gitOutput(cwd, ['rev-parse', 'HEAD']);
|
|
168
|
+
if (head) {
|
|
169
|
+
// The checked-out tree is the truth. The one exception: a checkout of the
|
|
170
|
+
// synthetic GITHUB_SHA commit (pull_request merge commit / workflow_run
|
|
171
|
+
// default tip) is labeled with the event's real head, because nothing ever
|
|
172
|
+
// restores by the synthetic SHA. A checkout of anything else — e.g. the
|
|
173
|
+
// base branch in a cache-miss job — keeps its own SHA, so a base-tree map
|
|
174
|
+
// is never published under the head's store key (a false-green poisoning).
|
|
175
|
+
if (fromEvent && head === env.GITHUB_SHA)
|
|
176
|
+
return fromEvent;
|
|
177
|
+
return head;
|
|
178
|
+
}
|
|
179
|
+
const fallback = fromEvent ?? env.GITHUB_SHA;
|
|
180
|
+
if (fallback && /^[0-9a-f]{7,40}$/i.test(fallback))
|
|
181
|
+
return fallback;
|
|
182
|
+
throw new MapStoreError('must run inside a git repository, or pass --sha <commit>');
|
|
165
183
|
}
|
|
166
184
|
export function refSha(ref, cwd = process.cwd()) {
|
|
167
185
|
const sha = gitOutput(cwd, ['rev-parse', `${ref}^{commit}`]);
|
package/dist/report.js
CHANGED
|
@@ -48,47 +48,172 @@ function outermost(paths) {
|
|
|
48
48
|
function innermost(paths) {
|
|
49
49
|
return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(p + ' > ')));
|
|
50
50
|
}
|
|
51
|
-
function
|
|
51
|
+
function sortedProperties(props) {
|
|
52
|
+
return Object.entries(props).sort(([left], [right]) => left.localeCompare(right, 'en'));
|
|
53
|
+
}
|
|
54
|
+
function restingAnnotationIdentity(entry) {
|
|
55
|
+
if (!entry)
|
|
56
|
+
return null;
|
|
52
57
|
const sortedPseudo = Object.fromEntries(Object.entries(entry.pseudo ?? {})
|
|
53
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
54
|
-
.map(([pseudo, properties]) => [
|
|
55
|
-
|
|
56
|
-
Object.entries(properties).sort(([left], [right]) => left.localeCompare(right)),
|
|
57
|
-
]));
|
|
58
|
-
return JSON.stringify([
|
|
58
|
+
.sort(([left], [right]) => left.localeCompare(right, 'en'))
|
|
59
|
+
.map(([pseudo, properties]) => [pseudo, sortedProperties(properties)]));
|
|
60
|
+
return [
|
|
59
61
|
entry.tag,
|
|
60
62
|
entry.cls,
|
|
61
63
|
entry.rect?.[2] ?? null,
|
|
62
64
|
entry.rect?.[3] ?? null,
|
|
63
|
-
|
|
65
|
+
sortedProperties(entry.style),
|
|
64
66
|
sortedPseudo,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
function normalizeStructuralPath(elementPath) {
|
|
70
|
+
return elementPath.replace(/:nth-(?:child|of-type)\(\d+\)/g, (selector) => selector.replace(/\d+/, '*'));
|
|
71
|
+
}
|
|
72
|
+
function annotationScope(elementPath) {
|
|
73
|
+
const parentSeparator = elementPath.lastIndexOf(' > ');
|
|
74
|
+
return normalizeStructuralPath(parentSeparator === -1 ? '' : elementPath.slice(0, parentSeparator));
|
|
75
|
+
}
|
|
76
|
+
function relativeStateTarget(ownerPath, targetPath) {
|
|
77
|
+
if (targetPath === ownerPath)
|
|
78
|
+
return '';
|
|
79
|
+
const ownerPseudoPrefix = `${ownerPath}::`;
|
|
80
|
+
if (targetPath.startsWith(ownerPseudoPrefix))
|
|
81
|
+
return targetPath.slice(ownerPath.length);
|
|
82
|
+
const descendantPrefix = `${ownerPath} > `;
|
|
83
|
+
const relativePath = targetPath.startsWith(descendantPrefix) ? targetPath.slice(descendantPrefix.length) : targetPath;
|
|
84
|
+
return normalizeStructuralPath(relativePath);
|
|
85
|
+
}
|
|
86
|
+
function canonicalForcedStates(map, ownerPath) {
|
|
87
|
+
return Object.entries(map.states?.[ownerPath] ?? {})
|
|
88
|
+
.sort(([left], [right]) => left.localeCompare(right, 'en'))
|
|
89
|
+
.map(([stateName, deltas]) => [
|
|
90
|
+
stateName,
|
|
91
|
+
Object.entries(deltas)
|
|
92
|
+
.map(([targetPath, properties]) => [
|
|
93
|
+
relativeStateTarget(ownerPath, targetPath),
|
|
94
|
+
restingAnnotationIdentity(map.elements[targetPath]),
|
|
95
|
+
sortedProperties(properties),
|
|
96
|
+
])
|
|
97
|
+
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right), 'en')),
|
|
65
98
|
]);
|
|
66
99
|
}
|
|
100
|
+
function annotationIdentity(map, elementPath, entry) {
|
|
101
|
+
return JSON.stringify([restingAnnotationIdentity(entry), canonicalForcedStates(map, elementPath)]);
|
|
102
|
+
}
|
|
103
|
+
function sortedAnnotationPaths(paths) {
|
|
104
|
+
return [...paths].sort((left, right) => left.localeCompare(right, 'en', { numeric: true }));
|
|
105
|
+
}
|
|
67
106
|
function indexAnnotationIdentities(map) {
|
|
68
107
|
const pathsByIdentity = new Map();
|
|
69
108
|
for (const [elementPath, entry] of Object.entries(map.elements)) {
|
|
70
|
-
const identity = annotationIdentity(entry);
|
|
109
|
+
const identity = annotationIdentity(map, elementPath, entry);
|
|
71
110
|
pathsByIdentity.set(identity, [...(pathsByIdentity.get(identity) ?? []), elementPath]);
|
|
72
111
|
}
|
|
73
112
|
return pathsByIdentity;
|
|
74
113
|
}
|
|
75
|
-
function
|
|
76
|
-
|
|
114
|
+
function pathsByAnnotationScope(paths) {
|
|
115
|
+
const pathsByScope = new Map();
|
|
116
|
+
for (const elementPath of paths) {
|
|
117
|
+
const scope = annotationScope(elementPath);
|
|
118
|
+
pathsByScope.set(scope, [...(pathsByScope.get(scope) ?? []), elementPath]);
|
|
119
|
+
}
|
|
120
|
+
for (const scopedPaths of pathsByScope.values())
|
|
121
|
+
scopedPaths.sort((left, right) => left.localeCompare(right, 'en', { numeric: true }));
|
|
122
|
+
return pathsByScope;
|
|
123
|
+
}
|
|
124
|
+
/** Captured children per concrete container path, for the displacement proof. */
|
|
125
|
+
function containerChildCounts(map) {
|
|
126
|
+
const counts = new Map();
|
|
127
|
+
for (const elementPath of Object.keys(map.elements)) {
|
|
128
|
+
const separator = elementPath.lastIndexOf(' > ');
|
|
129
|
+
const container = separator === -1 ? '' : elementPath.slice(0, separator);
|
|
130
|
+
counts.set(container, (counts.get(container) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
return counts;
|
|
133
|
+
}
|
|
134
|
+
/** The concrete container where two element paths diverge (never the leaf itself). */
|
|
135
|
+
function deepestCommonContainer(beforePath, afterPath) {
|
|
136
|
+
const beforeSegments = beforePath.split(' > ');
|
|
137
|
+
const afterSegments = afterPath.split(' > ');
|
|
138
|
+
const shared = [];
|
|
139
|
+
const limit = Math.min(beforeSegments.length, afterSegments.length) - 1;
|
|
140
|
+
for (let i = 0; i < limit && beforeSegments[i] === afterSegments[i]; i++)
|
|
141
|
+
shared.push(beforeSegments[i]);
|
|
142
|
+
return shared.join(' > ');
|
|
143
|
+
}
|
|
144
|
+
function containerOf(elementPath) {
|
|
145
|
+
const separator = elementPath.lastIndexOf(' > ');
|
|
146
|
+
return separator === -1 ? '' : elementPath.slice(0, separator);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A cross-path match is a MOVE claim, and a matched pair's annotations are
|
|
150
|
+
* suppressed — so the move must be PROVABLE from the captured data, one of:
|
|
151
|
+
*
|
|
152
|
+
* - the container where the two paths diverge gained or lost captured children
|
|
153
|
+
* (a sibling insertion/removal displaced everything after it), or
|
|
154
|
+
* - a same-container slide into a vacated slot: the source slot emptied and the
|
|
155
|
+
* destination slot is new. That is displacement by an UNCAPTURED sibling — an
|
|
156
|
+
* injected `<style>`/`<script>` shifts `nth-child` without entering the census.
|
|
157
|
+
*
|
|
158
|
+
* A pair with neither proof — a style swap between siblings, a pure reorder of
|
|
159
|
+
* occupied slots, or a coincidental twin in a cousin container — stays
|
|
160
|
+
* annotated, because the data cannot prove nothing changed there.
|
|
161
|
+
*/
|
|
162
|
+
function canReconcileAnnotationPair(beforeMap, afterMap, beforeCounts, afterCounts, beforePath, afterPath, remainingAfter) {
|
|
163
|
+
if (!remainingAfter.has(afterPath))
|
|
77
164
|
return false;
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
165
|
+
const divergence = deepestCommonContainer(beforePath, afterPath);
|
|
166
|
+
if ((beforeCounts.get(divergence) ?? 0) !== (afterCounts.get(divergence) ?? 0))
|
|
167
|
+
return true;
|
|
168
|
+
if (containerOf(beforePath) !== containerOf(afterPath))
|
|
169
|
+
return false;
|
|
170
|
+
return !beforeMap.elements[afterPath] && !afterMap.elements[beforePath];
|
|
171
|
+
}
|
|
172
|
+
function reconcileIdentityPaths(beforeMap, afterMap, beforeCounts, afterCounts, beforePaths, afterPaths, matches) {
|
|
173
|
+
const remainingBefore = new Set(beforePaths);
|
|
174
|
+
const remainingAfter = new Set(afterPaths);
|
|
175
|
+
// Preserve stable paths first. This keeps duplicate occurrences deterministic
|
|
176
|
+
// without claiming which indistinguishable physical node was inserted.
|
|
177
|
+
for (const beforePath of beforePaths) {
|
|
178
|
+
if (!remainingAfter.has(beforePath))
|
|
179
|
+
continue;
|
|
180
|
+
matches.beforeToAfter.set(beforePath, beforePath);
|
|
181
|
+
matches.afterToBefore.set(beforePath, beforePath);
|
|
182
|
+
remainingBefore.delete(beforePath);
|
|
183
|
+
remainingAfter.delete(beforePath);
|
|
184
|
+
}
|
|
185
|
+
const remainingAfterPathsByScope = pathsByAnnotationScope(remainingAfter);
|
|
186
|
+
// Reconcile only within the same normalized structural neighborhood. Any
|
|
187
|
+
// excess occurrence remains unmatched and is annotated as an addition/removal.
|
|
188
|
+
for (const beforePath of sortedAnnotationPaths([...remainingBefore])) {
|
|
189
|
+
const candidates = remainingAfterPathsByScope.get(annotationScope(beforePath)) ?? [];
|
|
190
|
+
const afterPath = candidates.find((candidate) => canReconcileAnnotationPair(beforeMap, afterMap, beforeCounts, afterCounts, beforePath, candidate, remainingAfter));
|
|
191
|
+
if (!afterPath)
|
|
192
|
+
continue;
|
|
193
|
+
matches.beforeToAfter.set(beforePath, afterPath);
|
|
194
|
+
matches.afterToBefore.set(afterPath, beforePath);
|
|
195
|
+
remainingBefore.delete(beforePath);
|
|
196
|
+
remainingAfter.delete(afterPath);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function reconcileAnnotationPaths(beforeMap, afterMap) {
|
|
200
|
+
const beforePathsByIdentity = indexAnnotationIdentities(beforeMap);
|
|
201
|
+
const afterPathsByIdentity = indexAnnotationIdentities(afterMap);
|
|
202
|
+
const beforeCounts = containerChildCounts(beforeMap);
|
|
203
|
+
const afterCounts = containerChildCounts(afterMap);
|
|
204
|
+
const matches = {
|
|
205
|
+
beforeToAfter: new Map(),
|
|
206
|
+
afterToBefore: new Map(),
|
|
207
|
+
};
|
|
208
|
+
const identities = new Set([...beforePathsByIdentity.keys(), ...afterPathsByIdentity.keys()]);
|
|
209
|
+
for (const identity of identities) {
|
|
210
|
+
reconcileIdentityPaths(beforeMap, afterMap, beforeCounts, afterCounts, sortedAnnotationPaths(beforePathsByIdentity.get(identity) ?? []), sortedAnnotationPaths(afterPathsByIdentity.get(identity) ?? []), matches);
|
|
211
|
+
}
|
|
212
|
+
return matches;
|
|
213
|
+
}
|
|
214
|
+
function annotationSides(finding, beforeMoved, afterMoved) {
|
|
90
215
|
if (finding.kind !== 'dom')
|
|
91
|
-
return
|
|
216
|
+
return { before: !beforeMoved, after: !afterMoved };
|
|
92
217
|
if (finding.change === 'removed')
|
|
93
218
|
return { before: !beforeMoved, after: false };
|
|
94
219
|
if (finding.change === 'added')
|
|
@@ -96,17 +221,15 @@ function annotationSides(finding, beforeMoved, afterMoved, reconcileMoved) {
|
|
|
96
221
|
return { before: true, after: true };
|
|
97
222
|
}
|
|
98
223
|
function annotationPaths(findings, beforeMap, afterMap) {
|
|
99
|
-
const
|
|
100
|
-
const afterPathsByIdentity = indexAnnotationIdentities(afterMap);
|
|
101
|
-
const reconcileMoved = findings.some((finding) => finding.kind === 'dom');
|
|
224
|
+
const matches = reconcileAnnotationPaths(beforeMap, afterMap);
|
|
102
225
|
const beforePaths = new Set();
|
|
103
226
|
const afterPaths = new Set();
|
|
104
227
|
for (const finding of findings) {
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const beforeMoved =
|
|
108
|
-
const afterMoved =
|
|
109
|
-
const sides = annotationSides(finding, beforeMoved, afterMoved
|
|
228
|
+
const beforeMatch = matches.beforeToAfter.get(finding.path);
|
|
229
|
+
const afterMatch = matches.afterToBefore.get(finding.path);
|
|
230
|
+
const beforeMoved = beforeMatch !== undefined && beforeMatch !== finding.path;
|
|
231
|
+
const afterMoved = afterMatch !== undefined && afterMatch !== finding.path;
|
|
232
|
+
const sides = annotationSides(finding, beforeMoved, afterMoved);
|
|
110
233
|
if (sides.before)
|
|
111
234
|
beforePaths.add(finding.path);
|
|
112
235
|
if (sides.after)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.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",
|