styleproof 3.1.1 → 3.1.3

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.
@@ -13,6 +13,8 @@
13
13
  * surface plus a commented guard block to wire to your own route registry.
14
14
  * - playwright.config.ts: only if one does not already exist, so an existing
15
15
  * Playwright project is never disturbed.
16
+ * - .github/workflows/styleproof.yml: restores reusable maps from the
17
+ * styleproof-maps branch and only captures in CI when the maps are missing.
16
18
  *
17
19
  * Idempotent: re-running never overwrites an existing spec (use --force) and
18
20
  * never touches an existing playwright.config.ts. Exit 0 = done (or nothing to
@@ -20,7 +22,6 @@
20
22
  */
21
23
  import fs from 'node:fs';
22
24
  import path from 'node:path';
23
- import { spawnSync } from 'node:child_process';
24
25
  import { discoverNextRoutes } from '../dist/index.js';
25
26
  import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
26
27
 
@@ -41,8 +42,9 @@ What it writes:
41
42
  surfaces and the \`expected\` coverage guard to them, so a new page can't ship
42
43
  uncaptured. Otherwise it writes one sample surface + a commented guard block.
43
44
  - playwright.config.ts, only if absent (an existing one is left untouched)
45
+ - .github/workflows/styleproof.yml, a cache-first PR report workflow
44
46
 
45
- After running, capture a baseline against a PRODUCTION build:
47
+ After running, build and upload this commit's map outside CI when possible:
46
48
  npx styleproof-map
47
49
 
48
50
  To certify a refactor:
@@ -107,8 +109,8 @@ const HEADER = `/**
107
109
  * detects your @media breakpoints from the loaded CSS and sweeps one viewport per
108
110
  * band — no config. Capture against a PRODUCTION build — dev servers inject styles.
109
111
  *
110
- * npx styleproof-map # capture this branch into stylemaps/current
111
- * npx styleproof-diff # compare against the inferred base branch
112
+ * npx styleproof-map # capture this commit into the local cache and map store
113
+ * npx styleproof-diff # compare cached base/head maps by commit SHA
112
114
  */`;
113
115
 
114
116
  // Next.js detected: derive both surfaces and the coverage guard from the app's
@@ -285,81 +287,23 @@ export default defineConfig({
285
287
  });
286
288
  `;
287
289
 
288
- // The out-of-the-box gate: capture happens PRE-PUSH on your machine (where the app
289
- // already builds + serves) and the lean computed-style map is committed, so main
290
- // always carries a base map and CI is a fast, browser-less diff of two precomputed
291
- // maps. The hook redirects capture into a committed `stylemaps/` dir and drops
292
- // screenshots via the STYLEPROOF_BASEDIR / STYLEPROOF_SCREENSHOTS env knobs.
293
- const HOOK_DIR = '.githooks';
294
- const HOOK_PATH = `${HOOK_DIR}/pre-push`;
295
- const PREPUSH_HOOK = `#!/bin/sh
296
- # StyleProof pre-push hook (generated by styleproof-init).
297
- #
298
- # Captures this branch's computed-style map, commits it, and pushes it WITH your
299
- # push — one \`git push\`, never two. So main always carries a base map and CI just
300
- # diffs two precomputed maps (no browser). Maps are lean .json.gz under stylemaps/.
301
- # Activate once per clone: git config core.hooksPath .githooks
302
- set -e
303
-
304
- remote="$1"
305
-
306
- # Re-entry guard: the map commit is pushed by this hook (below), which fires the
307
- # hook a second time — skip the expensive capture on that pass.
308
- [ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
309
-
310
- ${PM.exec(`styleproof-map --spec ${specPath}`)}
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
-
337
- git add stylemaps
338
- git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
339
-
340
- # Map changed: commit it and push the updated branch ourselves so you never push
341
- # twice (git pushes the pre-hook commit, so a new commit here wouldn't otherwise go).
342
- # The re-entry guard stops the inner push from recapturing; then abort this original,
343
- # now-stale push so nothing is sent twice.
344
- git commit -m "chore(styleproof): update computed-style map"
345
- echo "StyleProof: style map changed — committed it and pushing with your branch…" >&2
346
- STYLEPROOF_SKIP_CAPTURE=1 git push "$remote" HEAD
347
- echo "StyleProof: ✓ pushed your branch with the updated map — nothing more to do." >&2
348
- echo "StyleProof: (git's 'failed to push some refs' below is expected; the push above already succeeded.)" >&2
349
- exit 1
350
- `;
351
-
352
290
  const CI_PATH = '.github/workflows/styleproof.yml';
353
291
  const CI_WORKFLOW = `name: StyleProof
354
292
 
355
- # Maps are captured + committed pre-push (see .githooks/pre-push), so CI runs no
356
- # browser it just diffs this branch's committed map against the base branch's.
293
+ # Cache-first v3 flow:
294
+ # - run \`styleproof-map\` locally after committing to build/upload this commit's map
295
+ # outside CI when possible;
296
+ # - CI restores base/head maps from the styleproof-maps branch and generates the
297
+ # report without a browser;
298
+ # - on cache miss, CI recaptures both sides in one pinned environment so the
299
+ # comparison stays valid.
357
300
  on: pull_request
358
301
 
359
302
  permissions:
360
- contents: read
303
+ contents: write
361
304
  issues: write
362
305
  pull-requests: write
306
+ statuses: write
363
307
 
364
308
  jobs:
365
309
  styleproof:
@@ -367,55 +311,55 @@ jobs:
367
311
  steps:
368
312
  - uses: actions/checkout@v4
369
313
  with:
370
- fetch-depth: 0 # need the base ref to read its committed map
314
+ fetch-depth: 0 # need base/head commits for cache fallback capture
371
315
  ${PM.setup}
372
316
  - run: ${PM.install}
373
- - id: diff
374
- name: Diff this branch's committed map against the base
317
+ - id: maps
318
+ name: Restore cached StyleProof maps
375
319
  shell: bash
376
320
  run: |
321
+ BASE_SHA="\${{ github.event.pull_request.base.sha }}"
322
+ HEAD_SHA="\${{ github.event.pull_request.head.sha }}"
323
+ rm -rf __stylemaps__
377
324
  set +e
378
- ${PM.exec(`styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}"`)} | tee styleproof-diff.txt
379
- code=\${PIPESTATUS[0]}
325
+ ${PM.exec(`styleproof-map --restore --sha "$BASE_SHA" --dir base --base-dir __stylemaps__ --spec ${specPath}`)}
326
+ base_code=$?
327
+ ${PM.exec(`styleproof-map --restore --sha "$HEAD_SHA" --dir head --base-dir __stylemaps__ --spec ${specPath}`)}
328
+ head_code=$?
380
329
  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 ] || [ "$code" -eq 3 ]; then
