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/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
- const ledger = { version: 1, expected: ledgerExpected, exclude, determinism };
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 npm `@playwright/test`
592
- * version (in the manifest) is only a proxy — the actual Chromium binary can change while
593
- * it holds constant (a re-download, a different browser store, a CI image bump). The
594
- * compatibility guard reads this back to refuse a cross-build compare instead of walling
595
- * a PR with false diffs. Best-effort: an unavailable version leaves the guard as-is. */
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
- writeBrowserBuildSidecar(path.join(settings.baseDir, dir), version);
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.20.0",
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
  }