styleproof 3.16.0 → 3.18.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/report.js CHANGED
@@ -385,6 +385,14 @@ export function prettyLabel(p, cls) {
385
385
  const first = cls.split(/\s+/)[0] ?? '';
386
386
  return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
387
387
  }
388
+ // Surface keys originate from artifact filenames — attacker-controlled in the
389
+ // fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
390
+ // (the Action slices report.md above the first `### `). Strip the Markdown/HTML
391
+ // control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
392
+ // or table into that bot comment. Escaping at the render boundary — the keys stay
393
+ // legible; only the injection surface is removed. (Crop FILENAMES are separately
394
+ // restricted to [a-z0-9-]; this is the display-side equivalent.)
395
+ const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
388
396
  const surfaceBase = (s) => s.replace(/@\d+$/, '');
389
397
  const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
390
398
  function pushSurfaceWidth(byBase, base, surface) {
@@ -396,7 +404,7 @@ function renderSurfaceGroups(byBase) {
396
404
  return [...byBase]
397
405
  .map(([base, ws]) => {
398
406
  const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
399
- return widths.length ? `${base} @ ${widths.join(', ')}` : base;
407
+ return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
400
408
  })
401
409
  .join(' · ');
402
410
  }
@@ -821,7 +829,7 @@ function renderContentSurface(ctx, surface, changes, seq) {
821
829
  const mapB = loadStyleMap(findCapture(ctx.afterDir, surface));
822
830
  const pngA = readPng(path.join(ctx.beforeDir, `${surface}.png`));
823
831
  const pngB = readPng(path.join(ctx.afterDir, `${surface}.png`));
824
- const md = ['', `### \`${surface}\` · ${changes.length} content change(s)`];
832
+ const md = ['', `### \`${safeKey(surface)}\` · ${changes.length} content change(s)`];
825
833
  for (const c of changes) {
826
834
  seq++;
827
835
  md.push('', `**\`${prettyLabel(c.path, c.cls)}\`**`, '', `- before: \`${clipText(c.before) || '(empty)'}\``, `- after: \`${clipText(c.after) || '(empty)'}\``, ...contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq));
@@ -888,7 +896,7 @@ function coverageLine(cov) {
888
896
  if (cov.basis === 'complete')
889
897
  return `- **Coverage** — ✓ complete (all ${cov.registrySize} registered surface(s) captured)`;
890
898
  if (cov.basis === 'incomplete')
891
- return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.join(', ')})`;
899
+ return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.map(safeKey).join(', ')})`;
892
900
  return '- **Coverage** — ⚠ not asserted (no `expected` registry; certifies only the captured surfaces)';
893
901
  }