385
- echo "result=changed" >> "$GITHUB_OUTPUT"
330
+ if [ "$base_code" -eq 0 ] && [ "$head_code" -eq 0 ]; then
331
+ echo "capture-needed=false" >> "$GITHUB_OUTPUT"
386
332
  else
387
- echo "result=error" >> "$GITHUB_OUTPUT"
333
+ echo "capture-needed=true" >> "$GITHUB_OUTPUT"
388
334
  fi
389
- - name: Comment StyleProof result
390
- if: >-
391
- always() &&
392
- github.event_name == 'pull_request' &&
393
- steps.diff.outputs.result != '' &&
394
- steps.diff.outputs.result != 'error'
395
- uses: actions/github-script@v9
396
- with:
397
- script: |
398
- const issue_number = context.payload.pull_request.number;
399
- const marker = '<!-- styleproof-report -->';
400
- const clean = '\${{ steps.diff.outputs.result }}' === 'clean';
401
- const body = clean
402
- ? marker + '\\n## 🗺️ StyleProof report\\n\\n✓ No visual changes detected.'
403
- : marker + '\\n## 🗺️ StyleProof report\\n\\nVisual changes detected. See the StyleProof check logs for the computed-style diff.';
404
- const { data: comments } = await github.rest.issues.listComments({
405
- ...context.repo,
406
- issue_number,
407
- per_page: 100,
408
- });
409
- const existing = comments.find((comment) => comment.body && comment.body.includes(marker));
410
- if (existing) {
411
- await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
412
- } else {
413
- await github.rest.issues.createComment({ ...context.repo, issue_number, body });
414
- }
415
- - name: Fail on StyleProof diff
416
- if: steps.diff.outputs.exit-code != '0'
335
+ - name: Capture maps in CI on cache miss
336
+ if: steps.maps.outputs.capture-needed == 'true'
417
337
  shell: bash
