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/CHANGELOG.md +192 -0
- package/README.md +437 -321
- package/bin/styleproof-capture.mjs +4 -0
- package/bin/styleproof-diff.mjs +192 -27
- package/bin/styleproof-init.mjs +75 -7
- package/bin/styleproof-map.mjs +23 -9
- package/bin/styleproof-report.mjs +6 -0
- package/dist/capture-url.js +5 -0
- package/dist/capture.d.ts +59 -0
- package/dist/capture.js +127 -5
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/coverage.d.ts +7 -0
- package/dist/data-residue.d.ts +70 -0
- package/dist/data-residue.js +105 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/map-store.d.ts +37 -1
- package/dist/map-store.js +103 -10
- package/dist/report.d.ts +1 -4
- package/dist/report.js +100 -353
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +63 -9
- package/example/styleproof-approve.yml +108 -0
- package/package.json +7 -4
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
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
+
};
|