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/capture.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { gzipSync, gunzipSync } from 'node:zlib';
4
4
  import { classifyInventory, collectNavAffordances } from './inventory.js';
5
+ import { endpointOf, residueKey } from './data-residue.js';
5
6
  import { isMapFile } from './map-store.js';
6
7
  const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"], [tabindex]';
7
8
  // Freeze motion so every captured value is a settled end state, not a frame
@@ -598,6 +599,93 @@ export function trackInflightRequests(page) {
598
599
  },
599
600
  };
600
601
  }
602
+ /**
603
+ * Watch the data boundary (`url`, the same `replayUrl` glob record/replay uses) for
604
+ * requests that FAIL during capture — a network-level failure or a 4xx/5xx response.
605
+ * A failing data request means the captured state renders that endpoint's FALLBACK
606
+ * branch; the response-driven state is uncaptured and unproven (issue #205).
607
+ *
608
+ * Matches purely by listening to `requestfailed`/`response` and testing the URL against
609
+ * the boundary glob — NOT via a `page.route`. A passive route can't be used to tag: the
610
+ * tracker is armed before `go()`, and a route the surface itself adds in `go()` (an abort
611
+ * fixture, the HAR replay route) runs first and never falls through to a tag route added
612
+ * earlier, so tagged requests would be missed. Listeners see every request regardless of
613
+ * routing, which is exactly what a residue observer needs. Arm BEFORE navigation, like
614
+ * {@link trackInflightRequests}, so the page's own load fetches are seen; deduped per
615
+ * `<surface>·<endpoint>` so the same failure across widths / a self-check re-run is one entry.
616
+ *
617
+ * ONLY failures are recorded — never a 2xx that merely wasn't fixtured: in recording mode
618
+ * every live 2xx is legitimately recorded, so a blanket "uncontrolled" flag would fire on
619
+ * every healthy record run (issue #205, "deliberately out of scope").
620
+ */
621
+ export function trackDataResidue(page, url, surface) {
622
+ const byKey = new Map();
623
+ const inBoundary = urlMatcher(url);
624
+ const record = (request, reason) => {
625
+ if (!inBoundary(request.url()))
626
+ return;
627
+ const endpoint = endpointOf(request.url());
628
+ const key = residueKey(surface, endpoint);
629
+ if (!byKey.has(key))
630
+ byKey.set(key, { key, surface, endpoint, reason });
631
+ };
632
+ const onFailed = (r) => record(r, r.failure()?.errorText ?? 'request failed');
633
+ // A completed response's status is synchronous here, so a 4xx/5xx is recorded before
634
+ // capture reads the residue. A >=400 is a fallback-branch trigger just like a net failure.
635
+ const onResponse = (resp) => {
636
+ if (resp.status() >= 400)
637
+ record(resp.request(), `HTTP ${resp.status()}`);
638
+ };
639
+ page.on('requestfailed', onFailed);
640
+ page.on('response', onResponse);
641
+ return {
642
+ residue: () => Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key)),
643
+ dispose: () => {
644
+ page.off('requestfailed', onFailed);
645
+ page.off('response', onResponse);
646
+ },
647
+ };
648
+ }
649
+ /**
650
+ * A predicate matching a URL against a Playwright-style URL glob — the same micro-syntax
651
+ * `page.route`/`routeFromHAR` accept for `replayUrl`, replicated because Playwright exposes
652
+ * no public matcher and reaching into its bundled internals is fragile across versions.
653
+ * `**` spans path separators, `*` matches within a segment (not `/`), `?` is a literal
654
+ * (URL globs, unlike shell globs, treat `?` as the query delimiter), and `{a,b}` alternates.
655
+ * A plain (glob-char-free) string is treated as a substring match, matching Playwright's
656
+ * own "contains" fallback for non-glob route URLs.
657
+ */
658
+ export function urlMatcher(glob) {
659
+ if (!/[*?{}[\]]/.test(glob))
660
+ return (url) => url.includes(glob);
661
+ const specials = new Set(['.', '+', '^', '$', '|', '(', ')']);
662
+ let re = '';
663
+ for (let i = 0; i < glob.length; i++) {
664
+ const c = glob[i];
665
+ if (c === '*') {
666
+ if (glob[i + 1] === '*') {
667
+ re += '.*';
668
+ i++;
669
+ }
670
+ else
671
+ re += '[^/]*';
672
+ }
673
+ else if (c === '?')
674
+ re += '\\?';
675
+ else if (c === '{')
676
+ re += '(';
677
+ else if (c === '}')
678
+ re += ')';
679
+ else if (c === ',')
680
+ re += '|';
681
+ else if (specials.has(c))
682
+ re += `\\${c}`;
683
+ else
684
+ re += c;
685
+ }
686
+ const compiled = new RegExp(`^${re}$`);
687
+ return (url) => compiled.test(url);
688
+ }
601
689
  /** Settle the page and return the paths of live regions to exclude. */
