styleproof 3.17.0 → 3.19.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.d.ts CHANGED
@@ -185,6 +185,23 @@ type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
185
185
  metadata?: CaptureMetadata;
186
186
  };
187
187
  export declare function expandSurfaceVariants(surface: Surface): ExpandedSurface[];
188
+ /** The identity fields of an expanded surface a collision check needs. */
189
+ type ExpandedKeyed = {
190
+ key: string;
191
+ metadata?: CaptureMetadata;
192
+ };
193
+ /**
194
+ * Fail LOUDLY on two expanded surfaces sharing a capture key.
195
+ *
196
+ * The expanded key is `surface.key-variant.key`, and that key is the map filename
197
+ * (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
198
+ * change without breaking backward compatibility. But the `-` join is ambiguous:
199
+ * surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
200
+ * `a-b-c`, and the second capture would silently overwrite the first, dropping a
201
+ * surface with no error. Rather than mangle the public key format, we assert
202
+ * uniqueness up front and name BOTH origins so the author can rename one.
203
+ */
204
+ export declare function assertUniqueExpandedKeys(surfaces: ExpandedKeyed[]): void;
188
205
  /**
189
206
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
190
207
  * server. A long-lived stream can't round-trip through a HAR entry: recording
@@ -255,25 +272,29 @@ export type CrawlOptions = CaptureConfig & {
255
272
  /** Max ms to wait for the crawl root's links to render before reading them
256
273
  * (an SPA hydrates its nav client-side). Default 15000. */
257
274
  linkTimeout?: number;
275
+ /**
276
+ * The full set of surface keys the app knows its nav should link to — its route
277
+ * universe. When set, the crawl reconciles the DISCOVERED link set against it, both
278
+ * directions: an `expected` key with no rendered link fails (nav regression), and a
279
+ * rendered link with no `expected` entry fails (a new route with no owner). For a
280
+ * link-crawled SPA the rendered nav IS the route universe, so this is the same
281
+ * list-vs-ledger discipline as `defineStyleMapCapture`'s guard with the nav as the
282
+ * source of truth.
283
+ *
284
+ * Unlike the spec guard, this runs INSIDE the crawl capture test — the link set
285
+ * isn't known until a browser renders the page — so it only fires when the capture
286
+ * runs (STYLEMAP_DIR set), not in every `npm test`. Omit to keep the current
287
+ * behaviour: capture what the nav links to, assert no completeness.
288
+ */
289
+ expected?: string[];
290
+ /**
291
+ * Expected/rendered keys deliberately not reconciled, each mapped to its reason — a
292
+ * visible, reviewed opt-out ledger for links that render CONDITIONALLY (behind auth
293
+ * or a feature flag) and so can't be asserted present or absent on every run. An
294
+ * excluded key never triggers a missing- or unexpected-link failure; an `exclude`
295
+ * key in neither `expected` nor the rendered set fails, so the ledger can't rot.
296
+ */
297
+ exclude?: Record<string, string>;
258
298
  };
259
- /**
260
- * Like {@link defineStyleMapCapture}, but the surface set is DISCOVERED at run time
261
- * by crawling a page's links instead of being hand-listed — for a single-route SPA
262
- * whose views are `?tab=`/client-routed and so invisible to the filesystem
263
- * {@link discoverNextRoutes}. It navigates `from`, reads its same-origin `<a href>`s
264
- * (filtered by `match`), and captures each as a surface keyed by `key`. The app just
265
- * has to render its nav as real links; nothing to hand-maintain, so the surface list
266
- * can't drift from the nav.
267
- *
268
- * One Playwright test does the whole sweep (the link set isn't known until a browser
269
- * has rendered the page, so per-surface tests can't be generated at collection time).
270
- * Per-surface failures are aggregated — one bad surface reports without hiding the
271
- * rest. Replay/self-check/clock-freeze behave exactly as for explicit surfaces.
272
- *
273
- * ```ts
274
- * // styleproof.spec.ts — capture every tab the nav links to
275
- * defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
276
- * ```
277
- */
278
299
  export declare function defineCrawlCapture(options: CrawlOptions): void;
