styleproof 3.20.0 → 4.0.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 +192 -0
- package/README.md +437 -321
- package/bin/styleproof-capture.mjs +4 -0
- package/bin/styleproof-diff.mjs +192 -27
- package/bin/styleproof-init.mjs +75 -7
- package/bin/styleproof-map.mjs +23 -9
- package/bin/styleproof-report.mjs +6 -0
- package/dist/capture-url.js +5 -0
- package/dist/capture.d.ts +59 -0
- package/dist/capture.js +127 -5
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/coverage.d.ts +7 -0
- package/dist/data-residue.d.ts +70 -0
- package/dist/data-residue.js +105 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/map-store.d.ts +37 -1
- package/dist/map-store.js +103 -10
- package/dist/report.d.ts +1 -4
- package/dist/report.js +100 -353
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +63 -9
- package/example/styleproof-approve.yml +108 -0
- package/package.json +7 -4
package/dist/runner.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { test, expect } from '@playwright/test';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
4
|
+
import { captureStyleMap, saveStyleMap, trackInflightRequests, trackDataResidue, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
6
|
import { coverageGaps, coverageKeys, translateExpected, COVERAGE_LEDGER, } from './coverage.js';
|
|
7
|
-
import { writeBrowserBuildSidecar } from './map-store.js';
|
|
7
|
+
import { writeBrowserBuildSidecar, writeCaptureManifest } from './map-store.js';
|
|
8
8
|
import { detectViewportWidths } from './breakpoints.js';
|
|
9
9
|
import { selectCrawlLinks, crawlCoverageError } from './crawl.js';
|
|
10
10
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
@@ -476,6 +476,10 @@ async function captureSurface(page, surface, width, s) {
|
|
|
476
476
|
// count toward the network-aware settle — a request that starts during navigation
|
|
477
477
|
// fired its event before captureStyleMap could attach a listener of its own.
|
|
478
478
|
const requests = trackInflightRequests(page);
|
|
479
|
+
// Same timing for the residue watcher: a data request that fails during navigation
|
|
480
|
+
// must be seen. Keyed on the BASE surface key so a liveStates split (`-loading`/
|
|
481
|
+
// `-loaded`) and every width dedupe to one `<surface>·<endpoint>` residue entry.
|
|
482
|
+
const residue = trackDataResidue(page, s.replayUrl, surface.metadata?.surfaceKey ?? surface.key);
|
|
479
483
|
try {
|
|
480
484
|
await surface.go(page);
|
|
481
485
|
const map = await captureStyleMap(page, {
|
|
@@ -488,6 +492,9 @@ async function captureSurface(page, surface, width, s) {
|
|
|
488
492
|
});
|
|
489
493
|
if (s.selfCheck)
|
|
490
494
|
await assertDeterministic(page, surface, map, s.captureText, requests.pending);
|
|
495
|
+
// Attach data-residue AFTER the self-check re-run so both runs' failures are folded
|
|
496
|
+
// (deduped in the watcher). Warn always; the recorded residue is what the gate reads.
|
|
497
|
+
attachDataResidue(map, residue.residue());
|
|
491
498
|
const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
|
|
492
499
|
saveStyleMap(`${stem}.json.gz`, map);
|
|
493
500
|
if (s.screenshots) {
|
|
@@ -499,6 +506,21 @@ async function captureSurface(page, surface, width, s) {
|
|
|
499
506
|
}
|
|
500
507
|
finally {
|
|
501
508
|
requests.dispose();
|
|
509
|
+
residue.dispose();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** Attach any observed data-residue to the map and NAME each failure on stderr — what
|
|
513
|
+
* failed, what it means (fallback branch captured), what to do. One warning per
|
|
514
|
+
* (surface, endpoint); the watcher already deduped across widths / the self-check. */
|
|
515
|
+
function attachDataResidue(map, residue) {
|
|
516
|
+
if (!residue.length)
|
|
517
|
+
return;
|
|
518
|
+
map.dataResidue = residue;
|
|
519
|
+
for (const r of residue) {
|
|
520
|
+
// eslint-disable-next-line no-console
|
|
521
|
+
console.warn(`styleproof: surface '${r.surface}' — data request ${r.endpoint} FAILED during capture (${r.reason}). ` +
|
|
522
|
+
`The captured state renders this endpoint's fallback branch; states driven by its real responses are ` +
|
|
523
|
+
`uncaptured and unproven. Fixture it (page.route / liveStates) or acknowledge it in styleproof.data-residue.json.`);
|
|
502
524
|
}
|
|
503
525
|
}
|
|
504
526
|
/**
|
|
@@ -527,6 +549,14 @@ export function resolveBaseDir(baseDir, env = process.env.STYLEPROOF_BASEDIR) {
|
|
|
527
549
|
export function resolveScreenshots(screenshots, env = process.env.STYLEPROOF_SCREENSHOTS) {
|
|
528
550
|
return screenshots ?? env !== '0';
|
|
529
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* The data-residue guard mode: `'gate'` (the v4 default) blocks the diff on an
|
|
554
|
+
* unacknowledged failing data endpoint; `'warn'` is the explicit opt-out that records +
|
|
555
|
+
* warns without gating. Single source of truth for the default, so the flip lives here.
|
|
556
|
+
*/
|
|
557
|
+
export function resolveDataResidue(mode) {
|
|
558
|
+
return mode ?? 'gate';
|
|
559
|
+
}
|
|
530
560
|
/**
|
|
531
561
|
* Apply the capture defaults once, so explicit-surface and crawl capture can't
|
|
532
562
|
* drift — the replay boundary, frozen clock and self-check policy resolve to the
|
|
@@ -541,6 +571,7 @@ function resolveSettings(c) {
|
|
|
541
571
|
screenshots: resolveScreenshots(c.screenshots),
|
|
542
572
|
replayFrom,
|
|
543
573
|
replayUrl: c.replayUrl ?? process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**',
|
|
574
|
+
dataResidue: resolveDataResidue(c.dataResidue),
|
|
544
575
|
freezeClock: c.freezeClock ?? true,
|
|
545
576
|
clockTime: c.clockTime ?? '2025-01-01T00:00:00Z',
|
|
546
577
|
selfCheck: c.selfCheck ?? defaultSelfCheck(replayFrom),
|
|
@@ -584,19 +615,42 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfac
|
|
|
584
615
|
// the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
|
|
585
616
|
// compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
|
|
586
617
|
const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
|
|
587
|
-
|
|
618
|
+
// Carry the residue-gate mode in the bundle so styleproof-diff knows whether an
|
|
619
|
+
// unacknowledged failing endpoint should BLOCK ('gate', the v4 default) or only
|
|
620
|
+
// inform ('warn', the explicit opt-out). Recorded verbatim so the bundle is
|
|
621
|
+
// self-documenting; an absent field (older bundles) is still read as warn, so
|
|
622
|
+
// maps captured before this default flip never start gating retroactively.
|
|
623
|
+
const ledger = {
|
|
624
|
+
version: 1,
|
|
625
|
+
expected: ledgerExpected,
|
|
626
|
+
exclude,
|
|
627
|
+
determinism,
|
|
628
|
+
dataResidue: settings.dataResidue,
|
|
629
|
+
};
|
|
588
630
|
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
589
631
|
});
|
|
590
632
|
}
|
|
591
|
-
/** Record the real browser build into the capture bundle. The
|
|
592
|
-
* version (in the manifest) is only a proxy — the actual Chromium
|
|
593
|
-
* it holds constant (a re-download, a different browser store, a
|
|
594
|
-
* compatibility guard reads this back to refuse a cross-build compare
|
|
595
|
-
* a PR with false diffs. Best-effort: an unavailable version leaves
|
|
633
|
+
/** Record the real browser build into the capture bundle, then stamp the manifest. The
|
|
634
|
+
* npm `@playwright/test` version (in the manifest) is only a proxy — the actual Chromium
|
|
635
|
+
* binary can change while it holds constant (a re-download, a different browser store, a
|
|
636
|
+
* CI image bump). The compatibility guard reads this back to refuse a cross-build compare
|
|
637
|
+
* instead of walling a PR with false diffs. Best-effort: an unavailable version leaves
|
|
638
|
+
* the guard as-is.
|
|
639
|
+
*
|
|
640
|
+
* The manifest is stamped HERE, at the runner level, so every capture flow produces a
|
|
641
|
+
* manifest-bearing dir — including a raw `STYLEMAP_DIR=x npx playwright test` run (the
|
|
642
|
+
* fork capture workflow's shape), which never goes through the styleproof-map CLI. Since
|
|
643
|
+
* v4 the diff refuses a map-bearing dir without one. It's written in the same test as
|
|
644
|
+
* the sidecar (not a sibling test) because the manifest reads the sidecar back for
|
|
645
|
+
* `browserVersion`, and fullyParallel gives sibling tests no ordering. styleproof-map
|
|
646
|
+
* re-stamps a richer manifest (real spec hash, git identity) after the run; the runtime
|
|
647
|
+
* fields the compare guard reads are the same either way. */
|
|
596
648
|
function writeBrowserBuildTest(settings, dir) {
|
|
597
649
|
test('styleproof browser build', ({ page }) => {
|
|
598
650
|
const version = page.context().browser()?.version();
|
|
599
|
-
|
|
651
|
+
const outDir = path.join(settings.baseDir, dir);
|
|
652
|
+
writeBrowserBuildSidecar(outDir, version);
|
|
653
|
+
writeCaptureManifest({ dir: outDir, screenshots: settings.screenshots });
|
|
600
654
|
});
|
|
601
655
|
}
|
|
602
656
|
export function defineStyleMapCapture(options) {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# StyleProof approval gate — copy to .github/workflows/ on your DEFAULT branch.
|
|
2
|
+
#
|
|
3
|
+
# issue_comment workflows only ever run from the default branch, so this must
|
|
4
|
+
# live on main (not on the feature branch) to take effect. It flips the
|
|
5
|
+
# `StyleProof` commit status when a write-access reviewer ticks the single
|
|
6
|
+
# "Approve all changes" box in the StyleProof report comment — one tick signs
|
|
7
|
+
# off every changed or new surface. Pair it with the Action's `require-approval: true` input and
|
|
8
|
+
# either a branch-protection rule requiring the `StyleProof` status, or
|
|
9
|
+
# `styleproof.config.json`'s `"blocking": true` to fail the job on unapproved
|
|
10
|
+
# changes (the blocking option for repos without branch protection).
|
|
11
|
+
name: StyleProof approve
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
issue_comment:
|
|
15
|
+
types: [edited] # ticking a checkbox edits the comment
|
|
16
|
+
|
|
17
|
+
# statuses:write to flip the gate; pull-requests:read to resolve the PR head SHA;
|
|
18
|
+
# issues:write only for the "needs write access" reply on an unauthorized tick.
|
|
19
|
+
permissions:
|
|
20
|
+
statuses: write
|
|
21
|
+
pull-requests: read
|
|
22
|
+
issues: write
|
|
23
|
+
|
|
24
|
+
jobs:
|
|
25
|
+
approve:
|
|
26
|
+
# A HUMAN editing the BOT's own StyleProof report comment on a PR. This is the
|
|
27
|
+
# trust gate, not the marker: it excludes the bot's own upsert-edits
|
|
28
|
+
# (sender is a Bot) and any attacker-authored comment (comment.user is a User),
|
|
29
|
+
# so the only edits that reach the logic are humans ticking boxes in our comment.
|
|
30
|
+
if: >-
|
|
31
|
+
github.event.issue.pull_request &&
|
|
32
|
+
github.event.comment.user.type == 'Bot' &&
|
|
33
|
+
github.event.sender.type == 'User' &&
|
|
34
|
+
contains(github.event.comment.body, '<!-- styleproof-report -->')
|
|
35
|
+
# ubuntu-latest is fine where you have GitHub-hosted Actions minutes. On a
|
|
36
|
+
# PRIVATE repo without them, this job silently fails to start — point it at
|
|
37
|
+
# the same self-hosted runner your CI uses, e.g. runs-on: [self-hosted, …].
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/github-script@v7
|
|
41
|
+
with:
|
|
42
|
+
script: |
|
|
43
|
+
const STATUS = 'StyleProof'; // must match the Action's status-context input
|
|
44
|
+
|
|
45
|
+
// Re-fetch the comment so concurrent ticks all compute from the LATEST
|
|
46
|
+
// body and converge (the event payload is the body as of one edit). The
|
|
47
|
+
// body is untrusted — only ever pattern-matched here, never evaluated.
|
|
48
|
+
const fresh = await github.rest.issues.getComment({ ...context.repo, comment_id: context.payload.comment.id });
|
|
49
|
+
const body = fresh.data.body || '';
|
|
50
|
+
|
|
51
|
+
// Bind approval to the exact commit the report was generated for. A
|
|
52
|
+
// push after the report leaves this stale, so a new render can never
|
|
53
|
+
// inherit a green status.
|
|
54
|
+
const shaMatch = body.match(/<!-- styleproof-sha:([0-9a-f]{40}) -->/i);
|
|
55
|
+
if (!shaMatch) return;
|
|
56
|
+
const reportSha = shaMatch[1];
|
|
57
|
+
|
|
58
|
+
// One "Approve all changes" box signs off every changed or new surface.
|
|
59
|
+
// No box means nothing to approve, leave the status as the Action set it.
|
|
60
|
+
const allBox = body.split('\n').find((l) => /^\s*-\s+\[[ xX]\]\s+\*\*Approve all changes\*\*/.test(l));
|
|
61
|
+
if (!allBox) return;
|
|
62
|
+
const approved = /\[[xX]\]/.test(allBox);
|
|
63
|
+
|
|
64
|
+
// The human who toggled the box must have write access. (`sender` on an
|
|
65
|
+
// edited event is the editor; combined with the bot-authored + non-bot
|
|
66
|
+
// guards in `if:`, that's the person who ticked.)
|
|
67
|
+
const actor = context.payload.sender.login;
|
|
68
|
+
let perm = 'none';
|
|
69
|
+
try {
|
|
70
|
+
perm = (await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, username: actor })).data.permission;
|
|
71
|
+
} catch {
|
|
72
|
+
/* not a collaborator → stays 'none', fails closed */
|
|
73
|
+
}
|
|
74
|
+
if (!['admin', 'maintain', 'write'].includes(perm)) {
|
|
75
|
+
await github.rest.issues.createComment({
|
|
76
|
+
...context.repo,
|
|
77
|
+
issue_number: context.payload.issue.number,
|
|
78
|
+
body: `@${actor} — approving the StyleProof gate needs write access to this repo, so the check stays red.`,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Only ever stamp the exact reviewed commit. If the PR moved since the
|
|
84
|
+
// report was posted, do nothing — the Action re-posts (red) for the new SHA.
|
|
85
|
+
const pr = await github.rest.pulls.get({ ...context.repo, pull_number: context.payload.issue.number });
|
|
86
|
+
const headSha = pr.data.head.sha;
|
|
87
|
+
if (headSha !== reportSha) return;
|
|
88
|
+
|
|
89
|
+
// The status description is the source of truth for WHO approved, so a
|
|
90
|
+
// later report re-run (e.g. to clear a blocking check) can reconstruct
|
|
91
|
+
// "approved by @x" without this workflow having run again.
|
|
92
|
+
await github.rest.repos.createCommitStatus({
|
|
93
|
+
...context.repo,
|
|
94
|
+
sha: headSha,
|
|
95
|
+
context: STATUS,
|
|
96
|
+
state: approved ? 'success' : 'failure',
|
|
97
|
+
description: approved ? `Approved by @${actor}` : 'Tick "Approve all changes" to sign off',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Reflect the approver in the comment itself for instant feedback. Rebuild
|
|
101
|
+
// the box line from scratch (dropping any prior "approved by" suffix) so a
|
|
102
|
+
// re-tick by someone else updates it and an untick clears it. Editing our
|
|
103
|
+
// own comment via GITHUB_TOKEN never retriggers this workflow.
|
|
104
|
+
const boxRe = /^(\s*-\s+\[[ xX]\]\s+\*\*Approve all changes\*\*).*$/m;
|
|
105
|
+
const newBody = body.replace(boxRe, (_, head) => (approved ? `${head} — _approved by @${actor}_` : head));
|
|
106
|
+
if (newBody !== body) {
|
|
107
|
+
await github.rest.issues.updateComment({ ...context.repo, comment_id: context.payload.comment.id, body: newBody });
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.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",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"files": [
|
|
47
47
|
"dist",
|
|
48
48
|
"bin",
|
|
49
|
+
"example/styleproof-approve.yml",
|
|
49
50
|
"docs/demo-composite.png",
|
|
50
51
|
"README.md",
|
|
51
52
|
"CHANGELOG.md",
|
|
@@ -80,16 +81,18 @@
|
|
|
80
81
|
"pngjs": "^7.0.0"
|
|
81
82
|
},
|
|
82
83
|
"devDependencies": {
|
|
84
|
+
"@commitlint/cli": "^21.2.0",
|
|
85
|
+
"@commitlint/config-conventional": "^21.2.0",
|
|
83
86
|
"@eslint/js": "^10.0.1",
|
|
84
87
|
"@playwright/test": "^1.60.0",
|
|
85
88
|
"@types/node": "^26.0.0",
|
|
86
89
|
"@types/pngjs": "^6.0.5",
|
|
87
90
|
"eslint": "^10.5.0",
|
|
91
|
+
"fallow": "^3.2.0",
|
|
88
92
|
"globals": "^17.6.0",
|
|
93
|
+
"husky": "^9.1.7",
|
|
89
94
|
"prettier": "^3.4.2",
|
|
90
95
|
"typescript": "^6.0.3",
|
|
91
|
-
"typescript-eslint": "^8.18.0"
|
|
92
|
-
"husky": "^9.1.7",
|
|
93
|
-
"fallow": "^3.2.0"
|
|
96
|
+
"typescript-eslint": "^8.18.0"
|
|
94
97
|
}
|
|
95
98
|
}
|