894
902
  function determinismLine(det) {
@@ -900,7 +908,7 @@ function determinismLine(det) {
900
908
  }
901
909
  function inventoryLine(inv) {
902
910
  if (inv.unexplained.length > 0) {
903
- const keys = inv.unexplained.map((i) => i.key);
911
+ const keys = inv.unexplained.map((i) => safeKey(i.key));
904
912
  return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
905
913
  }
906
914
  if (inv.delta.removed.length > 0)
@@ -1176,7 +1184,7 @@ function renderNewSurface(p, ctx, cropSeq) {
1176
1184
  const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
1177
1185
  const md = [
1178
1186
  '',
1179
- `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`,
1187
+ `### \`${safeKey(p.sd.surface)}\` · new surface ${NEW_SURFACE_MARKER}`,
1180
1188
  '',
1181
1189
  `_${formatSurfaceWithContext(p.sd.surface, map)}_`,
1182
1190
  ];
@@ -1196,6 +1204,31 @@ function renderNewSurface(p, ctx, cropSeq) {
1196
1204
  md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
1197
1205
  return { md, json, cropSeq };
1198
1206
  }
1207
+ /** The one-time banner where report.md switches from full detail to one-line
1208
+ * summaries, so the reader knows nothing is missing — only relocated to report.json. */
1209
+ function cappedNoticeLines(budget) {
1210
+ return [
1211
+ '',
1212
+ '## … more changed surfaces (summarized to keep this report renderable)',
1213
+ '',
1214
+ `_This report reached its ~${Math.round(budget / 1000)} KB display budget (GitHub does not render ` +
1215
+ `markdown past ~512 KB), so the surfaces below are listed as one-liners. Their full property ` +
1216
+ `tables are in \`report.json\` and their crops in \`crops/\` — the certification above covers every ` +
1217
+ `surface; only the inline detail is capped._`,
1218
+ '',
1219
+ ];
1220
+ }
1221
+ /** One-line summary for a changed surface whose full detail was budget-capped: its
1222
+ * name (and how many surfaces share the identical change) · change count · a crop
1223
+ * link so the reviewer can still see it without opening report.json. */
1224
+ function compactChangeSummary(cg, json, img) {
1225
+ const surface = safeKey(cg.rep.sd.surface);
1226
+ const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
1227
+ const regions = json.regions ?? [];
1228
+ const composite = regions[0]?.images?.composite;
1229
+ const link = composite ? ` — [crop](${img(composite)})` : '';
1230
+ return `- \`${surface}\`${more} · ${cg.rep.findings.length} change(s)${link}`;
1231
+ }
1199
1232
  export function generateStyleMapReport(opts) {
1200
1233
  const { beforeDir, afterDir, outDir, imageBaseUrl = '',
1201
1234
  // Tighter than before (was 24) so the change fills the frame — the annotation
@@ -1203,7 +1236,7 @@ export function generateStyleMapReport(opts) {
1203
1236
  pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
1204
1237
  // More, smaller crops before collapsing (was 6), so distinct changes get their
1205
1238
  // own focused frame rather than one wide merged one.
1206
- maxCrops = 8, foldDetailsAt = 0, } = opts;
1239
+ maxCrops = 8, foldDetailsAt = 0, maxReportBytes = 400_000, } = opts;
1207
1240
  const includeNoise = opts.includeLayoutNoise ?? false;
1208
1241
  const includeContent = opts.includeContent ?? false;
1209
1242
  const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
@@ -1261,18 +1294,38 @@ export function generateStyleMapReport(opts) {
1261
1294
  }));
1262
1295
  let totalFindings = 0;
1263
1296
  let cropSeq = 0;
1297
+ // report.md must stay renderable — GitHub refuses to render markdown past ~512 KB.
1298
+ // Emit full detail greedily until the byte budget is reached, then list any remaining
1299
+ // surfaces as one-liners. The exhaustive per-row detail is always in report.json and
1300
+ // every crop in crops/, so the cap changes what's shown inline, never what's certified.
1301
+ let reportBytes = md.join('\n').length;
1302
+ let capped = false;
1303
+ const emitDetail = (detail, summary) => {
1304
+ const cost = detail.join('\n').length + 1;
1305
+ if (!capped && reportBytes + cost <= maxReportBytes) {
1306
+ md.push(...detail);
1307
+ reportBytes += cost;
1308
+ return;
1309
+ }
1310
+ if (!capped) {
1311
+ md.push(...cappedNoticeLines(maxReportBytes));
1312
+ capped = true;
1313
+ }
1314
+ md.push(summary);
1315
+ reportBytes += summary.length + 1;
1316
+ };
1264
1317
  for (const cg of changeGroups) {
1265
1318
  const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
1266
- md.push(...r.md);
1267
1319
  json.push(r.json);
1268
1320
  totalFindings += r.findingCount;
1269
1321
  cropSeq = r.cropSeq;
1322
+ emitDetail(r.md, compactChangeSummary(cg, r.json, img));
1270
1323
  }
1271
1324
  for (const p of missing) {
1272
1325
  const r = renderNewSurface(p, ctx, cropSeq);
1273
- md.push(...r.md);
1274
1326
  json.push(r.json);
1275
1327
  cropSeq = r.cropSeq;
1328
+ emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
1276
1329
  }
1277
1330
  md.push(...contentSection.md);
1278
1331
  const reportMdPath = path.join(outDir, 'report.md');
package/dist/runner.d.ts CHANGED
@@ -255,25 +255,29 @@ export type CrawlOptions = CaptureConfig & {
255
255
  /** Max ms to wait for the crawl root's links to render before reading them
256
256
  * (an SPA hydrates its nav client-side). Default 15000. */
257
257
  linkTimeout?: number;
258
+ /**
259
+ * The full set of surface keys the app knows its nav should link to — its route
260
+ * universe. When set, the crawl reconciles the DISCOVERED link set against it, both
261
+ * directions: an `expected` key with no rendered link fails (nav regression), and a
262
+ * rendered link with no `expected` entry fails (a new route with no owner). For a
263
+ * link-crawled SPA the rendered nav IS the route universe, so this is the same
264
+ * list-vs-ledger discipline as `defineStyleMapCapture`'s guard with the nav as the
265
+ * source of truth.
266
+ *
267
+ * Unlike the spec guard, this runs INSIDE the crawl capture test — the link set
268
+ * isn't known until a browser renders the page — so it only fires when the capture
269
+ * runs (STYLEMAP_DIR set), not in every `npm test`. Omit to keep the current
270
+ * behaviour: capture what the nav links to, assert no completeness.
271
+ */
272
+ expected?: string[];
273
+ /**
274
+ * Expected/rendered keys deliberately not reconciled, each mapped to its reason — a
275
+ * visible, reviewed opt-out ledger for links that render CONDITIONALLY (behind auth
276
+ * or a feature flag) and so can't be asserted present or absent on every run. An
277
+ * excluded key never triggers a missing- or unexpected-link failure; an `exclude`
278
+ * key in neither `expected` nor the rendered set fails, so the ledger can't rot.
279
+ */
280
+ exclude?: Record<string, string>;
258
281
  };
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
282
  export declare function defineCrawlCapture(options: CrawlOptions): void;
279
283
  export {};
package/dist/runner.js CHANGED
@@ -4,8 +4,9 @@ import path from 'node:path';
4
4
  import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
6
  import { coverageGaps, 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')
@@ -473,6 +474,17 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
473
474
  fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
474
475
  });
475
476
  }