279
300
  export {};
package/dist/runner.js CHANGED
@@ -3,9 +3,10 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
- import { coverageGaps, COVERAGE_LEDGER } from './coverage.js';
6
+ import { coverageGaps, coverageKeys, translateExpected, COVERAGE_LEDGER, } from './coverage.js';
7
+ import { writeBrowserBuildSidecar } from './map-store.js';
7
8
  import { detectViewportWidths } from './breakpoints.js';
8
- import { selectCrawlLinks } from './crawl.js';
9
+ import { selectCrawlLinks, crawlCoverageError } from './crawl.js';
9
10
  /** One-line description of the first drift finding, for the self-check error. */
10
11
  function driftDesc(f) {
11
12
  if (f.kind === 'dom')
@@ -209,6 +210,36 @@ export function expandSurfaceVariants(surface) {
209
210
  return [baseSurface, ...expandedVariants];
210
211
  return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
211
212
  }
213
+ /** Human-readable origin of an expanded surface for a collision message. */
214
+ function expandedOrigin(s) {
215
+ const surfaceKey = s.metadata?.surfaceKey ?? s.key;
216
+ const variantKey = s.metadata?.variantKey;
217
+ return variantKey ? `surface '${surfaceKey}' variant '${variantKey}'` : `surface '${surfaceKey}'`;
218
+ }
219
+ /**
220
+ * Fail LOUDLY on two expanded surfaces sharing a capture key.
221
+ *
222
+ * The expanded key is `surface.key-variant.key`, and that key is the map filename
223
+ * (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
224
+ * change without breaking backward compatibility. But the `-` join is ambiguous:
225
+ * surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
226
+ * `a-b-c`, and the second capture would silently overwrite the first, dropping a
227
+ * surface with no error. Rather than mangle the public key format, we assert
228
+ * uniqueness up front and name BOTH origins so the author can rename one.
229
+ */
230
+ export function assertUniqueExpandedKeys(surfaces) {
231
+ const byKey = new Map();
232
+ for (const s of surfaces) {
233
+ const prior = byKey.get(s.key);
234
+ if (prior) {
235
+ throw new Error(`styleproof: capture key '${s.key}' is produced by two surfaces — ` +
236
+ `${expandedOrigin(prior)} collides with ${expandedOrigin(s)}. ` +
237
+ `Keys must expand uniquely (they name the map files and report entries); ` +
238
+ `rename one surface or variant.`);
239
+ }
240
+ byKey.set(s.key, s);
241
+ }
242
+ }
212
243
  /**
213
244
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
214
245
  * server. A long-lived stream can't round-trip through a HAR entry: recording
@@ -458,7 +489,7 @@ function resolveSettings(c) {
458
489
  * `expected: null` records that the spec declared no registry, so a green can only
459
490
  * certify the captured surfaces. Runs on a capture run (dir set) only.
460
491
  */
461
- function writeCoverageLedgerTest(settings, dir, expected, exclude) {
492
+ function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfaces) {
462
493
  test('styleproof coverage ledger', () => {
463
494
  const outDir = path.join(settings.baseDir, dir);
464
495
  fs.mkdirSync(outDir, { recursive: true });
@@ -469,14 +500,30 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
469
500
  : settings.replayFrom
470
501
  ? 'replayed'
471
502
  : 'unproven';
472
- const ledger = { version: 1, expected, exclude, determinism };
503
+ // Pre-translate the declared universe into the keys actually captured to disk, so
504
+ // the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
505
+ // compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
506
+ const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
507
+ const ledger = { version: 1, expected: ledgerExpected, exclude, determinism };
473
508
  fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
474
509
  });
475
510
  }
511
+ /** Record the real browser build into the capture bundle. The npm `@playwright/test`
512
+ * version (in the manifest) is only a proxy — the actual Chromium binary can change while
513
+ * it holds constant (a re-download, a different browser store, a CI image bump). The
514
+ * compatibility guard reads this back to refuse a cross-build compare instead of walling
515
+ * a PR with false diffs. Best-effort: an unavailable version leaves the guard as-is. */
516
+ function writeBrowserBuildTest(settings, dir) {
517
+ test('styleproof browser build', ({ page }) => {
518
+ const version = page.context().browser()?.version();
519
+ writeBrowserBuildSidecar(path.join(settings.baseDir, dir), version);
520
+ });
521
+ }
476
522
  export function defineStyleMapCapture(options) {
477
523
  const { surfaces, expected, exclude = {}, dir } = options;
478
524
  const settings = resolveSettings(options);
479
525
  const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
526
+ assertUniqueExpandedKeys(captureSurfaces);
480
527
  // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
481
528
  // a route added without a surface fails the app's own tests — long before, and
482
529
  // independent of, a capture run. This is the one gap captures can't catch: a
@@ -485,7 +532,10 @@ export function defineStyleMapCapture(options) {
485
532
  if (expected) {
486
533
  test.describe('styleproof coverage', () => {
487
534
  test('every expected surface is captured or explicitly excluded', () => {
488
- const { uncovered, staleExclusions } = coverageGaps(captureSurfaces.map((s) => s.key), expected, exclude);
535
+ const { uncovered, staleExclusions } = coverageGaps(
536
+ // A liveStates surface is captured only as its `-loading`/`-loaded` splits;
537
+ // map each back to the declared base key so the split satisfies `expected`.
538
+ coverageKeys(captureSurfaces), expected, exclude);
489
539
  expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
490
540
  `nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
491
541
  `Missing: ${uncovered.join(', ')}`).toEqual([]);
@@ -497,7 +547,9 @@ export function defineStyleMapCapture(options) {
497
547
  test.describe('styleproof capture', () => {
498
548
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
499
549
  if (dir)
500
- writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
550
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, captureSurfaces);
551
+ if (dir)
552
+ writeBrowserBuildTest(settings, dir);
501
553
  for (const surface of captureSurfaces) {
502
554
  if (surface.widths && surface.widths.length > 0) {
503
555
  // Explicit widths: one parallelizable test per surface × width.
@@ -541,38 +593,96 @@ export function defineStyleMapCapture(options) {
541
593
  * defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
542
594
  * ```
543
595
  */
596
+ /**
597
+ * Load the crawl root, wait for its nav links to hydrate, and read them into a
598
+ * deduped, keyed surface list. A link-less page is fine for an unfiltered crawl —
599
+ * it still captures `from` itself (includeSelf); a `match`-filtered crawl genuinely
600
+ * needs links, so its hydration timeout surfaces. Throws when nothing matched.
601
+ */
602
+ async function discoverCrawlLinks(page, { from, match, key, linkTimeout }) {
603
+ await page.goto(from, { waitUntil: 'load' });
604
+ await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
605
+ if (match !== undefined)
606
+ throw e;
607
+ });
608
+ const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
609
+ // Unfiltered crawl → also capture `from` itself, so the root is always covered and
610
+ // a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
611
+ // crawl captures only the links the caller asked for.
612
+ const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
613
+ if (links.length === 0) {
614
+ throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
615
+ `<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
616
+ }
617
+ return links;
618
+ }
619
+ /**
620
+ * Capture every discovered surface, aggregating per-surface failures so one bad
621
+ * surface reports without skipping the rest — they're an independent set, not a
622
+ * chain. Auto-width parity with explicit surfaces: no widths given → navigate once,
623
+ * detect the surface's @media bands, then sweep one viewport per band.
624
+ */
625
+ async function sweepCrawlSurfaces(page, captureSurfaces, settings) {
626
+ const failures = [];
627
+ for (const surface of captureSurfaces) {
628
+ let sweep = surface.widths;
629
+ if (!sweep || sweep.length === 0) {
630
+ try {
631
+ await surface.go(page);
632
+ sweep = await detectViewportWidths(page);
633
+ }
634
+ catch (e) {
635
+ failures.push(`${surface.key} @ auto: ${e.message}`);
636
+ continue;
637
+ }
638
+ }
639
+ for (const width of sweep) {
640
+ try {
641
+ await captureSurface(page, surface, width, settings);
642
+ }
643
+ catch (e) {
644
+ failures.push(`${surface.key} @ ${width}: ${e.message}`);
645
+ }
646
+ }
647
+ }
648
+ if (failures.length) {
649
+ throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
650
+ }
651
+ }
544
652
  export function defineCrawlCapture(options) {
545
- const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
653
+ const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, expected, exclude = {}, } = options;
546
654
  const settings = resolveSettings(options);
547
655
  // Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
548
656
  // that styleproof-map uses to select capture tests picks up crawl specs too.
549
657
  test.describe('styleproof capture (crawl)', () => {
550
658
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
551
- // A crawl DISCOVERS its surfaces from the nav — it has no registry to check against,
552
- // so it records `expected: null` (completeness honestly "not asserted": the crawl
553
- // captures what the nav links to, and can't prove that's every route in the app).
659
+ // Record the completeness basis. Without `expected` a crawl has no registry to
660
+ // check against, so it records `expected: null` (honestly "not asserted": it
661
+ // captures what the nav links to, and can't prove that's every route). With
662
+ // `expected` the crawl reconciles the DISCOVERED link set against it below and the
663
+ // ledger travels with the declared universe.
664
+ // The crawl applies the SAME variants/liveStates to every discovered link, so the
665
+ // expansion of a declared key is knowable up front (before discovery): expand each
666
+ // `expected` key with this crawl's variants/liveStates to get the keys captured to
667
+ // disk, so a liveStates crawl's ledger is pre-translated like the spec-driven one.
668
+ const ledgerSurfaces = (expected ?? []).flatMap((key) => expandSurfaceVariants({ key, go: async () => { }, variants, liveStates }));
669
+ if (dir)
670
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, ledgerSurfaces);
554
671
  if (dir)
555
- writeCoverageLedgerTest(settings, dir, null, {});
672
+ writeBrowserBuildTest(settings, dir);
556
673
  test('discover surfaces by crawling links, then capture each', async ({ page }) => {
557
- // 1. Load the root and wait for its nav links to hydrate an SPA renders them
558
- // client-side, so they aren't in the initial HTML.
559
- await page.goto(from, { waitUntil: 'load' });
560
- // Wait for the nav's links to hydrate (an SPA renders them client-side). A link-less
561
- // page is fine for an unfiltered crawl it still captures `from` itself (includeSelf);
562
- // a `match`-filtered crawl genuinely needs links, so let its timeout surface.
563
- await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
564
- if (match !== undefined)
565
- throw e;
566
- });
567
- const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
568
- // Unfiltered crawl → also capture `from` itself, so the root is always covered and
569
- // a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
570
- // crawl captures only the links the caller asked for.
571
- const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
572
- if (links.length === 0) {
573
- throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
574
- `<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
575
- }
674
+ // 1. Load the root and read its hydrated nav links into the surface set.
675
+ const links = await discoverCrawlLinks(page, { from, match, key, linkTimeout });
676
+ // Coverage guard for a crawled nav. The rendered link set IS the route universe
677
+ // for a link-crawled SPA, so reconciling it against `expected` (both directions)
678
+ // is the spec guard's list-vs-ledger discipline with the nav as source of truth.
679
+ // Runs here inside the capture test because the link set isn't known until
680
+ // the page renders (unlike the static spec guard, which runs in the plain suite).
681
+ const gap = expected
682
+ ? crawlCoverageError(from, links.map((l) => l.key), expected, exclude)
683
+ : null;
684
+ if (gap)
685
+ throw new Error(gap);
576
686
  const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
577
687
  key: link.key,
578
688
  go: async (p) => {
@@ -587,40 +697,14 @@ export function defineCrawlCapture(options) {
587
697
  liveStates,
588
698
  popups,
589
699
  }));
700
+ assertUniqueExpandedKeys(captureSurfaces);
590
701
  // Budget the whole sweep up front: one test captures every surface, and
591
702
  // captureSurface no longer sets its own timeout, so size it to the work found.
592
703
  // With auto-width the band count isn't known until each surface renders, so
593
704
  // assume up to 4 bands per surface.
594
705
  test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 4), 0) * 60_000));
595
- // 2. Capture each discovered surface. Aggregate failures so one bad surface
596
- // reports without skipping the rest — they're an independent set, not a chain.
597
- const failures = [];
598
- for (const surface of captureSurfaces) {
599
- // Auto-width parity with explicit surfaces: no widths given → navigate once and
600
- // detect this surface's @media bands, then sweep one viewport per band.
601
- let sweep = surface.widths;
602
- if (!sweep || sweep.length === 0) {
603
- try {
604
- await surface.go(page);
605
- sweep = await detectViewportWidths(page);
606
- }
607
- catch (e) {
608
- failures.push(`${surface.key} @ auto: ${e.message}`);
609
- continue;
610
- }
611
- }
612
- for (const width of sweep) {
613
- try {
614
- await captureSurface(page, surface, width, settings);
615
- }
616
- catch (e) {
617
- failures.push(`${surface.key} @ ${width}: ${e.message}`);
618
- }
619
- }
620
- }
621
- if (failures.length) {
622
- throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
623
- }
706
+ // 2. Capture each discovered surface, aggregating per-surface failures.
707
+ await sweepCrawlSurfaces(page, captureSurfaces, settings);
624
708
  });