418
- run: exit \${{ steps.diff.outputs.exit-code }}
338
+ run: |
339
+ BASE_SHA="\${{ github.event.pull_request.base.sha }}"
340
+ HEAD_SHA="\${{ github.event.pull_request.head.sha }}"
341
+ rm -rf __stylemaps__
342
+
343
+ git checkout "$BASE_SHA"
344
+ ${PM.install}
345
+ if [ -f "${specPath}" ]; then
346
+ ${PM.exec(`styleproof-map --spec ${specPath} --dir base --base-dir __stylemaps__ --keep-har --no-upload`)}
347
+ else
348
+ mkdir -p __stylemaps__/base
349
+ fi
350
+
351
+ git checkout "$HEAD_SHA"
352
+ ${PM.install}
353
+ if find __stylemaps__/base -name '*.har' -print -quit | grep -q .; then
354
+ STYLEPROOF_REPLAY_FROM=__stylemaps__/base ${PM.exec(`styleproof-map --spec ${specPath} --dir head --base-dir __stylemaps__ --no-upload`)}
355
+ else
356
+ ${PM.exec(`styleproof-map --spec ${specPath} --dir head --base-dir __stylemaps__ --no-upload`)}
357
+ fi
358
+ - uses: BenSheridanEdwards/StyleProof@v3
359
+ with:
360
+ baseline-dir: __stylemaps__/base
361
+ fresh-dir: __stylemaps__/head
362
+ require-approval: true
419
363
  `;
420
364
 
421
365
  function writeFileSafe(file, contents, { force: f } = {}) {
@@ -426,6 +370,15 @@ function writeFileSafe(file, contents, { force: f } = {}) {
426
370
  return { wrote: true, exists };
427
371
  }
428
372
 
373
+ function ensureGitignoreLine(line) {
374
+ const file = '.gitignore';
375
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
376
+ if (existing.split(/\r?\n/).includes(line)) return false;
377
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
378
+ fs.writeFileSync(file, `${existing}${prefix}${line}\n`);
379
+ return true;
380
+ }
381
+
429
382
  // Choose the scaffold: routes-aware when this is a Next.js app with discoverable
430
383
  // routes, else the generic one-surface starter.
431
384
  const routes = discoverNextRoutes(process.cwd());
@@ -460,58 +413,30 @@ if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
460
413
  wroteSomething = true;
461
414
  }
462
415
 
463
- // Pre-push capture+commit hook the out-of-the-box gate (capture local, CI diffs).
464
- const hook = writeFileSafe(HOOK_PATH, PREPUSH_HOOK, { force });
465
- if (hook.wrote) {
466
- fs.chmodSync(HOOK_PATH, 0o755);
467
- console.log(`${hook.exists ? 'overwrote' : 'created'} ${HOOK_PATH} (pre-push capture + commit)`);
416
+ const ignored = ['.styleproof/', 'test-results/', 'playwright-report/'].filter((line) => ensureGitignoreLine(line));
417
+ if (ignored.length) {
418
+ console.log(`updated .gitignore (${ignored.join(', ')})`);
468
419
  wroteSomething = true;
469
- } else {
470
- console.log(`${HOOK_PATH} already exists — left untouched (use --force to overwrite)`);
471
420
  }
472
421
 
473
- // Browser-less CI diff — never overwrite an existing workflow.
422
+ // Cache-first CI report — never overwrite an existing workflow.
474
423
  const ci = writeFileSafe(CI_PATH, CI_WORKFLOW);
475
424
  if (ci.wrote) {
476
- console.log(`created ${CI_PATH} (browser-less CI diff)`);
425
+ console.log(`created ${CI_PATH} (cache-first StyleProof report)`);
477
426
  wroteSomething = true;
478
427
  } else {
479
428
  console.log(`${CI_PATH} already exists — left untouched`);
480
429
  }
481
430
 
482
- // Activate the hook so ONE `styleproof-init` is all it takes — but never clobber a
483
- // repo that already manages its hooks (husky, or an existing core.hooksPath); there
484
- // we leave it and print the one-liner instead.
485
- const git = (a) => spawnSync('git', a, { encoding: 'utf8' });
486
- const inGitRepo = git(['rev-parse', '--is-inside-work-tree']).status === 0;
487
- const existingHooksPath = inGitRepo ? git(['config', '--local', '--get', 'core.hooksPath']).stdout.trim() : '';
488
- const managesHooks = existingHooksPath !== '' || fs.existsSync('.husky');
489
- let hookActivated = false;
490
- if (inGitRepo && !managesHooks) {
491
- if (git(['config', 'core.hooksPath', HOOK_DIR]).status === 0) {
492
- hookActivated = true;
493
- console.log(`activated the pre-push hook (core.hooksPath → ${HOOK_DIR})`);
494
- wroteSomething = true;
495
- }
496
- }
497
-
498
- console.log('\nHow the gate works — capture local, CI just diffs:');
499
- if (hookActivated) {
500
- console.log(' 1. ✓ Pre-push hook is active — nothing to do.');
501
- } else if (managesHooks) {
502
- console.log(` 1. Your repo already manages git hooks (${existingHooksPath || '.husky'}), so the hook`);
503
- console.log(` wasn't auto-activated. Point your hook runner at ${HOOK_DIR}/pre-push, or run:`);
504
- console.log(` git config core.hooksPath ${HOOK_DIR}`);
505
- } else {
506
- console.log(' 1. Activate the pre-push hook (once per clone):');
507
- console.log(` git config core.hooksPath ${HOOK_DIR}`);
508
- }
509
- console.log(' 2. Push your branch. The hook captures the computed-style map against a');
510
- console.log(' PRODUCTION build, commits it (lean .json.gz under stylemaps/), and pushes it');
511
- console.log(' with your branch — one `git push`, never two.');
512
- console.log(' 3. On the PR, CI diffs your committed map against the base branch — no browser,');
513
- console.log(' just a fast comparison of precomputed maps. An empty diff certifies no change.');
514
- console.log(' So main always carries a base map and every PR is a quick diff, not a recapture.');
431
+ console.log('\nHow the gate works local-first maps, CI report when cached:');
432
+ console.log(' 1. Commit your code, then run:');
433
+ console.log(' npx styleproof-map');
434
+ console.log(' It captures a production-build map into .styleproof/ and uploads the bundle');
435
+ console.log(' to the styleproof-maps branch when the git remote is available.');
436
+ console.log(' 2. Open the PR. CI restores the base/head bundles and generates the report');
437
+ console.log(' without a browser when both maps are present and compatible.');
438
+ console.log(' 3. If a bundle is missing or incompatible, CI recaptures both sides in the same');
439
+ console.log(' environment before generating the report. Correctness beats a stale cache.');
515
440
 