602
690
  async function detectVolatile(page, ignore, stabilize, captureText, externalPending) {
603
691
  if (stabilize === false)
@@ -793,15 +881,49 @@ export function loadStyleMap(filePath) {
793
881
  'Re-capture it — a partial write or interrupted upload produces an unreadable .gz.', { cause: e });
794
882
  }
795
883
  }
884
+ /** Every surface map in a capture dir, loaded — the shared reader behind the
885
+ * per-field extractors below. */
886
+ function loadDirMaps(dir) {
887
+ return fs
888
+ .readdirSync(dir)
889
+ .filter(isMapFile)
890
+ .map((f) => loadStyleMap(path.join(dir, f)));
891
+ }
796
892
  /**
797
893
  * Read every surface map's navigable inventory from a capture dir, in the shape the
798
894
  * inventory audit consumes. One home for "read the inventories out of a dir", shared
799
895
  * by the diff CLI and the report (was duplicated in both).
800
896
  */
801
897
  export function readInventories(dir) {
802
- return fs
803
- .readdirSync(dir)
804
- .filter(isMapFile)
805
- .map((f) => loadStyleMap(path.join(dir, f)))
806
- .map((m) => (m.inventory ? { inventory: m.inventory } : {}));
898
+ return loadDirMaps(dir).map((m) => (m.inventory ? { inventory: m.inventory } : {}));
899
+ }
900
+ /**
901
+ * Read every surface map's `dataResidue` from a capture dir, in the shape the
902
+ * data-residue audit consumes. Lives here (not in data-residue.ts) so that module
903
+ * stays a pure leaf — it must not import `loadStyleMap` back from this file.
904
+ */
905
+ export function readResidue(dir) {
906
+ return loadDirMaps(dir).map((m) => (m.dataResidue ? { dataResidue: m.dataResidue } : {}));
907
+ }
908
+ /**
909
+ * Each captured surface key → the set of element paths it renders, unioned across
910
+ * the given dirs (so an element present on only one side still "belongs" to the
911
+ * surface). Feeds the shared-chrome tier, which needs to know, per surface, which
912
+ * paths exist to tell a frame-wide change from one view's content. Shared by the
913
+ * report and the diff CLI (both already read maps this way).
914
+ */
915
+ export function surfaceElementPaths(...dirs) {
916
+ const bySurface = new Map();
917
+ for (const dir of dirs) {
918
+ if (!fs.existsSync(dir))
919
+ continue;
920
+ for (const file of fs.readdirSync(dir).filter(isMapFile)) {
921
+ const surface = file.replace(/\.json(\.gz)?$/, '');
922
+ const set = bySurface.get(surface) ?? new Set();
923
+ for (const p of Object.keys(loadStyleMap(path.join(dir, file)).elements))
924
+ set.add(p);
925
+ bySurface.set(surface, set);
926
+ }
927
+ }
928
+ return bySurface;
807
929
  }