625
709
  });
626
710
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { captureStyleMap } from './capture.js';
3
3
  import { diffStyleMaps } from './diff.js';
4
+ import { DANGER_SOURCE } from './danger.js';
4
5
  function slug(value) {
5
6
  return (value
6
7
  .toLowerCase()
@@ -39,8 +40,11 @@ function liveKey(candidate) {
39
40
  function liveSelector(candidate) {
40
41
  return candidate.path;
41
42
  }
43
+ // `dangerSource` is the shared destructive-label pattern (see {@link DANGER_SOURCE}),
44
+ // passed in because this function is serialized into the browser and can't close over
45
+ // a Node `RegExp`.
42
46
  // fallow-ignore-next-line complexity
43
- function collectCandidates() {
47
+ function collectCandidates(dangerSource) {
44
48
  const controls = [
45
49
  '[aria-expanded]',
46
50
  '[aria-haspopup]',
@@ -53,7 +57,7 @@ function collectCandidates() {
53
57
  'select',
54
58
  'form',
55
59
  ].join(',');
56
- const dangerous = /\b(delete|remove|destroy|logout|log out|sign out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect)\b/i;
60
+ const dangerous = new RegExp(dangerSource, 'i');
57
61
  const esc = (value) => CSS.escape(value);
58
62
  const quote = (value) => JSON.stringify(value);
59
63
  const visible = (el) => {
@@ -93,7 +97,15 @@ function collectCandidates() {
93
97
  return pathSelector(el);
94
98
  };
95
99
  const labelFor = (el) => {
96
- const own = (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '').trim();
100
+ // Include `title` so an icon-only control (no text, no aria-label) announcing
101
+ // itself via a native tooltip — `<button title="Delete">🗑</button>` — still
102
+ // yields a real label. Without it the label is "button", slipping past the
103
+ // destructive guard below that this harvester's clicks must respect.
104
+ const own = (el.getAttribute('aria-label') ||
105
+ el.getAttribute('name') ||
106
+ el.textContent ||
107
+ el.getAttribute('title') ||
108
+ '').trim();
97
109
  return own.replace(/\s+/g, ' ').slice(0, 80) || el.tagName.toLowerCase();
98
110
  };
99
111
  const reasonFor = (el) => {
@@ -144,7 +156,7 @@ function collectCandidates() {
144
156
  return out;
145
157
  }
146
158
  async function discoverCandidates(page) {
147
- return page.evaluate(collectCandidates);
159
+ return page.evaluate(collectCandidates, DANGER_SOURCE);
148
160
  }
149
161
  async function perform(page, candidate) {
150
162
  const target = page.locator(candidate.selector).first();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.17.0",
3
+ "version": "3.19.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",