516
441
  if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
517
442
  process.exit(0);
@@ -2,16 +2,17 @@
2
2
  /**
3
3
  * Capture the current branch's computed-style map.
4
4
  *
5
- * The zero-config path is the committed-map flow scaffolded by styleproof-init:
5
+ * The zero-config path is the local-first cache flow scaffolded by styleproof-init:
6
6
  * styleproof-map
7
7
  *
8
8
  * It runs Playwright against e2e/styleproof.spec.ts with:
9
9
  * STYLEMAP_DIR=current
10
- * STYLEPROOF_BASEDIR=stylemaps
11
- * STYLEPROOF_SCREENSHOTS=0
10
+ * STYLEPROOF_BASEDIR=.styleproof/maps
11
+ * STYLEPROOF_SCREENSHOTS=1
12
12
  */
13
13
  import fs from 'node:fs';
14
14
  import { spawnSync } from 'node:child_process';
15
+ import path from 'node:path';
15
16
  import {
16
17
  isHelpArg,
17
18
  missingSpecMessage,
@@ -19,6 +20,19 @@ import {
19
20
  showHelpAndExit,
20
21
  unknownFlagMessage,
21
22
  } from '../dist/cli-errors.js';
23
+ import {
24
+ DEFAULT_MAP_DIR,
25
+ DEFAULT_MAP_LABEL,
26
+ DEFAULT_MAP_STORE_BRANCH,
27
+ DEFAULT_REMOTE,
28
+ MapStoreError,
29
+ currentGitSha,
30
+ expectedCompatibilityKey,
31
+ publishMapBundle,
32
+ restoreMapBundle,
33
+ workingTreeDirty,
34
+ writeMapManifest,
35
+ } from '../dist/map-store.js';
22
36
 
23
37
  const HELP = `styleproof-map — capture this branch's computed-style map
24
38
 
@@ -26,25 +40,39 @@ usage: styleproof-map [options] [-- <playwright args>]
26
40
 
27
41
  options:
28
42
  --spec <path> StyleProof spec that must exist (default: e2e/styleproof.spec.ts)
29
- --dir <label> output label under --base-dir (default: current)
30
- --base-dir <path> output root directory (default: stylemaps)
31
- --screenshots keep screenshots for reports (default: off for committed maps)
32
- --no-screenshots write lean .json.gz maps only (default)
43
+ --dir <label> output label under --base-dir (default: ${DEFAULT_MAP_LABEL})
44
+ --base-dir <path> output root directory (default: ${DEFAULT_MAP_DIR})
45
+ --screenshots keep screenshots for reports (default)
46
+ --no-screenshots write lean .json.gz maps only
33
47
  --keep-har keep recorded HAR files for advanced replay workflows
48
+ --sha <commit> commit SHA this map belongs to (default: current HEAD)
49
+ --upload require upload to the map store branch after capture
50
+ --no-upload capture locally only (default in CI)
51
+ --restore restore a map from the map store instead of capturing
52
+ --cache-branch <b> map store branch (default: ${DEFAULT_MAP_STORE_BRANCH})
53
+ --remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
34
54
  -h, --help show this help
35
55
 
36
56
  Examples:
37
57
  styleproof-map
58
+ styleproof-map --upload
59
+ styleproof-map --restore --sha 0123abcd --dir head --base-dir __stylemaps__
38
60
  styleproof-map --spec e2e/styleproof.spec.ts
39
- styleproof-map --dir review --base-dir __stylemaps__ --screenshots --keep-har
61
+ styleproof-map --dir review --base-dir __stylemaps__ --keep-har --no-upload
40
62
  `;
41
63
 
42
64
  const argv = process.argv.slice(2);
43
65
  let spec = 'e2e/styleproof.spec.ts';
44
- let dir = process.env.STYLEMAP_DIR ?? 'current';
45
- let baseDir = process.env.STYLEPROOF_BASEDIR ?? 'stylemaps';
46
- let screenshots = process.env.STYLEPROOF_SCREENSHOTS ?? '0';
66
+ let dir = process.env.STYLEMAP_DIR ?? DEFAULT_MAP_LABEL;
67
+ let baseDir = process.env.STYLEPROOF_BASEDIR ?? DEFAULT_MAP_DIR;
68
+ let screenshots = process.env.STYLEPROOF_SCREENSHOTS ?? '1';
47
69
  let keepHar = process.env.STYLEPROOF_KEEP_HAR === '1';
70
+ let sha = process.env.STYLEPROOF_SHA ?? '';
71
+ let restore = false;
72
+ let cacheBranch = process.env.STYLEPROOF_CACHE_BRANCH ?? DEFAULT_MAP_STORE_BRANCH;
73
+ let remote = process.env.STYLEPROOF_REMOTE ?? DEFAULT_REMOTE;
74
+ let uploadMode =
75
+ process.env.STYLEPROOF_UPLOAD === '1' ? 'required' : process.env.STYLEPROOF_UPLOAD === '0' ? 'off' : 'auto';
48
76
  const playwrightArgs = [];
49
77
 
50
78
  for (let i = 0; i < argv.length; i++) {
@@ -62,6 +90,15 @@ for (let i = 0; i < argv.length; i++) {
62
90
  else if (a === '--screenshots') screenshots = '1';
63
91
  else if (a === '--no-screenshots') screenshots = '0';
64
92
  else if (a === '--keep-har') keepHar = true;
93
+ else if (a === '--sha') sha = argv[++i];
94
+ else if (a.startsWith('--sha=')) sha = a.slice(6);
95
+ else if (a === '--upload') uploadMode = 'required';
96
+ else if (a === '--no-upload') uploadMode = 'off';
97
+ else if (a === '--restore') restore = true;
98
+ else if (a === '--cache-branch') cacheBranch = argv[++i];
99
+ else if (a.startsWith('--cache-branch=')) cacheBranch = a.slice(15);
100
+ else if (a === '--remote') remote = argv[++i];
101
+ else if (a.startsWith('--remote=')) remote = a.slice(9);
65
102
  else if (a.startsWith('--')) {
66
103
  console.error(unknownFlagMessage('styleproof-map', a));
67
104
  process.exit(2);
@@ -86,6 +123,14 @@ if (!fs.existsSync(spec)) {
86
123
  console.error(missingSpecMessage(spec));
87
124
  process.exit(2);
88
125
  }
126
+ if (restore && !sha) {
127
+ try {
128
+ sha = currentGitSha(process.cwd());
129
+ } catch (e) {
130
+ console.error(e instanceof Error ? e.message : String(e));
131
+ process.exit(2);
132
+ }
133
+ }
89
134
 
90
135
  function removeHarFiles(root) {
91
136
  if (!fs.existsSync(root)) return;
@@ -96,6 +141,63 @@ function removeHarFiles(root) {
96
141
  }
97
142
  }
98
143
 
144
+ function shouldAutoUpload() {
145
+ return uploadMode === 'auto' && !process.env.CI;
146
+ }
147
+
148
+ function upload(dirPath) {
149
+ if (uploadMode === 'off') return;
150
+ if (!shouldAutoUpload() && uploadMode !== 'required') return;
151
+ try {
152
+ const res = publishMapBundle({ dir: dirPath, branch: cacheBranch, remote });
153
+ console.error(`styleproof-map: uploaded ${res.sha.slice(0, 12)} (${res.compatibilityKey}) to ${res.branch}`);
154
+ } catch (e) {
155
+ const message = e instanceof Error ? e.message : String(e);
156
+ if (uploadMode === 'required') {
157
+ console.error(`styleproof-map: upload failed\n${message}`);
158
+ process.exit(2);
159
+ }
160
+ if (e instanceof MapStoreError) {
161
+ console.error(`styleproof-map: map captured locally; upload skipped (${message})`);
162
+ } else {
163
+ console.error(`styleproof-map: map captured locally; upload skipped`);
164
+ }
165
+ }
166
+ }
167
+
168
+ const targetDir = path.join(baseDir, dir);
169
+ let dirtyBeforeCapture;
170
+ try {
171
+ dirtyBeforeCapture = workingTreeDirty(process.cwd());
172
+ } catch {
173
+ dirtyBeforeCapture = false;
174
+ }
175
+
176
+ if (restore) {
177
+ try {
178
+ const compatibilityKey = expectedCompatibilityKey({ spec });
179
+ const manifest = restoreMapBundle({
180
+ sha,
181
+ outDir: targetDir,
182
+ branch: cacheBranch,
183
+ remote,
184
+ compatibilityKey,
185
+ });
186
+ console.log(`styleproof-map: restored ${manifest.sha.slice(0, 12)} (${manifest.compatibilityKey}) to ${targetDir}`);
187
+ process.exit(0);
188
+ } catch (e) {
189
+ const message = e instanceof Error ? e.message : String(e);
190
+ console.error(
191
+ [
192
+ `styleproof-map: could not restore ${sha} from ${cacheBranch}`,
193
+ message,
194
+ `Next: run styleproof-map at that commit to build/upload the map, or let CI recapture both sides.`,
195
+ ].join('\n'),
196
+ );
197
+ process.exit(2);
198
+ }
199
+ }
200
+
99
201
  const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
100
202
  const env = {
101
203
  ...process.env,
@@ -112,5 +214,30 @@ if (result.error) {
112
214
  process.exit(2);
113
215
  }
114
216
  const status = result.status ?? 1;
115
- if (status === 0 && !keepHar) removeHarFiles(`${baseDir}/${dir}`);
217
+ if (status === 0) {
218
+ if (!keepHar) removeHarFiles(targetDir);
219
+ try {
220
+ let manifestSha = sha || undefined;
221
+ if (!manifestSha) {
222
+ try {
223
+ manifestSha = currentGitSha(process.cwd(), env);
224
+ } catch {
225
+ manifestSha = 'local';
226
+ }
227
+ }
228
+ const manifest = writeMapManifest({
229
+ dir: targetDir,
230
+ spec,
231
+ sha: manifestSha,
232
+ screenshots: screenshots !== '0',
233
+ dirty: dirtyBeforeCapture,
234
+ env,
235
+ });
236
+ console.error(`styleproof-map: wrote ${targetDir} for ${manifest.sha.slice(0, 12)} (${manifest.compatibilityKey})`);
237
+ } catch (e) {
238
+ console.error(`styleproof-map: could not write map manifest\n${e instanceof Error ? e.message : String(e)}`);
239
+ process.exit(2);
240
+ }
241
+ upload(targetDir);
242
+ }
116
243
  process.exit(status);
@@ -4,6 +4,7 @@
4
4
  * region, plus the exact property changes, as markdown ready for a PR
5
5
  * comment.
6
6
  *
7
+ * styleproof-report [baseRef] --out <dir> [options] # cached map store
7
8
  * styleproof-report <beforeDir> <afterDir> --out <dir> [options]
8
9
  *
9
10
  * Both capture dirs need the .json.gz maps; side-by-side images additionally
@@ -11,23 +12,28 @@
11
12
  * Exit code 0 = no changes, 1 = report generated, 2 = usage error.
12
13
  */
13
14
  import { generateStyleMapReport } from '../dist/report.js';
14
- import { cleanupBaseRefCaptureDirs, resolveBaseRefCaptureDirs } from '../dist/cli-base-ref.js';
15
15
  import { cliErrorMessage, isHelpArg, showHelpAndExit, unknownFlagMessage } from '../dist/cli-errors.js';
16
+ import {
17
+ DEFAULT_MAP_STORE_BRANCH,
18
+ DEFAULT_REMOTE,
19
+ assertCompatibleMapDirs,
20
+ cleanupCachedCaptureDirs,
21
+ resolveCachedCaptureDirs,
22
+ } from '../dist/map-store.js';
16
23
 
17
24
  const COMMAND = 'styleproof-report';
18
- const DEFAULT_MAPS_DIR = 'stylemaps/current';
19
25
 
20
26
  const HELP = `${COMMAND} — reviewable before/after report from two captures
21
27
 
22
28
  usage: ${COMMAND} [baseRef] [options]
23
- ${COMMAND} --base-ref <gitref> [mapsDir] [options]
24
29
  ${COMMAND} <beforeDir> <afterDir> [options]
25
30
 
26
31
  options:
27
- --base-ref <ref> report <mapsDir> as committed at <ref> (e.g. main)
28
- against your working <mapsDir> — base from git, no recapture
29
- --maps-dir <dir> committed map dir for the base-ref flow
30
- (default: ${DEFAULT_MAPS_DIR})
32
+ --spec <path> StyleProof spec used to select compatible cached maps
33
+ (default: e2e/styleproof.spec.ts)
34
+ --cache-branch <b> map store branch for default cached-map mode
35
+ (default: ${DEFAULT_MAP_STORE_BRANCH})
36
+ --remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
31
37
  --out <dir> output directory (default: styleproof-report)
32
38
  --image-base-url <url> prefix for image URLs in report.md (default: relative)
33
39
  --pad <px> padding around changed rects when cropping (default: 24)
@@ -58,8 +64,9 @@ let minWidth;
58
64
  let minHeight;
59
65
  let includeLayoutNoise = false;
60
66
  let includeContent = false;
61
- let baseRef = null;
62
- let mapsDir = DEFAULT_MAPS_DIR;
67
+ let spec = 'e2e/styleproof.spec.ts';
68
+ let cacheBranch = process.env.STYLEPROOF_CACHE_BRANCH ?? DEFAULT_MAP_STORE_BRANCH;
69
+ let remote = process.env.STYLEPROOF_REMOTE ?? DEFAULT_REMOTE;
63
70
  for (let i = 0; i < argv.length; i++) {
64
71
  const a = argv[i];
65
72
  if (isHelpArg(a)) showHelpAndExit(HELP);
@@ -81,10 +88,12 @@ for (let i = 0; i < argv.length; i++) {
81
88
  else if (a.startsWith('--include-layout-noise=')) includeLayoutNoise = a.slice(23) !== 'false';
82
89
  else if (a === '--include-content') includeContent = true;
83
90
  else if (a.startsWith('--include-content=')) includeContent = a.slice(18) !== 'false';
84
- else if (a === '--base-ref') baseRef = argv[++i];
85
- else if (a.startsWith('--base-ref=')) baseRef = a.slice(11);
86
- else if (a === '--maps-dir') mapsDir = argv[++i];
87
- else if (a.startsWith('--maps-dir=')) mapsDir = a.slice(11);
91
+ else if (a === '--spec') spec = argv[++i];
92
+ else if (a.startsWith('--spec=')) spec = a.slice(7);
93
+ else if (a === '--cache-branch') cacheBranch = argv[++i];
94
+ else if (a.startsWith('--cache-branch=')) cacheBranch = a.slice(15);
95
+ else if (a === '--remote') remote = argv[++i];
96
+ else if (a.startsWith('--remote=')) remote = a.slice(9);
88
97
  else if (a.startsWith('--')) {
89
98
  console.error(unknownFlagMessage(COMMAND, a));
90
99
  process.exit(2);
@@ -92,22 +101,30 @@ for (let i = 0; i < argv.length; i++) {
92
101
  }
93
102
  let beforeDir;
94
103
  let afterDir;
95
- let baseCapture = null;
96
- if (baseRef || args.length <= 1) {
104
+ let cacheCapture = null;
105
+ if (args.length <= 1) {
97
106
  try {
98
- baseCapture = resolveBaseRefCaptureDirs({
107
+ cacheCapture = resolveCachedCaptureDirs({
99
108
  command: COMMAND,
100
- baseRef,
101
- mapsDir,
102
109
  args,
103
- usage: 'usage: styleproof-report --base-ref <gitref> [mapsDir] [--out <dir>] [options]',
110
+ spec,
111
+ branch: cacheBranch,
112
+ remote,
113
+ baseUrl: process.env.BASE_URL,
114
+ usage: 'usage: styleproof-report [baseRef] [--out <dir>] [options]',
104
115
  });
116
+ beforeDir = cacheCapture.beforeDir;
117
+ afterDir = cacheCapture.afterDir;
105
118
  } catch (e) {
106
- console.error(cliErrorMessage(e));
119
+ console.error(
120
+ [
121
+ `${COMMAND}: cached maps are not available for this report`,
122
+ cliErrorMessage(e),
123
+ 'Next: run styleproof-map on the base and head commits to upload maps, or let CI recapture both sides.',
124
+ ].join('\n'),
125
+ );
107
126
  process.exit(2);
108
127
  }
109
- beforeDir = baseCapture.beforeDir;
110
- afterDir = baseCapture.afterDir;
111
128
  } else {
112
129
  if (args.length !== 2) {
113
130
  console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
@@ -135,6 +152,7 @@ if (foldDetailsAt !== undefined && Number.isNaN(foldDetailsAt)) {
135
152
 
136
153
  let result;
137
154
  try {
155
+ assertCompatibleMapDirs(beforeDir, afterDir);
138
156
  result = generateStyleMapReport({
139
157
  beforeDir,
140
158
  afterDir,
@@ -152,7 +170,7 @@ try {
152
170
  console.error(e.message);
153
171
  process.exit(2);
154
172
  } finally {
155
- cleanupBaseRefCaptureDirs(baseCapture);
173
+ cleanupCachedCaptureDirs(cacheCapture);
156
174
  }
157
175
 
158
176
  const newNote = result.newSurfaces ? ` (+${result.newSurfaces} new surface(s) with no baseline)` : '';