@@ -0,0 +1,91 @@
1
+ import { type Finding, type PropChange } from './diff.js';
2
+ export declare const isNonValue: (v: string) => boolean;
3
+ export declare function summarizeProps(props: PropChange[]): PropChange[];
4
+ /** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
5
+ export declare function prettyLabel(p: string, cls: string): string;
6
+ export declare const safeKey: (s: string) => string;
7
+ export declare const surfaceBase: (s: string) => string;
8
+ export declare const surfaceWidth: (s: string) => number;
9
+ export declare function pushSurfaceWidth(byBase: Map<string, number[]>, base: string, surface: string): void;
10
+ export declare function renderSurfaceGroups(byBase: Map<string, number[]>): string;
11
+ /** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
12
+ export declare function formatSurfaceList(surfaces: string[]): string;
13
+ /** Group findings by their element path (one group per changed element). */
14
+ export declare function groupByPath(findings: Finding[]): Finding[][];
15
+ /** Canonical signature of a surface's findings: surfaces that changed in the
16
+ * same way collapse into one section + one image (the rects differ per width;
17
+ * the change itself does not). */
18
+ export declare function signatureOf(findings: Finding[]): string;
19
+ /** A one-line heading for a change group: "1 element added", "2 elements restyled". */
20
+ export declare function groupTitle(findings: Finding[]): string;
21
+ /** How many of a surface's summarised props are derived/box longhands — the count
22
+ * the CLI folds behind `(+N derived longhands)`. Counts on the RAW finding props
23
+ * (before cleaning) so the CLI can advertise exactly what it suppressed. */
24
+ export declare function derivedLonghandCount(findings: Finding[]): number;
25
+ /**
26
+ * Strip the noise the visual report shouldn't carry, cross-referencing each
27
+ * element's layers so the forced-state layer stops echoing the base:
28
+ * - base/pseudo styles: drop size/position-derived longhands (reflow casualties);
29
+ * - forced states: drop derived + grid-track props, drop a delta the BASE
30
+ * already changed (a `:hover color` that just follows a recoloured base is an
31
+ * echo, not a dropped variant), and drop non-value↔non-value rows;
32
+ * - any finding left with no props is removed entirely.
33
+ */
34
+ export declare function cleanFindings(findings: Finding[]): Finding[];
35
+ /** A surface's diff distilled to just what grouping needs: its key and the
36
+ * findings kept after noise-cleaning. */
37
+ export type SurfaceFindings = {
38
+ surface: string;
39
+ findings: Finding[];
40
+ };
41
+ /** Surfaces that changed the SAME way, collapsed to one group + a representative. */
42
+ export type SignatureGroup = {
43
+ surfaces: string[];
44
+ rep: SurfaceFindings;
45
+ findings: Finding[];
46
+ };
47
+ /**
48
+ * Group surfaces that changed identically (same signature) into one group each,
49
+ * keeping the widest surface as the representative. The rects differ per width;
50
+ * the change itself does not. Callers pass the already-prepared per-surface
51
+ * findings (missing/one-sided surfaces excluded upstream).
52
+ */
53
+ export declare function groupBySignature(prepared: SurfaceFindings[]): SignatureGroup[];
54
+ /**
55
+ * The set of element paths that changed as SHARED CHROME — a persistent frame
56
+ * element (nav rail, header, footer) that every view renders and that moved on
57
+ * every view that renders it.
58
+ *
59
+ * The rule is STRUCTURAL, deliberately NOT a tunable percentage. For each changed
60
+ * path we compare two base-key sets:
61
+ * - `hosting` — the surface bases whose style map contains the path at all;
62
+ * - `changed` — the surface bases where the path appears in a finding.
63
+ * The path is chrome iff it is hosted on MORE THAN ONE base and it changed on
64
+ * EVERY base that hosts it (`changed ⊇ hosting`). "Every surface that has this
65
+ * element changed it" is exactly what shared chrome means; it needs no threshold
66
+ * to tune or defend. A content element (hosted on one base) fails the >1 guard; a
67
+ * partial change (some hosting bases unchanged) fails the coverage guard.
68
+ *
69
+ * Widths of one base collapse to the base key, so a nav present at @1280 and @390
70
+ * counts once. `surfacePaths` maps each captured surface key → the element paths
71
+ * it renders (union of both sides from the caller).
72
+ */
73
+ export declare function chromePaths(changedOnSurfaces: Array<{
74
+ path: string;
75
+ surfaces: string[];
76
+ }>, surfacePaths: Map<string, Set<string>>): Set<string>;
77
+ /**
78
+ * Split signature groups into the shared-chrome tier and the rest. A group is
79
+ * promoted only when EVERY one of its affected element paths is a chrome path
80
+ * (see `chromePaths`) — a group that entangles a frame change with a view's own
81
+ * content change stays in `rest` and renders in place, so we never hide a
82
+ * content change under a chrome banner.
83
+ */
84
+ export declare function classifyChrome<G extends {
85
+ surfaces: string[];
86
+ findings: Finding[];
87
+ }>(groups: G[], surfacePaths: Map<string, Set<string>>): {
88
+ chrome: G[];
89
+ rest: G[];
90
+ chromePaths: Set<string>;
91
+ };