477
+ /** Record the real browser build into the capture bundle. The npm `@playwright/test`
478
+ * version (in the manifest) is only a proxy — the actual Chromium binary can change while
479
+ * it holds constant (a re-download, a different browser store, a CI image bump). The
480
+ * compatibility guard reads this back to refuse a cross-build compare instead of walling
481
+ * a PR with false diffs. Best-effort: an unavailable version leaves the guard as-is. */
482
+ function writeBrowserBuildTest(settings, dir) {
483
+ test('styleproof browser build', ({ page }) => {
484
+ const version = page.context().browser()?.version();
485
+ writeBrowserBuildSidecar(path.join(settings.baseDir, dir), version);
486
+ });
487
+ }
476
488
  export function defineStyleMapCapture(options) {
477
489
  const { surfaces, expected, exclude = {}, dir } = options;
478
490
  const settings = resolveSettings(options);
@@ -498,6 +510,8 @@ export function defineStyleMapCapture(options) {
498
510
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
499
511
  if (dir)
500
512
  writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
513
+ if (dir)
514
+ writeBrowserBuildTest(settings, dir);
501
515
  for (const surface of captureSurfaces) {
502
516
  if (surface.widths && surface.widths.length > 0) {
503
517
  // Explicit widths: one parallelizable test per surface × width.
@@ -541,38 +555,91 @@ export function defineStyleMapCapture(options) {
541
555
  * defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
542
556
  * ```
543
557
  */
558
+ /**
559
+ * Load the crawl root, wait for its nav links to hydrate, and read them into a
560
+ * deduped, keyed surface list. A link-less page is fine for an unfiltered crawl —
561
+ * it still captures `from` itself (includeSelf); a `match`-filtered crawl genuinely
562
+ * needs links, so its hydration timeout surfaces. Throws when nothing matched.
563
+ */
564
+ async function discoverCrawlLinks(page, { from, match, key, linkTimeout }) {
565
+ await page.goto(from, { waitUntil: 'load' });
566
+ await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
567
+ if (match !== undefined)
568
+ throw e;
569
+ });
570
+ const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
571
+ // Unfiltered crawl → also capture `from` itself, so the root is always covered and
572
+ // a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
573
+ // crawl captures only the links the caller asked for.
574
+ const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
575
+ if (links.length === 0) {
576
+ throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
577
+ `<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
578
+ }
579
+ return links;
580
+ }
581
+ /**
582
+ * Capture every discovered surface, aggregating per-surface failures so one bad
583
+ * surface reports without skipping the rest — they're an independent set, not a
584
+ * chain. Auto-width parity with explicit surfaces: no widths given → navigate once,
585
+ * detect the surface's @media bands, then sweep one viewport per band.
586
+ */
587
+ async function sweepCrawlSurfaces(page, captureSurfaces, settings) {
588
+ const failures = [];
589
+ for (const surface of captureSurfaces) {
590
+ let sweep = surface.widths;
591
+ if (!sweep || sweep.length === 0) {
592
+ try {
593
+ await surface.go(page);
594
+ sweep = await detectViewportWidths(page);
595
+ }
596
+ catch (e) {
597
+ failures.push(`${surface.key} @ auto: ${e.message}`);
598
+ continue;
599
+ }
600
+ }
601
+ for (const width of sweep) {
602
+ try {
603
+ await captureSurface(page, surface, width, settings);
604
+ }
605
+ catch (e) {
606
+ failures.push(`${surface.key} @ ${width}: ${e.message}`);
607
+ }
608
+ }
609
+ }
610
+ if (failures.length) {
611
+ throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
612
+ }
613
+ }
544
614
  export function defineCrawlCapture(options) {
545
- const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
615
+ const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, expected, exclude = {}, } = options;
546
616
  const settings = resolveSettings(options);
547
617
  // Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
548
618
  // that styleproof-map uses to select capture tests picks up crawl specs too.
549
619
  test.describe('styleproof capture (crawl)', () => {
550
620
  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).
621
+ // Record the completeness basis. Without `expected` a crawl has no registry to
622
+ // check against, so it records `expected: null` (honestly "not asserted": it
623
+ // captures what the nav links to, and can't prove that's every route). With
624
+ // `expected` the crawl reconciles the DISCOVERED link set against it below and the
625
+ // ledger travels with the declared universe.
626
+ if (dir)
627
+ writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
554
628
  if (dir)
555
- writeCoverageLedgerTest(settings, dir, null, {});
629
+ writeBrowserBuildTest(settings, dir);
556
630
  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
- }
631
+ // 1. Load the root and read its hydrated nav links into the surface set.
632
+ const links = await discoverCrawlLinks(page, { from, match, key, linkTimeout });
633
+ // Coverage guard for a crawled nav. The rendered link set IS the route universe
634
+ // for a link-crawled SPA, so reconciling it against `expected` (both directions)
635
+ // is the spec guard's list-vs-ledger discipline with the nav as source of truth.
636
+ // Runs here inside the capture test because the link set isn't known until
637
+ // the page renders (unlike the static spec guard, which runs in the plain suite).
638
+ const gap = expected
639
+ ? crawlCoverageError(from, links.map((l) => l.key), expected, exclude)
640
+ : null;
641
+ if (gap)
642
+ throw new Error(gap);
576
643
  const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
577
644
  key: link.key,
578
645
  go: async (p) => {
@@ -592,35 +659,8 @@ export function defineCrawlCapture(options) {
592
659
  // With auto-width the band count isn't known until each surface renders, so
593
660
  // assume up to 4 bands per surface.
594
661
  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
- }
662
+ // 2. Capture each discovered surface, aggregating per-surface failures.
663
+ await sweepCrawlSurfaces(page, captureSurfaces, settings);
624
664
  });
625
665
  });
626
666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.16.0",
3
+ "version": "3.18.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",