styleproof 3.1.0 → 3.1.2
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 +72 -1
- package/README.md +29 -7
- package/bin/styleproof-init.mjs +80 -2
- package/bin/styleproof-map.mjs +16 -2
- package/dist/capture.d.ts +5 -4
- package/dist/capture.js +39 -12
- package/dist/diff.js +5 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,71 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.1.2] - 2026-06-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Documented the sound way to skip StyleProof work: skip the **entire** CI
|
|
15
|
+
workflow for docs-only / non-rendered paths with native path filters, but do
|
|
16
|
+
not skip individual surfaces based on changed-file guesses.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **Live-region detection now respects the surface's `ignore` list.** A region
|
|
21
|
+
the caller ignored was still scanned for `aria-live`/`role` and surfaced (and
|
|
22
|
+
persisted into the committed map) as a live-state candidate, unlike every other
|
|
23
|
+
capture pass which honours the merged `ignore`. Ignored regions are now excluded
|
|
24
|
+
from `liveCandidates` too.
|
|
25
|
+
- **The scaffolded CI workflow no longer blocks PRs that only add new surfaces.**
|
|
26
|
+
`styleproof init` generated a workflow that failed on any non-zero diff exit
|
|
27
|
+
code, so a new-surface-only diff (exit `3`) held the check red and the PR
|
|
28
|
+
receipt claimed "Visual changes detected" — contradicting the documented
|
|
29
|
+
contract that new surfaces never block. The gate now fails only on reviewable
|
|
30
|
+
diffs (exit `1`) and errors (exit `2`), matching `action.yml`, and the receipt
|
|
31
|
+
reports new surfaces explicitly.
|
|
32
|
+
- **A truncated forced-state layer is no longer silently certified as
|
|
33
|
+
identical.** When a surface had more interactive elements than
|
|
34
|
+
`maxInteractive`, capture truncated the `:hover/:focus/:active` layer but left
|
|
35
|
+
`statesSkipped` unset, so the uncaptured states read as "identical" against a
|
|
36
|
+
fully-captured side — the exact false-certification the flag exists to prevent.
|
|
37
|
+
Truncation now sets `statesSkipped`, and the diff reports the layer as "not
|
|
38
|
+
fully captured" on the affected side.
|
|
39
|
+
- **Release workflow no longer passes an empty `body_path` to the GitHub Release
|
|
40
|
+
step.** Previously a release whose CHANGELOG version section was absent (e.g. a
|
|
41
|
+
hotfix tag with no entry) passed `body_path: ''`, which `softprops/action-gh-release`
|
|
42
|
+
treats as a file to read and fails with ENOENT instead of auto-generating notes.
|
|
43
|
+
The step is now split into two mutually-exclusive steps: one with
|
|
44
|
+
`body_path: notes.md`, one with `generate_release_notes: true`.
|
|
45
|
+
|
|
46
|
+
## [3.1.1] - 2026-06-27
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **Release workflow now verifies npm publication before tagging.** A release
|
|
51
|
+
fails before creating tags or GitHub Releases if `NPM_TOKEN` is missing, and
|
|
52
|
+
it verifies `styleproof@<version>` on npm after publishing or detecting an
|
|
53
|
+
already-published version.
|
|
54
|
+
- **Generated pre-push hooks now restore no-op map churn before committing.** The
|
|
55
|
+
hook captures through `styleproof-map`, runs a semantic diff against `HEAD`, and
|
|
56
|
+
restores `stylemaps/` when the refreshed map is identical. When the map really
|
|
57
|
+
changes, the hook prints the diff plus a concrete live-state/replay hint before
|
|
58
|
+
creating the map commit.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- **Real cursor hover no longer contaminates the resting style map.** Capture now
|
|
63
|
+
parks the mouse over an ignored 1px hover sink before reading, matching the
|
|
64
|
+
existing focus blur behavior. The canonical resting map stays unhovered while
|
|
65
|
+
the forced `:hover` layer still records the hover delta.
|
|
66
|
+
- **`styleproof-map` now writes lean committed maps by default.** Recorded HAR
|
|
67
|
+
files are removed after successful capture so private API responses are not
|
|
68
|
+
accidentally committed by the default pre-push flow. Use `--keep-har` or
|
|
69
|
+
`STYLEPROOF_KEEP_HAR=1` for explicit record/replay workflows.
|
|
70
|
+
- **Generated pre-push hooks now clean untracked no-op artifacts.** When the
|
|
71
|
+
refreshed map is semantically identical to `HEAD`, the hook restores tracked
|
|
72
|
+
`stylemaps/` files and removes untracked files under `stylemaps/current`, so a
|
|
73
|
+
clean push leaves no generated capture debris behind.
|
|
74
|
+
|
|
10
75
|
## [3.1.0] - 2026-06-27
|
|
11
76
|
|
|
12
77
|
### Changed
|
|
@@ -47,6 +112,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
47
112
|
generated tests are registered through the package runner. It now targets the
|
|
48
113
|
capture suite with Playwright's grep path, matching the public
|
|
49
114
|
`styleproof-init` → `styleproof-map` → `styleproof-diff` flow.
|
|
115
|
+
- **Clean StyleProof PR runs now leave a visible receipt.** The Action creates or
|
|
116
|
+
updates its PR comment with `No visual changes detected.` even when there is no
|
|
117
|
+
existing report comment yet, and `styleproof-init` scaffolds the same
|
|
118
|
+
no-change PR comment for the browser-less committed-map workflow.
|
|
50
119
|
- **Layout-equivalent auto-margin drift no longer creates phantom diffs.** Some
|
|
51
120
|
browser/forced-state combinations can report horizontal `margin-left` /
|
|
52
121
|
`margin-right` / logical margin equivalents differently even when the captured
|
|
@@ -889,7 +958,9 @@ number)`), so each viewport band can capture at its own height. Default remains
|
|
|
889
958
|
- `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
|
|
890
959
|
property, and state that drifted (exit 1).
|
|
891
960
|
|
|
892
|
-
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.
|
|
961
|
+
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.2...HEAD
|
|
962
|
+
[3.1.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.1...v3.1.2
|
|
963
|
+
[3.1.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.0...v3.1.1
|
|
893
964
|
[3.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.2...v3.1.0
|
|
894
965
|
[3.0.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.1...v3.0.2
|
|
895
966
|
[3.0.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.0...v3.0.1
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ On every PR, StyleProof diffs the PR head's computed-style map against the base
|
|
|
16
16
|
|
|
17
17
|
- A **lean summary comment** linking to a committed side-by-side report — the report is the complete source of truth (**one section per distinct change**, with a before/after cropped screenshot cropped from the same rectangle so the two sides line up exactly, **plain-English bullets that tell you what to look for** — `columns: 2 → 3`, `recoloured cyan → amber` — and the exact property changes). The comment never duplicates the report, so the two can't drift, and it renders identically on public and private repos.
|
|
18
18
|
- A single **Approve all changes** checkbox in the comment, driving a `StyleProof` commit status: red until one tick signs off every change, green when there are none. The reviewer who ticks it is recorded inline (_approved by @them_), sourced from the commit status so it survives a report re-run.
|
|
19
|
+
- **Clean runs leave a receipt too.** When no visual changes are detected, StyleProof still creates or updates the PR comment with `✓ No visual changes detected.`, so a green status has a visible record instead of only a silent check.
|
|
19
20
|
- **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
|
|
20
21
|
- The diff is always **head-vs-base**, so the report is _exactly what this PR changes_ — whether the maps are captured fresh in CI or (by default) committed pre-push and diffed browser-free in CI. See [Quickstart](#quickstart).
|
|
21
22
|
|
|
@@ -167,7 +168,7 @@ It scaffolds:
|
|
|
167
168
|
|
|
168
169
|
- a **capture spec** (`e2e/styleproof.spec.ts`) describing your surfaces (a Next.js app gets its routes _and_ the coverage guard wired automatically — see below);
|
|
169
170
|
- a **`playwright.config.ts`** that builds and serves a **production build** (never a flaky dev server) and captures surfaces **in parallel** (`fullyParallel`);
|
|
170
|
-
- a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, commits
|
|
171
|
+
- a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, restores no-op map churn, commits real map changes as lean `.json.gz` files under `stylemaps/`, and **pushes them with your branch — one `git push`, never two**;
|
|
171
172
|
- a **browser-less CI workflow** that diffs your committed map against the base branch.
|
|
172
173
|
|
|
173
174
|
Describe your surfaces — **omit `widths`** and StyleProof sweeps your real `@media` breakpoints automatically:
|
|
@@ -195,15 +196,18 @@ npx styleproof-map
|
|
|
195
196
|
npx styleproof-diff
|
|
196
197
|
```
|
|
197
198
|
|
|
198
|
-
`styleproof-map` captures this branch into `stylemaps/current
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
`styleproof-map` captures this branch into `stylemaps/current` as lean committed
|
|
200
|
+
maps: HAR recordings are removed by default so private API responses do not land
|
|
201
|
+
in git. Keep them only for an explicit record/replay workflow with
|
|
202
|
+
`styleproof-map --keep-har` (or `STYLEPROOF_KEEP_HAR=1`). `styleproof-diff` then
|
|
203
|
+
compares the committed map against the base branch automatically: in GitHub
|
|
204
|
+
Actions it uses the PR base; locally it checks `branch.<name>.gh-merge-base`,
|
|
205
|
+
then the current GitHub PR base via `gh pr view` (handy for stacked PRs), then
|
|
202
206
|
`origin/main`, `origin/master`, `main`, and `master`. Pin it when needed with
|
|
203
207
|
`styleproof-diff main`, `styleproof-diff master`, or `styleproof-diff --base-ref
|
|
204
208
|
origin/my-base`.
|
|
205
209
|
|
|
206
|
-
**That's the whole loop.** Capture happens **pre-push on your machine** (where the app already builds and serves); the
|
|
210
|
+
**That's the whole loop.** Capture happens **pre-push on your machine** (where the app already builds and serves); the hook first diffs the refreshed map against `HEAD` and restores it when the semantic map is unchanged, otherwise it commits and pushes the lean map; and **CI is a browser-less diff of two precomputed maps** — no build, no browser, just a fast comparison (~5400× cheaper on the compare step than re-capturing both sides in CI). `main` always carries a base map, so every PR is _your code vs the committed base_. Require the `StyleProof` status in branch protection and an unverified visual change can't merge.
|
|
207
211
|
|
|
208
212
|
> **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same environment (your machine, or a pinned container). The self-check fails loudly on a non-deterministic capture, so drift never passes silently.
|
|
209
213
|
|
|
@@ -230,6 +234,24 @@ Then copy [`example/styleproof-approve.yml`](https://github.com/BenSheridanEdwar
|
|
|
230
234
|
|
|
231
235
|
**Prefer to capture in CI instead of committing maps?** For a repo with many outside contributors on different machines, StyleProof can capture **both** base and head in CI and diff them there — no committed maps. See **[Forks and Dependabot](#forks-and-dependabot)** for that flow (it's also the fork-safe split). The committed-map flow above is the default because it's far faster and simpler for a single team.
|
|
232
236
|
|
|
237
|
+
**Want to skip work safely?** Skip the **whole** StyleProof workflow only for
|
|
238
|
+
changes that cannot affect rendered output, such as docs-only edits, using your
|
|
239
|
+
CI provider's native path filters. Do not skip individual surfaces from a
|
|
240
|
+
StyleProof run based on a changed-file guess: shared CSS, tokens, resets,
|
|
241
|
+
themes, layout primitives, and runtime styling can repaint any surface, and a
|
|
242
|
+
missed surface would certify green without being measured. If you want faster
|
|
243
|
+
feedback, order the highest-signal surfaces first in your spec, but still let
|
|
244
|
+
the full sweep finish before treating the gate as passed.
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
on:
|
|
248
|
+
pull_request:
|
|
249
|
+
paths-ignore:
|
|
250
|
+
- '**/*.md'
|
|
251
|
+
- 'docs/**'
|
|
252
|
+
- '.github/ISSUE_TEMPLATE/**'
|
|
253
|
+
```
|
|
254
|
+
|
|
233
255
|
## Forks and Dependabot
|
|
234
256
|
|
|
235
257
|
If you **capture in CI** rather than committing maps (the alternative noted at the end of the Quickstart — a better fit when many outside contributors push from different machines), the simplest setup runs the whole gate in one `pull_request` job that captures base + head and diffs them. That job needs a **write** token to push the report branch, post the comment, and set the `StyleProof` status. That's fine for same-repo PRs, but **fork and Dependabot PRs run with a read-only `GITHUB_TOKEN`** (GitHub's security default for untrusted PRs). So the job can't post the status — and a required `StyleProof` check then sits `pending` forever, blocking the PR even though a dependency or fork change usually touches no UI at all.
|
|
@@ -269,7 +291,7 @@ Anything still moving on its own after that is detected as a volatile region and
|
|
|
269
291
|
| Handled for you — zero config | How |
|
|
270
292
|
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
271
293
|
| In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
|
|
272
|
-
| Animations, transitions, focus
|
|
294
|
+
| Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately |
|
|
273
295
|
| Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
|
|
274
296
|
| Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
|
|
275
297
|
| Layout-equivalent horizontal auto margins | ignored only when the captured element rectangle is unchanged |
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -309,6 +309,31 @@ remote="$1"
|
|
|
309
309
|
|
|
310
310
|
${PM.exec(`styleproof-map --spec ${specPath}`)}
|
|
311
311
|
|
|
312
|
+
if git cat-file -e HEAD:stylemaps/current 2>/dev/null; then
|
|
313
|
+
tmp="\${TMPDIR:-/tmp}/styleproof-pre-push-diff.$$"
|
|
314
|
+
set +e
|
|
315
|
+
${PM.exec('styleproof-diff --base-ref HEAD --max=20')} > "$tmp"
|
|
316
|
+
code=$?
|
|
317
|
+
set -e
|
|
318
|
+
|
|
319
|
+
if [ "$code" -eq 2 ]; then
|
|
320
|
+
cat "$tmp" >&2
|
|
321
|
+
rm -f "$tmp"
|
|
322
|
+
exit 2
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
if [ "$code" -eq 0 ]; then
|
|
326
|
+
rm -f "$tmp"
|
|
327
|
+
git restore --source=HEAD -- stylemaps
|
|
328
|
+
git clean -fdq -- stylemaps/current
|
|
329
|
+
exit 0
|
|
330
|
+
fi
|
|
331
|
+
|
|
332
|
+
cat "$tmp" >&2
|
|
333
|
+
rm -f "$tmp"
|
|
334
|
+
echo "StyleProof: style map changed. If this was not an intended visual change, pin live states or replay/fixture the data boundary before pushing." >&2
|
|
335
|
+
fi
|
|
336
|
+
|
|
312
337
|
git add stylemaps
|
|
313
338
|
git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
|
|
314
339
|
|
|
@@ -333,6 +358,8 @@ on: pull_request
|
|
|
333
358
|
|
|
334
359
|
permissions:
|
|
335
360
|
contents: read
|
|
361
|
+
issues: write
|
|
362
|
+
pull-requests: write
|
|
336
363
|
|
|
337
364
|
jobs:
|
|
338
365
|
styleproof:
|
|
@@ -343,8 +370,59 @@ jobs:
|
|
|
343
370
|
fetch-depth: 0 # need the base ref to read its committed map
|
|
344
371
|
${PM.setup}
|
|
345
372
|
- run: ${PM.install}
|
|
346
|
-
-
|
|
347
|
-
|
|
373
|
+
- id: diff
|
|
374
|
+
name: Diff this branch's committed map against the base
|
|
375
|
+
shell: bash
|
|
376
|
+
run: |
|
|
377
|
+
set +e
|
|
378
|
+
${PM.exec(`styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}"`)} | tee styleproof-diff.txt
|
|
379
|
+
code=\${PIPESTATUS[0]}
|
|
380
|
+
set -e
|
|
381
|
+
echo "exit-code=\${code}" >> "$GITHUB_OUTPUT"
|
|
382
|
+
if [ "$code" -eq 0 ]; then
|
|
383
|
+
echo "result=clean" >> "$GITHUB_OUTPUT"
|
|
384
|
+
elif [ "$code" -eq 1 ]; then
|
|
385
|
+
echo "result=changed" >> "$GITHUB_OUTPUT"
|
|
386
|
+
elif [ "$code" -eq 3 ]; then
|
|
387
|
+
echo "result=new" >> "$GITHUB_OUTPUT"
|
|
388
|
+
else
|
|
389
|
+
echo "result=error" >> "$GITHUB_OUTPUT"
|
|
390
|
+
fi
|
|
391
|
+
- name: Comment StyleProof result
|
|
392
|
+
if: >-
|
|
393
|
+
always() &&
|
|
394
|
+
github.event_name == 'pull_request' &&
|
|
395
|
+
steps.diff.outputs.result != '' &&
|
|
396
|
+
steps.diff.outputs.result != 'error'
|
|
397
|
+
uses: actions/github-script@v9
|
|
398
|
+
with:
|
|
399
|
+
script: |
|
|
400
|
+
const issue_number = context.payload.pull_request.number;
|
|
401
|
+
const marker = '<!-- styleproof-report -->';
|
|
402
|
+
const result = '\${{ steps.diff.outputs.result }}';
|
|
403
|
+
const bodies = {
|
|
404
|
+
clean: '✓ No visual changes detected.',
|
|
405
|
+
new: '🆕 New surface(s) detected — no committed baseline to diff against yet, so nothing to approve. They will be certified once their map is committed.',
|
|
406
|
+
changed: 'Visual changes detected. See the StyleProof check logs for the computed-style diff.',
|
|
407
|
+
};
|
|
408
|
+
const body = marker + '\\n## 🗺️ StyleProof report\\n\\n' + (bodies[result] || bodies.changed);
|
|
409
|
+
const { data: comments } = await github.rest.issues.listComments({
|
|
410
|
+
...context.repo,
|
|
411
|
+
issue_number,
|
|
412
|
+
per_page: 100,
|
|
413
|
+
});
|
|
414
|
+
const existing = comments.find((comment) => comment.body && comment.body.includes(marker));
|
|
415
|
+
if (existing) {
|
|
416
|
+
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
|
|
417
|
+
} else {
|
|
418
|
+
await github.rest.issues.createComment({ ...context.repo, issue_number, body });
|
|
419
|
+
}
|
|
420
|
+
- name: Fail on StyleProof diff
|
|
421
|
+
# Exit 1 = reviewable diffs (gate red); 2 = error. Exit 3 (new surface,
|
|
422
|
+
# no baseline) never blocks — it has nothing to approve yet.
|
|
423
|
+
if: steps.diff.outputs.exit-code != '0' && steps.diff.outputs.exit-code != '3'
|
|
424
|
+
shell: bash
|
|
425
|
+
run: exit \${{ steps.diff.outputs.exit-code }}
|
|
348
426
|
`;
|
|
349
427
|
|
|
350
428
|
function writeFileSafe(file, contents, { force: f } = {}) {
|
package/bin/styleproof-map.mjs
CHANGED
|
@@ -30,12 +30,13 @@ options:
|
|
|
30
30
|
--base-dir <path> output root directory (default: stylemaps)
|
|
31
31
|
--screenshots keep screenshots for reports (default: off for committed maps)
|
|
32
32
|
--no-screenshots write lean .json.gz maps only (default)
|
|
33
|
+
--keep-har keep recorded HAR files for advanced replay workflows
|
|
33
34
|
-h, --help show this help
|
|
34
35
|
|
|
35
36
|
Examples:
|
|
36
37
|
styleproof-map
|
|
37
38
|
styleproof-map --spec e2e/styleproof.spec.ts
|
|
38
|
-
styleproof-map --dir review --base-dir __stylemaps__ --screenshots
|
|
39
|
+
styleproof-map --dir review --base-dir __stylemaps__ --screenshots --keep-har
|
|
39
40
|
`;
|
|
40
41
|
|
|
41
42
|
const argv = process.argv.slice(2);
|
|
@@ -43,6 +44,7 @@ let spec = 'e2e/styleproof.spec.ts';
|
|
|
43
44
|
let dir = process.env.STYLEMAP_DIR ?? 'current';
|
|
44
45
|
let baseDir = process.env.STYLEPROOF_BASEDIR ?? 'stylemaps';
|
|
45
46
|
let screenshots = process.env.STYLEPROOF_SCREENSHOTS ?? '0';
|
|
47
|
+
let keepHar = process.env.STYLEPROOF_KEEP_HAR === '1';
|
|
46
48
|
const playwrightArgs = [];
|
|
47
49
|
|
|
48
50
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -59,6 +61,7 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
59
61
|
else if (a.startsWith('--base-dir=')) baseDir = a.slice(11);
|
|
60
62
|
else if (a === '--screenshots') screenshots = '1';
|
|
61
63
|
else if (a === '--no-screenshots') screenshots = '0';
|
|
64
|
+
else if (a === '--keep-har') keepHar = true;
|
|
62
65
|
else if (a.startsWith('--')) {
|
|
63
66
|
console.error(unknownFlagMessage('styleproof-map', a));
|
|
64
67
|
process.exit(2);
|
|
@@ -84,6 +87,15 @@ if (!fs.existsSync(spec)) {
|
|
|
84
87
|
process.exit(2);
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
function removeHarFiles(root) {
|
|
91
|
+
if (!fs.existsSync(root)) return;
|
|
92
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
93
|
+
const full = `${root}/${entry.name}`;
|
|
94
|
+
if (entry.isDirectory()) removeHarFiles(full);
|
|
95
|
+
else if (entry.isFile() && entry.name.endsWith('.har')) fs.rmSync(full, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
|
|
88
100
|
const env = {
|
|
89
101
|
...process.env,
|
|
@@ -99,4 +111,6 @@ if (result.error) {
|
|
|
99
111
|
console.error(playwrightMissingMessage(result.error.message));
|
|
100
112
|
process.exit(2);
|
|
101
113
|
}
|
|
102
|
-
|
|
114
|
+
const status = result.status ?? 1;
|
|
115
|
+
if (status === 0 && !keepHar) removeHarFiles(`${baseDir}/${dir}`);
|
|
116
|
+
process.exit(status);
|
package/dist/capture.d.ts
CHANGED
|
@@ -76,10 +76,11 @@ export type StyleMap = {
|
|
|
76
76
|
elements: Record<string, ElementEntry>;
|
|
77
77
|
states: Record<string, Record<string, Record<string, Props>>>;
|
|
78
78
|
/**
|
|
79
|
-
* True when the forced :hover/:focus/:active layer was
|
|
80
|
-
* capture
|
|
81
|
-
*
|
|
82
|
-
*
|
|
79
|
+
* True when the forced :hover/:focus/:active layer was not fully captured for
|
|
80
|
+
* this capture — either CDP/page interactive-element count skew, or because the
|
|
81
|
+
* interactive-element count exceeded `maxInteractive` and capture was truncated.
|
|
82
|
+
* Persisted so a diff against a fully-captured side flags that the state layer
|
|
83
|
+
* wasn't certified, instead of silently reading as "identical".
|
|
83
84
|
*/
|
|
84
85
|
statesSkipped?: boolean;
|
|
85
86
|
/**
|
package/dist/capture.js
CHANGED
|
@@ -24,7 +24,26 @@ const FRAMEWORK_IGNORE = [
|
|
|
24
24
|
'template',
|
|
25
25
|
'next-route-announcer',
|
|
26
26
|
'[id="__next-route-announcer__"]',
|
|
27
|
+
'[data-styleproof-hover-sink]',
|
|
27
28
|
];
|
|
29
|
+
function installHoverSink() {
|
|
30
|
+
let sink = document.querySelector('[data-styleproof-hover-sink]');
|
|
31
|
+
if (!sink) {
|
|
32
|
+
sink = document.createElement('div');
|
|
33
|
+
sink.setAttribute('data-styleproof-hover-sink', '');
|
|
34
|
+
document.body.appendChild(sink);
|
|
35
|
+
}
|
|
36
|
+
sink.setAttribute('style', [
|
|
37
|
+
'position:fixed',
|
|
38
|
+
'left:0',
|
|
39
|
+
'top:0',
|
|
40
|
+
'width:1px',
|
|
41
|
+
'height:1px',
|
|
42
|
+
'z-index:2147483647',
|
|
43
|
+
'pointer-events:auto',
|
|
44
|
+
'opacity:0',
|
|
45
|
+
].join(';'));
|
|
46
|
+
}
|
|
28
47
|
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
29
48
|
* the capture (excluding live regions) and the diff (skipping them). */
|
|
30
49
|
export function isUnder(path, roots) {
|
|
@@ -356,7 +375,8 @@ async function captureForcedStates(page, ignore, maxInteractive, skipPaths = [])
|
|
|
356
375
|
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
357
376
|
const marked = (await page.evaluate(markInteractiveElements, { selector: INTERACTIVE, skipSel, attr: STATE_ID_ATTR })).filter((m) => !(skipPaths.length && isUnder(m.path, skipPaths)));
|
|
358
377
|
const limit = Math.min(marked.length, maxInteractive);
|
|
359
|
-
|
|
378
|
+
const truncated = marked.length > maxInteractive;
|
|
379
|
+
if (truncated) {
|
|
360
380
|
// eslint-disable-next-line no-console
|
|
361
381
|
console.warn(`styleproof: ${marked.length} interactive elements exceeds maxInteractive=${maxInteractive}; ` +
|
|
362
382
|
`forced-state capture truncated to the first ${maxInteractive}. Raise maxInteractive to cover them all.`);
|
|
@@ -386,7 +406,10 @@ async function captureForcedStates(page, ignore, maxInteractive, skipPaths = [])
|
|
|
386
406
|
await page.evaluate(clearInteractiveMarks, STATE_ID_ATTR).catch(() => undefined);
|
|
387
407
|
await client.detach();
|
|
388
408
|
}
|
|
389
|
-
|
|
409
|
+
// When truncated, the elements past the limit have no forced-state delta —
|
|
410
|
+
// flag the layer so the diff reports it as uncertified instead of letting the
|
|
411
|
+
// missing states read as "identical" against a fully-captured side.
|
|
412
|
+
return { states, skipped: truncated };
|
|
390
413
|
}
|
|
391
414
|
/** Element paths that differ between two captures (added, removed, or restyled). */
|
|
392
415
|
function changedElementPaths(a, b) {
|
|
@@ -582,15 +605,17 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
582
605
|
const stabilize = options.stabilize ?? true;
|
|
583
606
|
const captureText = options.captureText ?? false;
|
|
584
607
|
const captureComponent = options.captureComponent ?? false;
|
|
585
|
-
// Neutralise real focus the same way FREEZE_CSS neutralises motion:
|
|
586
|
-
//
|
|
587
|
-
// resting state. Real :focus is
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
608
|
+
// Neutralise real hover/focus the same way FREEZE_CSS neutralises motion: park
|
|
609
|
+
// the pointer over an ignored 1px sink and blur whatever element holds focus so
|
|
610
|
+
// every read below is the no-interaction resting state. Real :hover/:focus is
|
|
611
|
+
// nondeterministic across runs (the last Playwright action, autofocus, late
|
|
612
|
+
// hydration, a stray prior action) and contaminates BOTH layers — it bakes an
|
|
613
|
+
// interaction style into the resting map, AND it cancels that forced-state
|
|
614
|
+
// delta (forcing :focus on an already-focused element changes nothing). States
|
|
615
|
+
// are certified deterministically via CDP forcePseudoState below, never via
|
|
616
|
+
// whatever happened to be hovered or focused.
|
|
617
|
+
await page.evaluate(installHoverSink);
|
|
618
|
+
await page.mouse.move(0, 0);
|
|
594
619
|
await page.evaluate(() => document.activeElement?.blur?.());
|
|
595
620
|
// Inject the structural-path helper once so both capturePage and
|
|
596
621
|
// markInteractiveElements (each serialized into the page by page.evaluate, which
|
|
@@ -609,7 +634,9 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
609
634
|
// Detect semantic live-state candidates automatically, but don't exclude them
|
|
610
635
|
// merely for being live regions. Stable status/alert/log UI is product UI and
|
|
611
636
|
// should still be captured; this metadata only improves reports and diagnostics.
|
|
612
|
-
|
|
637
|
+
// Honour the caller's `ignore` too: a region the user excluded shouldn't be
|
|
638
|
+
// surfaced (or persisted) as a live candidate, matching every other capture pass.
|
|
639
|
+
const liveCandidates = await page.evaluate(detectLiveCandidates, { ignore });
|
|
613
640
|
// Motion longhands (transition/animation) are read separately so declared
|
|
614
641
|
// motion is verified even though every other value is a frozen end state.
|
|
615
642
|
// Read them on the SETTLED DOM, not before it: capturing pre-settle missed any
|
package/dist/diff.js
CHANGED
|
@@ -101,8 +101,9 @@ export function diffStyleMaps(a, b) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
// If the forced-state layer was skipped on exactly one side (CDP skew during
|
|
104
|
-
//
|
|
105
|
-
// loudly rather than letting {} vs {} read as
|
|
104
|
+
// capture, or truncation past maxInteractive), the :hover/:focus/:active layer
|
|
105
|
+
// was not fully compared — flag it loudly rather than letting {} vs {} read as
|
|
106
|
+
// "identical".
|
|
106
107
|
if (!!a.statesSkipped !== !!b.statesSkipped) {
|
|
107
108
|
findings.push({
|
|
108
109
|
kind: 'state',
|
|
@@ -113,8 +114,8 @@ export function diffStyleMaps(a, b) {
|
|
|
113
114
|
props: [
|
|
114
115
|
{
|
|
115
116
|
prop: 'forced :hover/:focus/:active layer',
|
|
116
|
-
before: a.statesSkipped ? '
|
|
117
|
-
after: b.statesSkipped ? '
|
|
117
|
+
before: a.statesSkipped ? 'not fully captured' : 'captured',
|
|
118
|
+
after: b.statesSkipped ? 'not fully captured' : 'captured',
|
|
118
119
|
},
|
|
119
120
|
],
|
|
120
121
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
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",
|