styleproof 3.2.0 → 3.3.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 +43 -0
- package/README.md +38 -5
- package/bin/styleproof-capture.mjs +136 -0
- package/bin/styleproof-diff.mjs +1 -1
- package/bin/styleproof-report.mjs +3 -3
- package/dist/capture-url.d.ts +80 -0
- package/dist/capture-url.js +185 -0
- package/dist/crawl-surfaces.d.ts +96 -0
- package/dist/crawl-surfaces.js +500 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/report.js +5 -7
- package/package.json +2 -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
|
+
## [3.3.0] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- New `styleproof-capture <url>` CLI: capture a single page (a deployed URL, a
|
|
15
|
+
static export, or a standalone HTML mockup) so you can prove a production build
|
|
16
|
+
renders identically to its design. Point it at the design and at the build,
|
|
17
|
+
diff the two, and zero diff means pixel-identical — anything else is named down
|
|
18
|
+
to the computed style. One shot, no spec, no config; writes the same
|
|
19
|
+
`<key>@<width>.json.gz` (+ `.png`) shape any capture does, so `styleproof-diff`
|
|
20
|
+
compares it against anything. Also exported programmatically as
|
|
21
|
+
`captureUrlToDir` / `runCaptureUrl` / `parseCaptureUrlArgs`.
|
|
22
|
+
- `styleproof-capture --crawl`: map a whole interactive design from one URL with
|
|
23
|
+
no spec and no selectors — EXHAUSTIVE by default, running until every
|
|
24
|
+
non-destructive control has been driven once and every structurally new surface
|
|
25
|
+
captured (a modal's tabs, a drawer's sub-views, a popover's panels), each under
|
|
26
|
+
a derived key. The sweep works in place with lazy, fingerprint-verified resets:
|
|
27
|
+
a no-op click costs nothing, only state-changing clicks pay a fresh
|
|
28
|
+
navigation + replay, and children are never attributed to the wrong parent.
|
|
29
|
+
Deterministic, deduped by a structural fingerprint, self-settling for async
|
|
30
|
+
(React/Vue/Babel) apps, discovery pinned to one viewport width, progress
|
|
31
|
+
streamed per captured surface, and destructive-looking controls are never
|
|
32
|
+
clicked. `--max-depth` / `--max-actions` / `--max-states` exist only as
|
|
33
|
+
throttles. Exported programmatically as `crawlAndCapture`.
|
|
34
|
+
- Crawl coverage verifier: after `--crawl`, every class the page's own
|
|
35
|
+
stylesheets define (parsed CSSOM) is checked against the classes rendered
|
|
36
|
+
across all captured surfaces, and the never-seen residue is printed — dead
|
|
37
|
+
CSS, or a state the crawl could not reach. `--require-full-coverage` makes
|
|
38
|
+
any residue exit 4, so "the design is fully covered, nothing missing" is a
|
|
39
|
+
machine-checked property. On `CrawlReport.coverage` programmatically.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- GitHub Action reports now publish every generated crop PNG, including zoom
|
|
44
|
+
crops, so committed `report.md` files do not point at missing images.
|
|
45
|
+
|
|
10
46
|
## [3.2.0] - 2026-06-30
|
|
11
47
|
|
|
12
48
|
### Added
|
|
@@ -72,6 +108,13 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
72
108
|
|
|
73
109
|
## [3.1.4] - 2026-06-29
|
|
74
110
|
|
|
111
|
+
### Changed
|
|
112
|
+
|
|
113
|
+
- New surfaces now require approval in review-gate mode. A new-surface-only diff
|
|
114
|
+
still exits `3`, but the Action marks `changed=true`, posts the report, and
|
|
115
|
+
holds the `StyleProof` status red until the reviewer approves it. Certify mode
|
|
116
|
+
was already strict because it fails on any report.
|
|
117
|
+
|
|
75
118
|
### Fixed
|
|
76
119
|
|
|
77
120
|
- **Popup capture no longer collapses distinct triggers that reuse the same
|
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
|
|
|
21
21
|
- [Works with any styling system](#works-with-any-styling-system)
|
|
22
22
|
- [Breakpoints, detected automatically](#breakpoints-detected-automatically)
|
|
23
23
|
- [Certify a refactor](#certify-a-refactor)
|
|
24
|
+
- [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel)
|
|
24
25
|
- [Install](#install)
|
|
25
26
|
- [Quickstart](#quickstart)
|
|
26
27
|
- [Forks and Dependabot](#forks-and-dependabot)
|
|
@@ -52,7 +53,9 @@ The important boundary: StyleProof only certifies states it can reach. If a stat
|
|
|
52
53
|
matters, list it as a surface, variant, popup, live state, or component-catalog
|
|
53
54
|
surface. `expected` turns that inventory into a guard, so an uncaptured new page,
|
|
54
55
|
component, modal, dropdown, or toast fails as missing coverage instead of
|
|
55
|
-
silently passing.
|
|
56
|
+
silently passing. A surface that exists only on the PR head is still reviewable:
|
|
57
|
+
in review-gate mode it holds the status red until approved, then becomes part of
|
|
58
|
+
the baseline once merged.
|
|
56
59
|
|
|
57
60
|
## The short version
|
|
58
61
|
|
|
@@ -117,8 +120,8 @@ full report. The report groups each distinct visual change with:
|
|
|
117
120
|
|
|
118
121
|
In review-gate mode, one **Approve all changes** checkbox turns the `StyleProof`
|
|
119
122
|
status green for that commit. Clean runs still leave a receipt: `No visual
|
|
120
|
-
changes detected.` New surfaces are shown as new baselines
|
|
121
|
-
handled by `expected`.
|
|
123
|
+
changes detected.` New surfaces are shown as new baselines and require approval;
|
|
124
|
+
coverage gaps are handled by `expected`.
|
|
122
125
|
|
|
123
126
|
## Auto-discovery
|
|
124
127
|
|
|
@@ -397,6 +400,36 @@ Omit `widths` on a surface and StyleProof reads your app's real `@media` breakpo
|
|
|
397
400
|
|
|
398
401
|
The same engine has a second mode that proves a change touched _nothing_ visual: with `fail-on-diff: true`, any difference at all fails the job. It's the job StyleProof was born for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical. Reach for it on any change whose whole promise is "the output is unchanged": a utility-class migration, a design-system swap, a dependency or build-tooling bump. Zero diff is the contract; one drifting longhand is a regression to investigate, not a change to approve.
|
|
399
402
|
|
|
403
|
+
## Match a design pixel-for-pixel
|
|
404
|
+
|
|
405
|
+
When you build a design in production, "looks the same" is a judgement call — and small gaps ship. `styleproof-capture` makes it an objective check: point it at the **design** (a deployed mockup, a static export, a standalone HTML file), point it at your **build**, and diff. Zero diff means the production UI renders _identically_ to the design; anything else is named exactly, down to the computed style, so you know precisely what's still off.
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
styleproof-capture https://example.com/pricing --key pricing --widths 1440,1024,768 --out design
|
|
409
|
+
styleproof-diff design .styleproof/maps/current # design vs build — zero diff = pixel-identical
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
You watch one number as you implement: the diff starts large and shrinks toward zero, and it hits zero the moment the built page matches the design. It's the objective version of putting the mockup and the app side by side and squinting.
|
|
413
|
+
|
|
414
|
+
(`styleproof-map` is the spec-driven flow for your own app's surfaces, with the coverage guard, map store, and record/replay; `styleproof-capture` is the one-shot for a page you just point at.) It writes `design/pricing@1440.json.gz` (+ `.png`), the same shape any capture writes, so `styleproof-diff` compares it against anything. Omit `--widths` to auto-detect the page's own `@media` breakpoints; pin them for a page whose CSS is cross-origin (a font stylesheet, say), since detection reads every sheet and fails loudly rather than guess. `--wait <selector>` holds until the intended state is on screen; `--ignore <selector>` skips a live region. Capture both sides in the same browser + fonts, since that's what "identical" is measured against.
|
|
415
|
+
|
|
416
|
+
### Crawl the whole interactive design: `--crawl`
|
|
417
|
+
|
|
418
|
+
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.
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
styleproof-capture https://example.com --crawl --out design # maps every reachable surface
|
|
422
|
+
styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
It's **exhaustive by default**: the crawl stops when there is nothing left to drive — every control tried once, every structurally new surface captured — not at a budget. Termination is guaranteed by dedup (controls dedup by selector, surfaces by a structural fingerprint), and the `--max-depth` / `--max-actions` / `--max-states` flags exist only as deliberate throttles. It's deterministic (document order; the same surface reached two ways is captured once) and self-settling — it waits for an async app (React/Vue/Babel that boots after `load`) to mount before reading, so a bare crawl of a client-rendered page still captures the mounted UI.
|
|
426
|
+
|
|
427
|
+
What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface.
|
|
428
|
+
|
|
429
|
+
**And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap).
|
|
430
|
+
|
|
431
|
+
**Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked** — mapping must not mutate; states gated behind one of those need a spec. Prefer the spec-driven `defineStyleMapCapture` when you want stable, named keys and the coverage guard; reach for `--crawl` to map a design (or a third-party page) you don't have a spec for.
|
|
432
|
+
|
|
400
433
|
## Install
|
|
401
434
|
|
|
402
435
|
```bash
|
|
@@ -620,7 +653,7 @@ When a PR **adds** an element, StyleProof now reports its **full resting compute
|
|
|
620
653
|
| `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
|
|
621
654
|
| `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
|
|
622
655
|
|
|
623
|
-
Outputs: `changed` (`"true"` when
|
|
656
|
+
Outputs: `changed` (`"true"` when any existing surface changed, or a new surface needs approval), `report-url`. Other inputs (`report-branch`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
|
|
624
657
|
|
|
625
658
|
**Policy file `styleproof.config.json`** (optional, at the repo root) — gate policy that isn't workflow plumbing:
|
|
626
659
|
|
|
@@ -679,7 +712,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
679
712
|
|
|
680
713
|
- `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
|
|
681
714
|
- `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
|
|
682
|
-
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
715
|
+
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. Exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
683
716
|
- `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
|
|
684
717
|
- `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
|
|
685
718
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Capture a single URL's computed-style map — one shot, no spec, no config.
|
|
4
|
+
*
|
|
5
|
+
* styleproof-capture <url> [options]
|
|
6
|
+
*
|
|
7
|
+
* For a page you just want to point at: a deployed URL, a static export, or a
|
|
8
|
+
* standalone HTML mockup. Writes `<key>@<width>.json.gz` (+ `.png`) into --out,
|
|
9
|
+
* the same shape a surface capture writes, so `styleproof-diff <a> <b>` compares
|
|
10
|
+
* it against any other capture. Capture a design mockup once, then diff each
|
|
11
|
+
* build against it to measure how close the implementation is.
|
|
12
|
+
*
|
|
13
|
+
* (Use styleproof-map — the spec-driven flow — for your own app's surfaces, where
|
|
14
|
+
* you also want the coverage guard, the map store, and record/replay.)
|
|
15
|
+
*
|
|
16
|
+
* Exit code 0 = captured, 2 = usage error, 3 = capture failed (e.g. browser
|
|
17
|
+
* missing, page unreachable, cross-origin CSS with no explicit --widths).
|
|
18
|
+
*/
|
|
19
|
+
import { chromium } from '@playwright/test';
|
|
20
|
+
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
21
|
+
import { UsageError, parseCaptureUrlArgs, runCaptureUrl } from '../dist/capture-url.js';
|
|
22
|
+
import { crawlAndCapture } from '../dist/crawl-surfaces.js';
|
|
23
|
+
|
|
24
|
+
const COMMAND = 'styleproof-capture';
|
|
25
|
+
|
|
26
|
+
const HELP = `${COMMAND} — capture a page's computed-style map(s) (no spec, no config)
|
|
27
|
+
|
|
28
|
+
usage: ${COMMAND} <url> [options]
|
|
29
|
+
|
|
30
|
+
one state (default): capture the page as it loads
|
|
31
|
+
--key <name> capture file prefix, <key>@<width>.json.gz (default: page)
|
|
32
|
+
--wait <selector> wait for this selector to be visible before capturing
|
|
33
|
+
--widths <csv> viewport widths, e.g. 1440,1024,768. Omit to detect the
|
|
34
|
+
page's own @media breakpoints (fails on cross-origin CSS —
|
|
35
|
+
pass widths for those). Crawl defaults to 1280 when omitted.
|
|
36
|
+
|
|
37
|
+
whole surface: --crawl
|
|
38
|
+
--crawl EXHAUSTIVE: drive every non-destructive control, recurse into
|
|
39
|
+
what opens, and capture every discovered surface under a derived
|
|
40
|
+
key — runs to natural termination, no budget. For a design that's
|
|
41
|
+
mostly modals/drawers/popovers. Destructive-looking controls
|
|
42
|
+
(delete/deploy/pay/revoke…) are never clicked.
|
|
43
|
+
--require-full-coverage
|
|
44
|
+
exit 4 unless every class the page's stylesheets define was
|
|
45
|
+
rendered in a captured surface — the machine check that
|
|
46
|
+
NOTHING in the design was missed (coverage is always printed)
|
|
47
|
+
--max-depth <n> throttle recursion depth (default: unbounded)
|
|
48
|
+
--max-actions <n> throttle controls tried per state (default: unbounded)
|
|
49
|
+
--max-states <n> throttle total surfaces (default: unbounded)
|
|
50
|
+
--no-reset-storage don't clear localStorage between steps (default: clear)
|
|
51
|
+
|
|
52
|
+
common:
|
|
53
|
+
--out <dir> output directory (default: styleproof-capture)
|
|
54
|
+
--ignore <sel> skip a nondeterministic region (repeatable)
|
|
55
|
+
--height <px> viewport height (default: 800)
|
|
56
|
+
--no-screenshots write lean .json.gz maps only (screenshots on by default)
|
|
57
|
+
-h, --help show this help
|
|
58
|
+
|
|
59
|
+
Then diff against another capture — zero diff = pixel-identical:
|
|
60
|
+
${COMMAND} https://example.com --crawl --out design
|
|
61
|
+
styleproof-diff design .styleproof/maps/current
|
|
62
|
+
|
|
63
|
+
exit: 0 captured, 2 usage error, 3 capture failed, 4 coverage gap (--require-full-coverage).
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const argv = process.argv.slice(2);
|
|
67
|
+
if (isHelpArg(argv[0])) showHelpAndExit(HELP);
|
|
68
|
+
|
|
69
|
+
let opts;
|
|
70
|
+
try {
|
|
71
|
+
opts = parseCaptureUrlArgs(argv);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e instanceof UsageError) {
|
|
74
|
+
console.error(`${COMMAND}: ${e.message}\nNext: run ${COMMAND} --help to see supported options.`);
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runCrawl() {
|
|
81
|
+
const browser = await chromium.launch();
|
|
82
|
+
try {
|
|
83
|
+
const page = await browser.newPage();
|
|
84
|
+
const crawlOpts = {
|
|
85
|
+
url: opts.url,
|
|
86
|
+
out: opts.out,
|
|
87
|
+
widths: opts.widths.length ? opts.widths : [1280],
|
|
88
|
+
ignore: opts.ignore,
|
|
89
|
+
height: opts.height,
|
|
90
|
+
screenshots: opts.screenshots,
|
|
91
|
+
waitSelector: opts.waitSelector,
|
|
92
|
+
maxDepth: opts.maxDepth,
|
|
93
|
+
maxActionsPerState: opts.maxActionsPerState,
|
|
94
|
+
maxStates: opts.maxStates,
|
|
95
|
+
resetStorage: opts.resetStorage,
|
|
96
|
+
// Stream each surface as it is captured, so progress is visible live and an
|
|
97
|
+
// interrupted run still shows exactly what it mapped.
|
|
98
|
+
onSurface: (s, ok) =>
|
|
99
|
+
console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
|
|
100
|
+
};
|
|
101
|
+
const report = await crawlAndCapture(page, crawlOpts);
|
|
102
|
+
console.log(
|
|
103
|
+
`✓ ${report.captured}/${report.surfaces.length} surface(s) × ${crawlOpts.widths.length} width(s) → ${opts.out} ` +
|
|
104
|
+
`(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
|
|
105
|
+
);
|
|
106
|
+
const cov = report.coverage;
|
|
107
|
+
if (cov.missing.length === 0) {
|
|
108
|
+
console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
|
|
109
|
+
} else {
|
|
110
|
+
console.log(
|
|
111
|
+
`⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
|
|
112
|
+
`(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
|
|
113
|
+
);
|
|
114
|
+
if (opts.requireFullCoverage) process.exit(4);
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
await browser.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if (opts.crawl) {
|
|
123
|
+
await runCrawl();
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
const results = await runCaptureUrl(opts, () => chromium.launch());
|
|
127
|
+
for (const r of results) console.log(`captured ${r.map}${r.screenshot ? ` (+ ${r.screenshot})` : ''}`);
|
|
128
|
+
console.log(`✓ ${results.length} capture(s) → ${opts.out}`);
|
|
129
|
+
process.exit(0);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error(`${COMMAND}: capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
132
|
+
console.error(
|
|
133
|
+
'Next: check the URL is reachable and run `npx playwright install chromium` if the browser is missing.',
|
|
134
|
+
);
|
|
135
|
+
process.exit(3);
|
|
136
|
+
}
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -181,7 +181,7 @@ console.log(
|
|
|
181
181
|
total === 0
|
|
182
182
|
? newSurfaces === 0
|
|
183
183
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
184
|
-
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare —
|
|
184
|
+
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
185
185
|
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
|
|
186
186
|
);
|
|
187
187
|
// 0 = identical, 1 = reviewable differences, 3 = only new surfaces (no baseline,
|
|
@@ -36,8 +36,8 @@ options:
|
|
|
36
36
|
--remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
|
|
37
37
|
--out <dir> output directory (default: styleproof-report)
|
|
38
38
|
--image-base-url <url> prefix for image URLs in report.md (default: relative)
|
|
39
|
-
--pad <px> padding around changed rects when cropping (default:
|
|
40
|
-
--max-crops <n> max crop regions per surface before collapsing (default:
|
|
39
|
+
--pad <px> padding around changed rects when cropping (default: 12)
|
|
40
|
+
--max-crops <n> max crop regions per surface before collapsing (default: 8)
|
|
41
41
|
--fold-details-at <n> row count at which a crop's property tables fold under a
|
|
42
42
|
<details> toggle (default: 0 = always; 'Infinity' = never)
|
|
43
43
|
--min-width <px> minimum crop width, for context (default: 320)
|
|
@@ -178,7 +178,7 @@ console.log(
|
|
|
178
178
|
result.changedSurfaces === 0
|
|
179
179
|
? result.newSurfaces === 0
|
|
180
180
|
? '✓ no changes — empty report written'
|
|
181
|
-
: `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for
|
|
181
|
+
: `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for review`
|
|
182
182
|
: `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
|
|
183
183
|
);
|
|
184
184
|
console.log(`report: ${result.reportMdPath}`);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Browser, Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* One-shot capture of a single URL's computed-style map — no spec, no config,
|
|
4
|
+
* no git. `defineStyleMapCapture` is the right tool when the surfaces live in
|
|
5
|
+
* your own app and you want the coverage guard, the map store, and record/replay.
|
|
6
|
+
* This is the tool for a page you just want to point at: a deployed URL, a
|
|
7
|
+
* static export, or a standalone HTML mockup. Capture it into a directory of
|
|
8
|
+
* `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
|
|
9
|
+
* `styleproof-diff <a> <b>` — compares like any other capture.
|
|
10
|
+
*
|
|
11
|
+
* The output is deliberately the same shape a surface capture writes, so a
|
|
12
|
+
* mockup's map and your app's committed map are directly diffable: capture the
|
|
13
|
+
* design once, then diff each build against it to measure how close the
|
|
14
|
+
* implementation is (a diff that shrinks toward zero as it converges).
|
|
15
|
+
*/
|
|
16
|
+
/** Raised for bad CLI usage so the bin can print help and exit 2. */
|
|
17
|
+
export declare class UsageError extends Error {
|
|
18
|
+
}
|
|
19
|
+
export type CaptureUrlOptions = {
|
|
20
|
+
/** Page to capture. */
|
|
21
|
+
url: string;
|
|
22
|
+
/** Capture file name prefix (`<key>@<width>.json.gz`); default `page`. */
|
|
23
|
+
key: string;
|
|
24
|
+
/**
|
|
25
|
+
* Viewport widths to sweep, one per @media band. Empty = auto-detect from the
|
|
26
|
+
* loaded CSSOM (fails loudly on a cross-origin/unreadable stylesheet — pass
|
|
27
|
+
* widths explicitly for a page whose CSS can't be read, e.g. a cross-origin
|
|
28
|
+
* font stylesheet).
|
|
29
|
+
*/
|
|
30
|
+
widths: number[];
|
|
31
|
+
/** Output directory for the maps (+ screenshots). */
|
|
32
|
+
out: string;
|
|
33
|
+
/** Selectors for nondeterministic regions to skip (passed through to capture). */
|
|
34
|
+
ignore: string[];
|
|
35
|
+
/** Wait for this selector to be visible before capturing (reach the intended state). */
|
|
36
|
+
waitSelector?: string;
|
|
37
|
+
/** Viewport height (default 800). */
|
|
38
|
+
height: number;
|
|
39
|
+
/** Also write a full-page `.png` per capture (default true). */
|
|
40
|
+
screenshots: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Crawl the URL's whole interactive surface instead of capturing one state:
|
|
43
|
+
* drive every non-destructive control, recurse into what opens, capture each
|
|
44
|
+
* discovered surface under a derived key. See {@link crawlAndCapture}.
|
|
45
|
+
*/
|
|
46
|
+
crawl: boolean;
|
|
47
|
+
/** crawl: recursion depth into opened surfaces (default 3). */
|
|
48
|
+
maxDepth: number;
|
|
49
|
+
/** crawl: fresh controls driven per state (default: unbounded — try them all). */
|
|
50
|
+
maxActionsPerState: number;
|
|
51
|
+
/** crawl: safety backstop on total surfaces (default: unbounded — exhaustive). */
|
|
52
|
+
maxStates: number;
|
|
53
|
+
/** crawl: clear storage on each reset so replay is deterministic (default true). */
|
|
54
|
+
resetStorage: boolean;
|
|
55
|
+
/** crawl: exit non-zero unless every class the page's stylesheets define was
|
|
56
|
+
* rendered in at least one captured surface (default false — report only). */
|
|
57
|
+
requireFullCoverage: boolean;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
|
|
61
|
+
* flow (help/exit codes) and this parse are testable without a browser.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseCaptureUrlArgs(argv: string[]): CaptureUrlOptions;
|
|
64
|
+
/** Written artifacts for one width. */
|
|
65
|
+
export type CaptureUrlResult = {
|
|
66
|
+
width: number;
|
|
67
|
+
map: string;
|
|
68
|
+
screenshot?: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Capture `opts.url` at each width using an already-open {@link Page}, writing
|
|
72
|
+
* `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
|
|
73
|
+
* width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
|
|
74
|
+
* arms the in-flight request tracker before each navigation so the page's own
|
|
75
|
+
* load fetches count toward the network-aware settle — same contract as a
|
|
76
|
+
* surface capture. Returns the files written.
|
|
77
|
+
*/
|
|
78
|
+
export declare function captureUrlToDir(page: Page, opts: CaptureUrlOptions): Promise<CaptureUrlResult[]>;
|
|
79
|
+
/** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
|
|
80
|
+
export declare function runCaptureUrl(opts: CaptureUrlOptions, launch: () => Promise<Browser>): Promise<CaptureUrlResult[]>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
4
|
+
import { detectViewportWidths } from './breakpoints.js';
|
|
5
|
+
/**
|
|
6
|
+
* One-shot capture of a single URL's computed-style map — no spec, no config,
|
|
7
|
+
* no git. `defineStyleMapCapture` is the right tool when the surfaces live in
|
|
8
|
+
* your own app and you want the coverage guard, the map store, and record/replay.
|
|
9
|
+
* This is the tool for a page you just want to point at: a deployed URL, a
|
|
10
|
+
* static export, or a standalone HTML mockup. Capture it into a directory of
|
|
11
|
+
* `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
|
|
12
|
+
* `styleproof-diff <a> <b>` — compares like any other capture.
|
|
13
|
+
*
|
|
14
|
+
* The output is deliberately the same shape a surface capture writes, so a
|
|
15
|
+
* mockup's map and your app's committed map are directly diffable: capture the
|
|
16
|
+
* design once, then diff each build against it to measure how close the
|
|
17
|
+
* implementation is (a diff that shrinks toward zero as it converges).
|
|
18
|
+
*/
|
|
19
|
+
/** Raised for bad CLI usage so the bin can print help and exit 2. */
|
|
20
|
+
export class UsageError extends Error {
|
|
21
|
+
}
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
key: 'page',
|
|
24
|
+
height: 800,
|
|
25
|
+
screenshots: true,
|
|
26
|
+
crawl: false,
|
|
27
|
+
// Exhaustive by default — these are safety backstops, not budgets.
|
|
28
|
+
maxDepth: 1000,
|
|
29
|
+
maxActionsPerState: 100000,
|
|
30
|
+
maxStates: 100000,
|
|
31
|
+
resetStorage: true,
|
|
32
|
+
requireFullCoverage: false,
|
|
33
|
+
};
|
|
34
|
+
function positiveNumber(raw, flag) {
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
37
|
+
throw new UsageError(`${flag}: not a positive number: ${raw}`);
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
function parseWidths(raw) {
|
|
41
|
+
const widths = raw
|
|
42
|
+
.split(',')
|
|
43
|
+
.map((s) => s.trim())
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((s) => positiveNumber(s, '--widths'));
|
|
46
|
+
if (widths.length === 0)
|
|
47
|
+
throw new UsageError('--widths: no widths given');
|
|
48
|
+
return widths;
|
|
49
|
+
}
|
|
50
|
+
// Table-driven so adding a flag is one entry, not another branch in the loop.
|
|
51
|
+
// value flags mutate the accumulator with their argument; bool flags take none.
|
|
52
|
+
const VALUE_FLAGS = {
|
|
53
|
+
'--key': (o, v) => (o.key = v),
|
|
54
|
+
'--widths': (o, v) => (o.widths = parseWidths(v)),
|
|
55
|
+
'--out': (o, v) => (o.out = v),
|
|
56
|
+
'--ignore': (o, v) => o.ignore.push(v),
|
|
57
|
+
'--wait': (o, v) => (o.waitSelector = v),
|
|
58
|
+
'--height': (o, v) => (o.height = positiveNumber(v, '--height')),
|
|
59
|
+
'--max-depth': (o, v) => (o.maxDepth = positiveNumber(v, '--max-depth')),
|
|
60
|
+
'--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
|
|
61
|
+
'--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
|
|
62
|
+
};
|
|
63
|
+
const BOOL_FLAGS = {
|
|
64
|
+
'--screenshots': (o) => (o.screenshots = true),
|
|
65
|
+
'--no-screenshots': (o) => (o.screenshots = false),
|
|
66
|
+
'--crawl': (o) => (o.crawl = true),
|
|
67
|
+
'--no-reset-storage': (o) => (o.resetStorage = false),
|
|
68
|
+
'--require-full-coverage': (o) => (o.requireFullCoverage = true),
|
|
69
|
+
};
|
|
70
|
+
// Apply one argv token to the accumulator; returns the index to resume from
|
|
71
|
+
// (advanced past a consumed `--flag value` pair). Flat early-returns so the
|
|
72
|
+
// parse loop stays trivial. Supports `--flag value` and `--flag=value`.
|
|
73
|
+
function applyArg(o, argv, i, positional) {
|
|
74
|
+
const a = argv[i];
|
|
75
|
+
const eq = a.startsWith('--') ? a.indexOf('=') : -1;
|
|
76
|
+
const name = eq === -1 ? a : a.slice(0, eq);
|
|
77
|
+
const bool = BOOL_FLAGS[name];
|
|
78
|
+
if (bool) {
|
|
79
|
+
bool(o);
|
|
80
|
+
return i;
|
|
81
|
+
}
|
|
82
|
+
const apply = VALUE_FLAGS[name];
|
|
83
|
+
if (apply) {
|
|
84
|
+
const v = eq === -1 ? argv[i + 1] : a.slice(eq + 1);
|
|
85
|
+
if (v === undefined)
|
|
86
|
+
throw new UsageError(`${name}: missing value`);
|
|
87
|
+
apply(o, v);
|
|
88
|
+
return eq === -1 ? i + 1 : i;
|
|
89
|
+
}
|
|
90
|
+
if (a.startsWith('--'))
|
|
91
|
+
throw new UsageError(`unknown flag: ${a}`);
|
|
92
|
+
positional.push(a);
|
|
93
|
+
return i;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
|
|
97
|
+
* flow (help/exit codes) and this parse are testable without a browser.
|
|
98
|
+
*/
|
|
99
|
+
export function parseCaptureUrlArgs(argv) {
|
|
100
|
+
const o = {
|
|
101
|
+
url: '',
|
|
102
|
+
key: DEFAULTS.key,
|
|
103
|
+
widths: [],
|
|
104
|
+
out: 'styleproof-capture',
|
|
105
|
+
ignore: [],
|
|
106
|
+
waitSelector: undefined,
|
|
107
|
+
height: DEFAULTS.height,
|
|
108
|
+
screenshots: DEFAULTS.screenshots,
|
|
109
|
+
crawl: DEFAULTS.crawl,
|
|
110
|
+
maxDepth: DEFAULTS.maxDepth,
|
|
111
|
+
maxActionsPerState: DEFAULTS.maxActionsPerState,
|
|
112
|
+
maxStates: DEFAULTS.maxStates,
|
|
113
|
+
resetStorage: DEFAULTS.resetStorage,
|
|
114
|
+
requireFullCoverage: DEFAULTS.requireFullCoverage,
|
|
115
|
+
};
|
|
116
|
+
const positional = [];
|
|
117
|
+
for (let i = 0; i < argv.length; i++)
|
|
118
|
+
i = applyArg(o, argv, i, positional);
|
|
119
|
+
if (positional.length === 0)
|
|
120
|
+
throw new UsageError('missing <url>');
|
|
121
|
+
if (positional.length > 1)
|
|
122
|
+
throw new UsageError(`expected one <url>, got ${positional.length}`);
|
|
123
|
+
o.url = positional[0];
|
|
124
|
+
return o;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Capture `opts.url` at each width using an already-open {@link Page}, writing
|
|
128
|
+
* `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
|
|
129
|
+
* width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
|
|
130
|
+
* arms the in-flight request tracker before each navigation so the page's own
|
|
131
|
+
* load fetches count toward the network-aware settle — same contract as a
|
|
132
|
+
* surface capture. Returns the files written.
|
|
133
|
+
*/
|
|
134
|
+
export async function captureUrlToDir(page, opts) {
|
|
135
|
+
fs.mkdirSync(opts.out, { recursive: true });
|
|
136
|
+
let widths = opts.widths;
|
|
137
|
+
if (widths.length === 0) {
|
|
138
|
+
await page.setViewportSize({ width: 1280, height: opts.height });
|
|
139
|
+
await page.goto(opts.url, { waitUntil: 'load' });
|
|
140
|
+
if (opts.waitSelector)
|
|
141
|
+
await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
|
|
142
|
+
widths = await detectViewportWidths(page);
|
|
143
|
+
}
|
|
144
|
+
const results = [];
|
|
145
|
+
for (const width of widths) {
|
|
146
|
+
await page.setViewportSize({ width, height: opts.height });
|
|
147
|
+
const requests = trackInflightRequests(page);
|
|
148
|
+
try {
|
|
149
|
+
await page.goto(opts.url, { waitUntil: 'load' });
|
|
150
|
+
if (opts.waitSelector)
|
|
151
|
+
await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
|
|
152
|
+
const map = await captureStyleMap(page, {
|
|
153
|
+
ignore: opts.ignore,
|
|
154
|
+
pendingRequests: requests.pending,
|
|
155
|
+
metadata: { surfaceKey: opts.key },
|
|
156
|
+
});
|
|
157
|
+
const stem = path.join(opts.out, `${opts.key}@${width}`);
|
|
158
|
+
const mapPath = `${stem}.json.gz`;
|
|
159
|
+
saveStyleMap(mapPath, map);
|
|
160
|
+
const result = { width, map: mapPath };
|
|
161
|
+
if (opts.screenshots) {
|
|
162
|
+
const shot = `${stem}.png`;
|
|
163
|
+
// captureStyleMap froze animations, so the shot matches the mapped state.
|
|
164
|
+
await page.screenshot({ path: shot, fullPage: true, animations: 'disabled' });
|
|
165
|
+
result.screenshot = shot;
|
|
166
|
+
}
|
|
167
|
+
results.push(result);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
requests.dispose();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
/** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
|
|
176
|
+
export async function runCaptureUrl(opts, launch) {
|
|
177
|
+
const browser = await launch();
|
|
178
|
+
try {
|
|
179
|
+
const page = await browser.newPage();
|
|
180
|
+
return await captureUrlToDir(page, opts);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await browser.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Surface crawler: deterministically map a single URL's WHOLE interactive
|
|
4
|
+
* surface — not just the state it loads in — and run to natural termination.
|
|
5
|
+
*
|
|
6
|
+
* A one-shot capture records the page as it lands, so a design that's mostly
|
|
7
|
+
* modals, drawers, and popovers has most of its surface behind clicks it never
|
|
8
|
+
* reaches. This crawls it instead: from the base state it finds every visible,
|
|
9
|
+
* non-destructive control (semantic controls AND anything the page styles as
|
|
10
|
+
* clickable — `cursor: pointer`) and drives each one, keeping whatever opens a
|
|
11
|
+
* structurally new surface. It then recurses depth-first into each new surface,
|
|
12
|
+
* so a modal's tabs, a drawer's sub-views, and a popover's panels are all mapped.
|
|
13
|
+
*
|
|
14
|
+
* EXHAUSTIVE by default: the crawl stops when the queue drains and every control
|
|
15
|
+
* has been driven once — not at a budget. Termination is guaranteed by dedup
|
|
16
|
+
* (a control is driven once by selector; a structural surface is captured once;
|
|
17
|
+
* both sets are finite). The `--max-*` options exist only as throttles.
|
|
18
|
+
*
|
|
19
|
+
* The sweep is IN-PLACE with lazy resets, which is what makes exhaustive
|
|
20
|
+
* affordable: standing in a state, each candidate is clicked where the page
|
|
21
|
+
* already is, and a cheap DOM fingerprint (tag+class in document order — no
|
|
22
|
+
* computed styles) decides what happened. A no-op click costs nothing; only a
|
|
23
|
+
* state-changing click pays a reset (fresh navigation + path replay), and every
|
|
24
|
+
* reset is VERIFIED against the state's fingerprint before the sweep continues,
|
|
25
|
+
* so children are never attributed to the wrong parent. New surfaces are
|
|
26
|
+
* captured in place the moment they're reached — a deep or animated click-path
|
|
27
|
+
* is never re-driven to capture, so it can't be the thing that drops a surface.
|
|
28
|
+
*
|
|
29
|
+
* Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked —
|
|
30
|
+
* mapping must not mutate. States gated behind such an action need a spec.
|
|
31
|
+
*/
|
|
32
|
+
export type CrawlStep = {
|
|
33
|
+
action: 'click' | 'select-option';
|
|
34
|
+
selector: string;
|
|
35
|
+
label: string;
|
|
36
|
+
reason: string;
|
|
37
|
+
value?: string;
|
|
38
|
+
};
|
|
39
|
+
export type CrawledSurface = {
|
|
40
|
+
key: string;
|
|
41
|
+
depth: number;
|
|
42
|
+
path: CrawlStep[];
|
|
43
|
+
elements: number;
|
|
44
|
+
};
|
|
45
|
+
/** Did the crawl SEE everything the design styles? `missing` lists classes the
|
|
46
|
+
* page's own stylesheets select on that never appeared in any captured surface —
|
|
47
|
+
* dead CSS, or a state the crawl could not reach. Empty missing = full coverage. */
|
|
48
|
+
export type CrawlCoverage = {
|
|
49
|
+
defined: number;
|
|
50
|
+
rendered: number;
|
|
51
|
+
missing: string[];
|
|
52
|
+
};
|
|
53
|
+
export type CrawlReport = {
|
|
54
|
+
surfaces: CrawledSurface[];
|
|
55
|
+
actionsTried: number;
|
|
56
|
+
skipped: number;
|
|
57
|
+
/** Surfaces successfully captured to disk at every width. */
|
|
58
|
+
captured: number;
|
|
59
|
+
/** Keys of surfaces discovered but whose full capture failed. */
|
|
60
|
+
failed: string[];
|
|
61
|
+
coverage: CrawlCoverage;
|
|
62
|
+
};
|
|
63
|
+
export type SurfaceCrawlOptions = {
|
|
64
|
+
url: string;
|
|
65
|
+
out: string;
|
|
66
|
+
widths: number[];
|
|
67
|
+
ignore: string[];
|
|
68
|
+
height: number;
|
|
69
|
+
screenshots: boolean;
|
|
70
|
+
waitSelector?: string;
|
|
71
|
+
/** Throttle: recursion depth into opened surfaces (base = 0). Default: unbounded. */
|
|
72
|
+
maxDepth: number;
|
|
73
|
+
/** Throttle: fresh controls driven per state. Default: unbounded — try them all. */
|
|
74
|
+
maxActionsPerState: number;
|
|
75
|
+
/** Throttle: total surfaces. Default: unbounded — run to natural termination. */
|
|
76
|
+
maxStates: number;
|
|
77
|
+
/** Clear localStorage/sessionStorage on each reset so replay is deterministic. Default true. */
|
|
78
|
+
resetStorage: boolean;
|
|
79
|
+
/** Called as each surface is recorded (captured=false when its full capture failed).
|
|
80
|
+
* Lets CLIs stream progress instead of reporting only at the end. */
|
|
81
|
+
onSurface?: (surface: CrawledSurface, captured: boolean) => void;
|
|
82
|
+
};
|
|
83
|
+
export declare const CRAWL_DEFAULTS: {
|
|
84
|
+
height: number;
|
|
85
|
+
screenshots: boolean;
|
|
86
|
+
maxDepth: number;
|
|
87
|
+
maxActionsPerState: number;
|
|
88
|
+
maxStates: number;
|
|
89
|
+
resetStorage: boolean;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Crawl `opts.url` and capture every reachable surface at every width — runs to
|
|
93
|
+
* natural termination by default. Returns the surfaces mapped (with the click-path
|
|
94
|
+
* that reached each), how many actions were tried/skipped, and captured/failed.
|
|
95
|
+
*/
|
|
96
|
+
export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
|
+
// Exhaustive by default — these ceilings are safety backstops, not budgets.
|
|
6
|
+
export const CRAWL_DEFAULTS = {
|
|
7
|
+
height: 900,
|
|
8
|
+
screenshots: true,
|
|
9
|
+
maxDepth: 1000,
|
|
10
|
+
maxActionsPerState: 100000,
|
|
11
|
+
maxStates: 100000,
|
|
12
|
+
resetStorage: true,
|
|
13
|
+
};
|
|
14
|
+
function slug(value) {
|
|
15
|
+
return (value
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '')
|
|
19
|
+
.slice(0, 24) || 'state');
|
|
20
|
+
}
|
|
21
|
+
function pathAndSearch(url) {
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
return u.pathname + u.search;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function deriveKey(steps, used) {
|
|
31
|
+
const base = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
|
|
32
|
+
let key = base;
|
|
33
|
+
for (let i = 2; used.has(key); i++)
|
|
34
|
+
key = `${base}-${i}`;
|
|
35
|
+
used.add(key);
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
/** Runs in the browser: every visible, enabled, non-navigating control worth trying. */
|
|
39
|
+
/* c8 ignore start */ // fallow-ignore-next-line complexity
|
|
40
|
+
function collectClickable() {
|
|
41
|
+
const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
|
|
42
|
+
const DANGER = /\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop)\b/i;
|
|
43
|
+
const esc = (v) => CSS.escape(v);
|
|
44
|
+
const quote = (v) => JSON.stringify(v);
|
|
45
|
+
const visible = (el) => {
|
|
46
|
+
const b = el.getBoundingClientRect();
|
|
47
|
+
const cs = getComputedStyle(el);
|
|
48
|
+
return (b.width > 0 && b.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && cs.pointerEvents !== 'none');
|
|
49
|
+
};
|
|
50
|
+
const unique = (s) => document.querySelectorAll(s).length === 1;
|
|
51
|
+
const pathSelector = (el) => {
|
|
52
|
+
const parts = [];
|
|
53
|
+
let cur = el;
|
|
54
|
+
while (cur && cur !== document.documentElement) {
|
|
55
|
+
let i = 1;
|
|
56
|
+
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
|
|
57
|
+
if (sib.tagName === cur.tagName)
|
|
58
|
+
i++;
|
|
59
|
+
parts.unshift(`${cur.tagName.toLowerCase()}:nth-of-type(${i})`);
|
|
60
|
+
cur = cur.parentElement;
|
|
61
|
+
}
|
|
62
|
+
return parts.join(' > ');
|
|
63
|
+
};
|
|
64
|
+
const selectorFor = (el) => {
|
|
65
|
+
const id = el.getAttribute('id');
|
|
66
|
+
if (id && unique(`#${esc(id)}`))
|
|
67
|
+
return `#${esc(id)}`;
|
|
68
|
+
for (const attr of ['data-testid', 'data-test', 'aria-label', 'name']) {
|
|
69
|
+
const v = el.getAttribute(attr);
|
|
70
|
+
if (v && unique(`${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`))
|
|
71
|
+
return `${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`;
|
|
72
|
+
}
|
|
73
|
+
return pathSelector(el);
|
|
74
|
+
};
|
|
75
|
+
const labelFor = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '')
|
|
76
|
+
.replace(/\s+/g, ' ')
|
|
77
|
+
.trim()
|
|
78
|
+
.slice(0, 80) || el.tagName.toLowerCase();
|
|
79
|
+
// Semantic controls first (stable, meaningful), then anything styled clickable.
|
|
80
|
+
// `grab` counts too: a draggable card is routinely ALSO a click target (open on
|
|
81
|
+
// click, drag to move), and we never drag — el.click() fires no drag gesture.
|
|
82
|
+
const pool = new Set([...document.querySelectorAll(SEMANTIC)]);
|
|
83
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
84
|
+
if (pool.has(el))
|
|
85
|
+
continue;
|
|
86
|
+
const cursor = getComputedStyle(el).cursor;
|
|
87
|
+
if (cursor === 'pointer' || cursor === 'grab')
|
|
88
|
+
pool.add(el);
|
|
89
|
+
}
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const el of pool) {
|
|
93
|
+
if (el instanceof HTMLAnchorElement && el.href)
|
|
94
|
+
continue; // links navigate — handled by link crawl, not here
|
|
95
|
+
if (el.closest(':disabled,[aria-disabled="true"]'))
|
|
96
|
+
continue;
|
|
97
|
+
if (!visible(el))
|
|
98
|
+
continue;
|
|
99
|
+
const selector = selectorFor(el);
|
|
100
|
+
if (seen.has(selector))
|
|
101
|
+
continue;
|
|
102
|
+
seen.add(selector);
|
|
103
|
+
const label = labelFor(el);
|
|
104
|
+
const unsafe = DANGER.test(label);
|
|
105
|
+
if (el instanceof HTMLSelectElement) {
|
|
106
|
+
const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
|
|
107
|
+
if (next)
|
|
108
|
+
out.push({ action: 'select-option', selector, label, reason: 'select-option', value: next.value, unsafe });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
out.push({
|
|
112
|
+
action: 'click',
|
|
113
|
+
selector,
|
|
114
|
+
label,
|
|
115
|
+
reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
|
|
116
|
+
unsafe,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/* c8 ignore stop */
|
|
123
|
+
/** Runs in the browser: the DOM's structural shape — every element's tag + class
|
|
124
|
+
* in document order (scripts/styles/meta skipped). Text and computed styles are
|
|
125
|
+
* deliberately excluded, so a ticking clock or animated opacity never reads as a
|
|
126
|
+
* new state, while any mount/unmount/class flip does. ~30ms vs the full
|
|
127
|
+
* computed-style walk it replaces for change detection. */
|
|
128
|
+
/* c8 ignore start */
|
|
129
|
+
function domShape() {
|
|
130
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'META', 'LINK', 'NOSCRIPT', 'TEMPLATE']);
|
|
131
|
+
const parts = [];
|
|
132
|
+
const classes = new Set();
|
|
133
|
+
const add = (el) => {
|
|
134
|
+
const cls = el.getAttribute('class') ?? '';
|
|
135
|
+
parts.push(`${el.tagName}.${cls}`);
|
|
136
|
+
for (const c of cls.split(/\s+/))
|
|
137
|
+
if (c)
|
|
138
|
+
classes.add(c);
|
|
139
|
+
};
|
|
140
|
+
// html/body themselves first: state classes there (a theme switch, modal-open)
|
|
141
|
+
// are a common pattern and restyle the whole page without touching any
|
|
142
|
+
// descendant's class — invisible to a descendants-only walk.
|
|
143
|
+
add(document.documentElement);
|
|
144
|
+
add(document.body);
|
|
145
|
+
for (const el of document.body.getElementsByTagName('*')) {
|
|
146
|
+
if (SKIP.has(el.tagName))
|
|
147
|
+
continue;
|
|
148
|
+
add(el);
|
|
149
|
+
}
|
|
150
|
+
return { shape: parts.join('\n'), elements: parts.length, classes: [...classes] };
|
|
151
|
+
}
|
|
152
|
+
/* c8 ignore stop */
|
|
153
|
+
/** Runs in the browser: every class name the page's OWN stylesheets select on —
|
|
154
|
+
* the design's defined vocabulary, read from the parsed CSSOM (inline and
|
|
155
|
+
* same-origin sheets; unreadable cross-origin sheets are skipped). Coverage is
|
|
156
|
+
* checked against this, so "fully covered" means every class the design styles
|
|
157
|
+
* was seen rendered in at least one captured surface. */
|
|
158
|
+
/* c8 ignore start */
|
|
159
|
+
function collectDefinedClasses() {
|
|
160
|
+
const out = new Set();
|
|
161
|
+
const scan = (rules) => {
|
|
162
|
+
if (!rules)
|
|
163
|
+
return;
|
|
164
|
+
for (const rule of rules) {
|
|
165
|
+
const r = rule;
|
|
166
|
+
if (r.selectorText) {
|
|
167
|
+
const re = /\.([A-Za-z_][A-Za-z0-9_-]*)/g;
|
|
168
|
+
for (let m = re.exec(r.selectorText); m; m = re.exec(r.selectorText))
|
|
169
|
+
out.add(m[1]);
|
|
170
|
+
}
|
|
171
|
+
if (r.cssRules)
|
|
172
|
+
scan(r.cssRules);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
for (const sheet of document.styleSheets) {
|
|
176
|
+
try {
|
|
177
|
+
scan(sheet.cssRules);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* cross-origin sheet — not the design's own vocabulary */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...out];
|
|
184
|
+
}
|
|
185
|
+
/* c8 ignore stop */
|
|
186
|
+
/** Structural fingerprint of the page's CURRENT state. Dedup key for surfaces. */
|
|
187
|
+
async function fingerprint(page) {
|
|
188
|
+
const fp = await page.evaluate(domShape);
|
|
189
|
+
return {
|
|
190
|
+
sig: createHash('sha256').update(fp.shape).digest('hex').slice(0, 16),
|
|
191
|
+
elements: fp.elements,
|
|
192
|
+
classes: fp.classes,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Wait for the DOM to stop changing (element count stable) — a cheap post-click
|
|
196
|
+
* settle that also covers content painted by a click-triggered fetch. */
|
|
197
|
+
async function settleDom(page, maxMs = 1200) {
|
|
198
|
+
let prev = -1;
|
|
199
|
+
const deadline = Date.now() + maxMs;
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
|
|
202
|
+
if (n === prev)
|
|
203
|
+
return;
|
|
204
|
+
prev = n;
|
|
205
|
+
await page.waitForTimeout(90);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Wait for an async-rendered app to finish mounting: the DOM stops growing and is
|
|
209
|
+
* non-trivial. Generic — no app-specific selector needed, so a bare crawl of a
|
|
210
|
+
* Babel/React/Vue page that boots after `load` still captures the mounted UI. */
|
|
211
|
+
async function waitSettled(page) {
|
|
212
|
+
await page.waitForLoadState('networkidle').catch(() => { });
|
|
213
|
+
let prev = -1;
|
|
214
|
+
for (let i = 0; i < 40; i++) {
|
|
215
|
+
const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
|
|
216
|
+
if (n > 5 && n === prev)
|
|
217
|
+
return;
|
|
218
|
+
prev = n;
|
|
219
|
+
await page.waitForTimeout(100);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Load the URL from a clean slate (storage cleared by the init script armed in
|
|
224
|
+
* crawlAndCapture — one navigation, no clear-then-reload) and wait for the app to
|
|
225
|
+
* mount. Pins the viewport to widths[0] so DISCOVERY always happens at one
|
|
226
|
+
* consistent width — captureInPlace sweeps the other widths and would otherwise
|
|
227
|
+
* leave the viewport wherever it finished (e.g. a mobile band where half the
|
|
228
|
+
* controls are hidden).
|
|
229
|
+
*/
|
|
230
|
+
async function gotoFresh(page, opts) {
|
|
231
|
+
await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
|
|
232
|
+
await page.goto(opts.url, { waitUntil: 'load' });
|
|
233
|
+
// Tolerant wait: the generic settle below is the real readiness signal; an
|
|
234
|
+
// optional waitSelector just accelerates it and must not fail the crawl.
|
|
235
|
+
const ready = opts.waitSelector ? page.locator(opts.waitSelector).first() : null;
|
|
236
|
+
if (ready)
|
|
237
|
+
await ready.waitFor({ state: 'visible' }).catch(() => { });
|
|
238
|
+
await waitSettled(page);
|
|
239
|
+
}
|
|
240
|
+
async function perform(page, s) {
|
|
241
|
+
const target = page.locator(s.selector).first();
|
|
242
|
+
if (s.action === 'select-option') {
|
|
243
|
+
await target.selectOption(s.value ?? '');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await target.waitFor({ state: 'attached', timeout: 5000 });
|
|
247
|
+
await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
|
|
248
|
+
// Dispatch the click IN-PAGE rather than through Playwright's actionability
|
|
249
|
+
// pipeline. `el.click()` fires the app's real (delegated) click handler but
|
|
250
|
+
// skips the stability/visibility/hit-testing waits that make Playwright's own
|
|
251
|
+
// click flake on an opening modal — the intermittent failure that silently
|
|
252
|
+
// dropped deep states on re-drive. We only need to REACH the state to map it.
|
|
253
|
+
const dispatched = await target
|
|
254
|
+
.evaluate((el) => {
|
|
255
|
+
if (el instanceof HTMLElement) {
|
|
256
|
+
el.click();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
})
|
|
261
|
+
.catch(() => false);
|
|
262
|
+
if (!dispatched)
|
|
263
|
+
await target.click({ timeout: 3000 }); // non-HTMLElement (SVG etc.)
|
|
264
|
+
}
|
|
265
|
+
async function replay(page, steps) {
|
|
266
|
+
for (const s of steps) {
|
|
267
|
+
await perform(page, s);
|
|
268
|
+
await settleDom(page);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Reset to a known state and VERIFY arrival by fingerprint (retry once). A false
|
|
272
|
+
* return means the path won't reproduce right now — the caller abandons the rest
|
|
273
|
+
* of that state's sweep (fail-soft) rather than mis-attributing children. */
|
|
274
|
+
async function resetToState(page, opts, steps, wantSig) {
|
|
275
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
276
|
+
try {
|
|
277
|
+
await gotoFresh(page, opts);
|
|
278
|
+
await replay(page, steps);
|
|
279
|
+
if ((await fingerprint(page)).sig === wantSig)
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* retry */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
/** Capture the page's CURRENT state (already driven to) at every width, resizing
|
|
289
|
+
* in place. Capturing where discovery already stands means a fragile click-path
|
|
290
|
+
* is never replayed a second time — the failure mode of re-driving deep, animated
|
|
291
|
+
* controls. */
|
|
292
|
+
async function captureInPlace(page, key, opts) {
|
|
293
|
+
for (const width of opts.widths) {
|
|
294
|
+
await page.setViewportSize({ width, height: opts.height });
|
|
295
|
+
const requests = trackInflightRequests(page);
|
|
296
|
+
try {
|
|
297
|
+
const map = await captureStyleMap(page, {
|
|
298
|
+
ignore: opts.ignore,
|
|
299
|
+
pendingRequests: requests.pending,
|
|
300
|
+
metadata: { surfaceKey: key },
|
|
301
|
+
});
|
|
302
|
+
const stem = path.join(opts.out, `${key}@${width}`);
|
|
303
|
+
saveStyleMap(`${stem}.json.gz`, map);
|
|
304
|
+
if (opts.screenshots)
|
|
305
|
+
await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
requests.dispose();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Identity of a state for the changer registry: the click-path that defines it. */
|
|
313
|
+
function stateKey(steps) {
|
|
314
|
+
return steps.map((s) => s.selector + (s.value ?? '')).join(' → ');
|
|
315
|
+
}
|
|
316
|
+
/** Record a newly-found surface, capture it in place (page is already there), and
|
|
317
|
+
* queue it for its own sweep. Streams progress via onSurface. */
|
|
318
|
+
async function record(page, opts, newPath, depth, fp, st) {
|
|
319
|
+
const key = deriveKey(newPath, st.used);
|
|
320
|
+
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
321
|
+
st.surfaces.push(surface);
|
|
322
|
+
st.queue.push({ path: newPath, depth, sig: fp.sig });
|
|
323
|
+
for (const c of fp.classes)
|
|
324
|
+
st.classes.add(c);
|
|
325
|
+
let ok = true;
|
|
326
|
+
try {
|
|
327
|
+
await captureInPlace(page, key, opts);
|
|
328
|
+
st.captured++;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
st.failed.push(key);
|
|
332
|
+
ok = false;
|
|
333
|
+
}
|
|
334
|
+
opts.onSurface?.(surface, ok);
|
|
335
|
+
}
|
|
336
|
+
/** Click one candidate where the page stands; classify the outcome. */
|
|
337
|
+
async function tryInPlace(page, c) {
|
|
338
|
+
const startUrl = pathAndSearch(page.url());
|
|
339
|
+
const before = (await fingerprint(page)).sig;
|
|
340
|
+
try {
|
|
341
|
+
await perform(page, c);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return 'failed';
|
|
345
|
+
}
|
|
346
|
+
await settleDom(page);
|
|
347
|
+
if (pathAndSearch(page.url()) !== startUrl)
|
|
348
|
+
return 'navigated';
|
|
349
|
+
return (await fingerprint(page)).sig === before ? 'noop' : 'changed';
|
|
350
|
+
}
|
|
351
|
+
/** Drive one candidate from where the page stands. Returns whether the page is
|
|
352
|
+
* still in the swept state (no-op click) and whether the action was a skip. */
|
|
353
|
+
async function driveCandidate(page, opts, entry, c, st) {
|
|
354
|
+
const outcome = await tryInPlace(page, c);
|
|
355
|
+
if (outcome === 'noop')
|
|
356
|
+
return { inState: true, skipped: false }; // still in the state — no reset needed
|
|
357
|
+
if (outcome === 'failed' || outcome === 'navigated')
|
|
358
|
+
return { inState: false, skipped: true };
|
|
359
|
+
// Register the changer for family retry — persistence checked in the state its
|
|
360
|
+
// own click produced (a tab survives its switch; an approve row consumes itself).
|
|
361
|
+
const persists = await page
|
|
362
|
+
.locator(c.selector)
|
|
363
|
+
.first()
|
|
364
|
+
.isVisible()
|
|
365
|
+
.catch(() => false);
|
|
366
|
+
const from = stateKey(entry.path);
|
|
367
|
+
const list = st.changersFrom.get(from) ?? [];
|
|
368
|
+
if (!list.some((x) => x.c.selector === c.selector))
|
|
369
|
+
list.push({ c, persists });
|
|
370
|
+
st.changersFrom.set(from, list);
|
|
371
|
+
const fp = await fingerprint(page);
|
|
372
|
+
if (st.seen.has(fp.sig))
|
|
373
|
+
return { inState: false, skipped: false }; // same surface reached another way
|
|
374
|
+
st.seen.add(fp.sig);
|
|
375
|
+
const step = {
|
|
376
|
+
action: c.action,
|
|
377
|
+
selector: c.selector,
|
|
378
|
+
label: c.label,
|
|
379
|
+
reason: c.reason,
|
|
380
|
+
...(c.value ? { value: c.value } : {}),
|
|
381
|
+
};
|
|
382
|
+
await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st);
|
|
383
|
+
return { inState: false, skipped: false };
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Sweep one state: drive every not-yet-tried control reachable from it, IN PLACE.
|
|
387
|
+
* A no-op click leaves the page in the state, so the sweep just continues; only a
|
|
388
|
+
* state-changing click (new surface, dup, navigation, or failure) pays a verified
|
|
389
|
+
* reset before the next candidate.
|
|
390
|
+
*/
|
|
391
|
+
/** The family-retry list for a state: its parent's persistent mode-switchers that
|
|
392
|
+
* are visible right now — minus the step that created this state itself. */
|
|
393
|
+
function familyRetries(entry, all, st) {
|
|
394
|
+
if (entry.path.length === 0)
|
|
395
|
+
return [];
|
|
396
|
+
const parentKey = stateKey(entry.path.slice(0, -1));
|
|
397
|
+
const ownSelector = entry.path[entry.path.length - 1].selector;
|
|
398
|
+
const visibleNow = new Set(all.map((c) => c.selector));
|
|
399
|
+
return (st.changersFrom.get(parentKey) ?? [])
|
|
400
|
+
.filter((x) => x.persists && x.c.selector !== ownSelector && visibleNow.has(x.c.selector))
|
|
401
|
+
.map((x) => x.c);
|
|
402
|
+
}
|
|
403
|
+
async function sweepState(page, opts, entry, st) {
|
|
404
|
+
if (!(await resetToState(page, opts, entry.path, entry.sig)))
|
|
405
|
+
return { tried: 0, skipped: 0 };
|
|
406
|
+
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
407
|
+
// Fresh controls first (already-driven global chrome would otherwise starve a
|
|
408
|
+
// deep surface's own controls; the throttle applies to fresh ones), then the
|
|
409
|
+
// parent's persistent mode-switchers re-tried in THIS sibling mode.
|
|
410
|
+
const fresh = all.filter((c) => !st.tried.has(c.selector)).slice(0, opts.maxActionsPerState);
|
|
411
|
+
const work = [
|
|
412
|
+
...fresh.map((c) => ({ c, retry: false })),
|
|
413
|
+
...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
|
|
414
|
+
];
|
|
415
|
+
let tried = 0;
|
|
416
|
+
let skipped = 0;
|
|
417
|
+
let inState = true;
|
|
418
|
+
for (const { c, retry } of work) {
|
|
419
|
+
if (st.surfaces.length >= opts.maxStates)
|
|
420
|
+
break;
|
|
421
|
+
if (!inState) {
|
|
422
|
+
if (!(await resetToState(page, opts, entry.path, entry.sig)))
|
|
423
|
+
break; // abandon rest, fail-soft
|
|
424
|
+
inState = true;
|
|
425
|
+
}
|
|
426
|
+
if (!retry)
|
|
427
|
+
st.tried.add(c.selector);
|
|
428
|
+
if (c.unsafe) {
|
|
429
|
+
skipped++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
tried++;
|
|
433
|
+
const r = await driveCandidate(page, opts, entry, c, st);
|
|
434
|
+
inState = r.inState;
|
|
435
|
+
if (r.skipped)
|
|
436
|
+
skipped++;
|
|
437
|
+
}
|
|
438
|
+
return { tried, skipped };
|
|
439
|
+
}
|
|
440
|
+
/** Depth-first discovery + in-place capture of every reachable surface. Depth-first
|
|
441
|
+
* so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
|
|
442
|
+
* branch is fresh; with no budget, order affects time-to-depth, not coverage. */
|
|
443
|
+
async function discover(page, opts) {
|
|
444
|
+
fs.mkdirSync(opts.out, { recursive: true });
|
|
445
|
+
await gotoFresh(page, opts);
|
|
446
|
+
const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
|
|
447
|
+
const fp = await fingerprint(page);
|
|
448
|
+
const st = {
|
|
449
|
+
seen: new Set([fp.sig]),
|
|
450
|
+
used: new Set(),
|
|
451
|
+
tried: new Set(),
|
|
452
|
+
changersFrom: new Map(),
|
|
453
|
+
classes: new Set(),
|
|
454
|
+
surfaces: [],
|
|
455
|
+
queue: [],
|
|
456
|
+
captured: 0,
|
|
457
|
+
failed: [],
|
|
458
|
+
};
|
|
459
|
+
await record(page, opts, [], 0, fp, st);
|
|
460
|
+
let actionsTried = 0;
|
|
461
|
+
let skipped = 0;
|
|
462
|
+
while (st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
|
|
463
|
+
const entry = st.queue.pop(); // LIFO → depth-first
|
|
464
|
+
if (entry.depth >= opts.maxDepth)
|
|
465
|
+
continue;
|
|
466
|
+
const r = await sweepState(page, opts, entry, st);
|
|
467
|
+
actionsTried += r.tried;
|
|
468
|
+
skipped += r.skipped;
|
|
469
|
+
}
|
|
470
|
+
const missing = defined.filter((c) => !st.classes.has(c)).sort();
|
|
471
|
+
return {
|
|
472
|
+
surfaces: st.surfaces,
|
|
473
|
+
actionsTried,
|
|
474
|
+
skipped,
|
|
475
|
+
captured: st.captured,
|
|
476
|
+
failed: st.failed,
|
|
477
|
+
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Crawl `opts.url` and capture every reachable surface at every width — runs to
|
|
482
|
+
* natural termination by default. Returns the surfaces mapped (with the click-path
|
|
483
|
+
* that reached each), how many actions were tried/skipped, and captured/failed.
|
|
484
|
+
*/
|
|
485
|
+
export async function crawlAndCapture(page, opts) {
|
|
486
|
+
if (opts.resetStorage) {
|
|
487
|
+
// Clear storage before the app's code runs on EVERY load, so each gotoFresh is
|
|
488
|
+
// a clean slate in one navigation (no clear-then-reload round trip).
|
|
489
|
+
await page.addInitScript(() => {
|
|
490
|
+
try {
|
|
491
|
+
localStorage.clear();
|
|
492
|
+
sessionStorage.clear();
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
/* storage unavailable (e.g. file://) — ignore */
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return discover(page, opts);
|
|
500
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
+
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
|
+
export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
|
|
4
|
+
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
5
|
+
export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep } from './crawl-surfaces.js';
|
|
2
6
|
export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
|
|
3
7
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
4
8
|
export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
+
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
|
+
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
2
4
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
3
5
|
export { coverageGaps } from './coverage.js';
|
|
4
6
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
package/dist/report.js
CHANGED
|
@@ -8,8 +8,7 @@ import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
|
|
|
8
8
|
// through the package's report module rather than a deep path.
|
|
9
9
|
export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
|
|
10
10
|
// Hidden marker appended to a new-surface heading. Invisible in rendered
|
|
11
|
-
// markdown; lets the PR-comment layer
|
|
12
|
-
// surface (vs the required box on a real change), so new surfaces never gate.
|
|
11
|
+
// markdown; lets the PR-comment layer recognize one-sided surfaces.
|
|
13
12
|
const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
|
|
14
13
|
const rectToBox = (r) => ({ x: r[0], y: r[1], w: r[2], h: r[3] });
|
|
15
14
|
const pad = (b, by) => ({ x: b.x - by, y: b.y - by, w: b.w + 2 * by, h: b.h + 2 * by });
|
|
@@ -910,8 +909,8 @@ export function generateStyleMapReport(opts) {
|
|
|
910
909
|
if (missing.length > 0) {
|
|
911
910
|
if (changeGroups.length > 0)
|
|
912
911
|
md.push('');
|
|
913
|
-
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for
|
|
914
|
-
`
|
|
912
|
+
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for review. ` +
|
|
913
|
+
`Approve them before they become the baseline.`);
|
|
915
914
|
}
|
|
916
915
|
}
|
|
917
916
|
if (volatileCount > 0) {
|
|
@@ -1037,8 +1036,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1037
1036
|
json.push(surfaceJson);
|
|
1038
1037
|
}
|
|
1039
1038
|
// New surfaces: present on only one side, so there's nothing to diff. Show the
|
|
1040
|
-
// captured side as a single screenshot
|
|
1041
|
-
// PR comment can attach an OPTIONAL approval box — these never gate the check.
|
|
1039
|
+
// captured side as a single screenshot and mark the heading for the PR comment.
|
|
1042
1040
|
for (const p of missing) {
|
|
1043
1041
|
const side = p.sd.missing === 'before' ? 'after' : 'before';
|
|
1044
1042
|
const srcDir = side === 'after' ? afterDir : beforeDir;
|
|
@@ -1058,7 +1056,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1058
1056
|
else {
|
|
1059
1057
|
md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
|
|
1060
1058
|
}
|
|
1061
|
-
md.push('', `_No baseline to compare against — this surface is new
|
|
1059
|
+
md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
|
|
1062
1060
|
json.push(surfaceJson);
|
|
1063
1061
|
}
|
|
1064
1062
|
md.push(...contentSection.md);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"bin": {
|
|
39
39
|
"styleproof-init": "./bin/styleproof-init.mjs",
|
|
40
40
|
"styleproof-map": "./bin/styleproof-map.mjs",
|
|
41
|
+
"styleproof-capture": "./bin/styleproof-capture.mjs",
|
|
41
42
|
"styleproof-diff": "./bin/styleproof-diff.mjs",
|
|
42
43
|
"styleproof-report": "./bin/styleproof-report.mjs",
|
|
43
44
|
"styleproof-variants": "./bin/styleproof-variants.mjs"
|