styleproof 3.1.2 → 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.
- package/CHANGELOG.md +46 -1
- package/README.md +86 -70
- package/bin/styleproof-diff.mjs +46 -32
- package/bin/styleproof-init.mjs +78 -160
- package/bin/styleproof-map.mjs +139 -12
- package/bin/styleproof-report.mjs +41 -23
- package/dist/capture.d.ts +1 -1
- package/dist/cli-errors.d.ts +0 -1
- package/dist/cli-errors.js +2 -8
- package/dist/diff.js +1 -1
- package/dist/gitref.d.ts +2 -12
- package/dist/gitref.js +2 -42
- package/dist/index.d.ts +1 -1
- package/dist/map-store.d.ts +85 -0
- package/dist/map-store.js +342 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.js +79 -9
- package/dist/runner.d.ts +31 -5
- package/dist/runner.js +189 -6
- package/package.json +5 -3
- package/dist/cli-base-ref.d.ts +0 -15
- package/dist/cli-base-ref.js +0 -53
package/dist/report.js
CHANGED
|
@@ -20,6 +20,13 @@ const union = (a, b) => {
|
|
|
20
20
|
};
|
|
21
21
|
const intersects = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
22
22
|
const visible = (b) => !!b && b.w > 0 && b.h > 0;
|
|
23
|
+
/** Bounding box that encloses every rect (the changed-element footprint). */
|
|
24
|
+
function unionRects(rects) {
|
|
25
|
+
const boxes = rects.map(rectToBox).filter(visible);
|
|
26
|
+
if (!boxes.length)
|
|
27
|
+
return null;
|
|
28
|
+
return boxes.reduce(union);
|
|
29
|
+
}
|
|
23
30
|
/** Outermost changed paths: drop any path that has a changed strict ancestor.
|
|
24
31
|
* Used to ANCHOR a crop (zoom to the whole changed region, not a leaf). */
|
|
25
32
|
function outermost(paths) {
|
|
@@ -161,6 +168,38 @@ function compositePair(before, after) {
|
|
|
161
168
|
fillRect(canvas, PAD + w + GAP / 2 - 1, PAD, 2, h, [48, 54, 61]); // divider
|
|
162
169
|
return canvas;
|
|
163
170
|
}
|
|
171
|
+
// Integer nearest-neighbor upscale. Nearest-neighbor (not smoothing) so the
|
|
172
|
+
// zoom invents no colours that weren't captured — a magnified crop is still a
|
|
173
|
+
// faithful pixel-for-pixel view, just bigger.
|
|
174
|
+
function scalePng(src, s) {
|
|
175
|
+
if (s <= 1)
|
|
176
|
+
return src;
|
|
177
|
+
const out = new PNG({ width: src.width * s, height: src.height * s });
|
|
178
|
+
for (let y = 0; y < out.height; y++) {
|
|
179
|
+
const sy = Math.floor(y / s);
|
|
180
|
+
for (let x = 0; x < out.width; x++) {
|
|
181
|
+
const si = (sy * src.width + Math.floor(x / s)) << 2;
|
|
182
|
+
const oi = (y * out.width + x) << 2;
|
|
183
|
+
out.data[oi] = src.data[si];
|
|
184
|
+
out.data[oi + 1] = src.data[si + 1];
|
|
185
|
+
out.data[oi + 2] = src.data[si + 2];
|
|
186
|
+
out.data[oi + 3] = src.data[si + 3];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
// A magnified crop centered on the changed box, for changes too small to see at
|
|
192
|
+
// 1:1 (e.g. a 2px font bump on a caret). Crops the tight context box, upscales by
|
|
193
|
+
// an integer factor, then outlines the changes (stroke scaled to stay visible).
|
|
194
|
+
function zoomCrop(src, box, rects, factor) {
|
|
195
|
+
const crop = cropPng(src, box, box.w, box.h);
|
|
196
|
+
const scaled = scalePng(crop.png, factor);
|
|
197
|
+
const t = Math.max(2, factor);
|
|
198
|
+
for (const [rx, ry, rw, rh] of rects) {
|
|
199
|
+
strokeRect(scaled, (rx - crop.ox) * factor, (ry - crop.oy) * factor, rw * factor, rh * factor, t);
|
|
200
|
+
}
|
|
201
|
+
return scaled;
|
|
202
|
+
}
|
|
164
203
|
function readPng(file) {
|
|
165
204
|
if (!fs.existsSync(file))
|
|
166
205
|
return null;
|
|
@@ -378,9 +417,11 @@ function surfaceContext(...maps) {
|
|
|
378
417
|
const metadata = maps.find((m) => m?.metadata)?.metadata;
|
|
379
418
|
if (!metadata?.variantKey)
|
|
380
419
|
return '';
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
420
|
+
if (metadata.variantKind === 'live-state')
|
|
421
|
+
return `live state \`${metadata.variantKey}\``;
|
|
422
|
+
if (metadata.variantKind === 'popup')
|
|
423
|
+
return `popup \`${metadata.variantKey}\``;
|
|
424
|
+
return `variant \`${metadata.variantKey}\``;
|
|
384
425
|
}
|
|
385
426
|
function formatSurfaceWithContext(surface, ...maps) {
|
|
386
427
|
const context = surfaceContext(...maps);
|
|
@@ -401,7 +442,9 @@ function liveCandidateLabel(candidate) {
|
|
|
401
442
|
return `${label} (${candidate.reason})`;
|
|
402
443
|
}
|
|
403
444
|
function captureFiles(dir) {
|
|
404
|
-
return fs.existsSync(dir)
|
|
445
|
+
return fs.existsSync(dir)
|
|
446
|
+
? fs.readdirSync(dir).filter((f) => f !== 'styleproof-manifest.json' && /\.json(\.gz)?$/.test(f))
|
|
447
|
+
: [];
|
|
405
448
|
}
|
|
406
449
|
function collectLiveCandidateLabels(beforeDir, afterDir) {
|
|
407
450
|
const seen = new Set();
|
|
@@ -791,7 +834,7 @@ export function generateStyleMapReport(opts) {
|
|
|
791
834
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '',
|
|
792
835
|
// Tighter than before (was 24) so the change fills the frame — the annotation
|
|
793
836
|
// box keeps enough context legible.
|
|
794
|
-
pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600,
|
|
837
|
+
pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
|
|
795
838
|
// More, smaller crops before collapsing (was 6), so distinct changes get their
|
|
796
839
|
// own focused frame rather than one wide merged one.
|
|
797
840
|
maxCrops = 8, foldDetailsAt = 0, } = opts;
|
|
@@ -947,10 +990,37 @@ export function generateStyleMapReport(opts) {
|
|
|
947
990
|
const annotated = compositePair(annotateCrop(before, rectsA), annotateCrop(after, rectsB));
|
|
948
991
|
writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
|
|
949
992
|
images = { composite: `${stem}-composite.png`, annotated: `${stem}-annotated.png` };
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
|
|
953
|
-
|
|
993
|
+
// Name the changed element(s) so the reviewer knows where to look without
|
|
994
|
+
// expanding anything (e.g. `changed: span.caret`).
|
|
995
|
+
const changedNames = [
|
|
996
|
+
...new Set(markPaths
|
|
997
|
+
.map((p) => mapA.elements[p] ?? mapB.elements[p])
|
|
998
|
+
.filter((e) => !!e)
|
|
999
|
+
.map((e) => (e.cls ? `${e.tag}.${e.cls.split(/\s+/)[0]}` : e.tag))),
|
|
1000
|
+
].slice(0, 3);
|
|
1001
|
+
const changedLabel = changedNames.length ? ` — changed: \`${changedNames.join('`, `')}\`` : '';
|
|
1002
|
+
const ctxLabel = formatSurfaceWithContext(sd.surface, mapA, mapB);
|
|
1003
|
+
// A sub-pixel change (e.g. a 2px font bump on a caret) is invisible at 1:1,
|
|
1004
|
+
// so when the changed-element footprint is small, add a magnified crop that
|
|
1005
|
+
// makes it obvious without the reviewer hunting. Anchored on the leaf rects.
|
|
1006
|
+
const changed = unionRects([...rectsA, ...rectsB]);
|
|
1007
|
+
const maxDim = changed ? Math.max(changed.w, changed.h) : 0;
|
|
1008
|
+
let zoomFactor = 0;
|
|
1009
|
+
if (zoomBelow > 0 && changed && maxDim > 0 && maxDim <= zoomBelow) {
|
|
1010
|
+
const zBox = pad(changed, Math.max(maxDim, 16)); // ~3× the change for context
|
|
1011
|
+
zoomFactor = Math.min(8, Math.max(2, Math.round(240 / Math.max(zBox.w, zBox.h))));
|
|
1012
|
+
const zoom = compositePair(zoomCrop(pngA, zBox, rectsA, zoomFactor), zoomCrop(pngB, zBox, rectsB, zoomFactor));
|
|
1013
|
+
writePng(path.join(outDir, `${stem}-zoom.png`), zoom);
|
|
1014
|
+
images.zoom = `${stem}-zoom.png`;
|
|
1015
|
+
}
|
|
1016
|
+
// Both views shown by default: the clean before|after (the real UI) and the
|
|
1017
|
+
// highlighted twin (magenta boxes on each change) so a reviewer sees WHAT
|
|
1018
|
+
// changed and WHERE without expanding anything. Plain images (no link wrap)
|
|
1019
|
+
// so a click opens the full-resolution file.
|
|
1020
|
+
md.push('', `})`, '', `<sub>◀ before · after ▶ — ${ctxLabel}</sub>`, '', `})`, '', `<sub>🔍 magenta boxes mark each change${changedLabel}</sub>`);
|
|
1021
|
+
if (images.zoom) {
|
|
1022
|
+
md.push('', `})`, '', `<sub>🔬 magnified ${zoomFactor}× — change too small to see at 1:1${changedLabel}</sub>`);
|
|
1023
|
+
}
|
|
954
1024
|
}
|
|
955
1025
|
else if (!region) {
|
|
956
1026
|
md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
|
package/dist/runner.d.ts
CHANGED
|
@@ -40,6 +40,12 @@ export type Surface = {
|
|
|
40
40
|
* variants so reports and diagnostics can explain why the capture was split.
|
|
41
41
|
*/
|
|
42
42
|
liveStates?: SurfaceLiveState[];
|
|
43
|
+
/**
|
|
44
|
+
* Opt in to automatically opening visible click-triggered popups after the base
|
|
45
|
+
* surface is captured. Captures persistent dialogs, popovers, menus, listboxes,
|
|
46
|
+
* tooltips, and open data-state overlays as `<surface>-popup-XX`.
|
|
47
|
+
*/
|
|
48
|
+
popups?: boolean | PopupCaptureOptions;
|
|
43
49
|
};
|
|
44
50
|
export type SurfaceVariant = {
|
|
45
51
|
/** Capture key suffix, joined as `<surface.key>-<variant.key>`. */
|
|
@@ -59,6 +65,18 @@ export type SurfaceVariant = {
|
|
|
59
65
|
height?: number | ((width: number) => number);
|
|
60
66
|
};
|
|
61
67
|
export type SurfaceLiveState = SurfaceVariant;
|
|
68
|
+
export type PopupCaptureOptions = {
|
|
69
|
+
/** Enable/disable popup discovery for this surface or capture run. */
|
|
70
|
+
enabled?: boolean;
|
|
71
|
+
/** Max visible trigger controls to try per surface/width (default 20). */
|
|
72
|
+
max?: number;
|
|
73
|
+
/** CSS selector for visible controls to click. */
|
|
74
|
+
triggers?: string;
|
|
75
|
+
/** CSS selector for visible popup/overlay roots that mean a click opened state. */
|
|
76
|
+
overlays?: string;
|
|
77
|
+
/** Max ms to wait for a clicked control to reveal an overlay (default 750). */
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
};
|
|
62
80
|
export type DefineOptions = {
|
|
63
81
|
surfaces: Surface[];
|
|
64
82
|
/**
|
|
@@ -143,8 +161,15 @@ export type DefineOptions = {
|
|
|
143
161
|
* Advisory only — never gates. See `CaptureOptions.captureComponent`.
|
|
144
162
|
*/
|
|
145
163
|
captureComponent?: boolean;
|
|
164
|
+
/**
|
|
165
|
+
* Opt-in automatic popup/modal capture for every surface. Existing suites keep
|
|
166
|
+
* their exact capture set unless this is enabled.
|
|
167
|
+
*/
|
|
168
|
+
popups?: boolean | PopupCaptureOptions;
|
|
146
169
|
};
|
|
147
170
|
export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
|
|
171
|
+
type ResolvedPopupCaptureOptions = Required<PopupCaptureOptions>;
|
|
172
|
+
export declare function resolvePopupCaptureOptions(input: boolean | PopupCaptureOptions | undefined): ResolvedPopupCaptureOptions;
|
|
148
173
|
type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
|
|
149
174
|
metadata?: CaptureMetadata;
|
|
150
175
|
};
|
|
@@ -174,15 +199,14 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
|
|
|
174
199
|
export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
|
|
175
200
|
/**
|
|
176
201
|
* Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
|
|
177
|
-
* default. Lets
|
|
178
|
-
*
|
|
179
|
-
* the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
|
|
202
|
+
* default. Lets CLIs and CI redirect capture into cache/fallback dirs without
|
|
203
|
+
* editing the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
|
|
180
204
|
*/
|
|
181
205
|
export declare function resolveBaseDir(baseDir: string | undefined, env?: string | undefined): string;
|
|
182
206
|
/**
|
|
183
207
|
* Whether to save full-page screenshots: explicit `screenshots` wins, else
|
|
184
|
-
* `STYLEPROOF_SCREENSHOTS=0` turns them off
|
|
185
|
-
*
|
|
208
|
+
* `STYLEPROOF_SCREENSHOTS=0` turns them off. On by default so restored map bundles
|
|
209
|
+
* can generate reviewable reports without recapturing.
|
|
186
210
|
*/
|
|
187
211
|
export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
|
|
188
212
|
/** The capture settings every capturer shares (everything bar the surface set). */
|
|
@@ -222,6 +246,8 @@ export type CrawlOptions = CaptureConfig & {
|
|
|
222
246
|
variants?: SurfaceVariant[];
|
|
223
247
|
/** First-class live product states captured for every discovered link surface. */
|
|
224
248
|
liveStates?: SurfaceLiveState[];
|
|
249
|
+
/** Opt-in automatic popup/modal capture for every discovered link surface. */
|
|
250
|
+
popups?: boolean | PopupCaptureOptions;
|
|
225
251
|
/** Max ms to wait for the crawl root's links to render before reading them
|
|
226
252
|
* (an SPA hydrates its nav client-side). Default 15000. */
|
|
227
253
|
linkTimeout?: number;
|
package/dist/runner.js
CHANGED
|
@@ -56,6 +56,107 @@ function mergeIgnore(...groups) {
|
|
|
56
56
|
const merged = [...new Set(groups.flatMap((g) => g ?? []))];
|
|
57
57
|
return merged.length ? merged : undefined;
|
|
58
58
|
}
|
|
59
|
+
const POPUP_TRIGGER_ATTR = 'data-styleproof-popup-trigger';
|
|
60
|
+
const DEFAULT_POPUP_TRIGGERS = [
|
|
61
|
+
'button:not([disabled]):not([type="submit"]):not([type="reset"])',
|
|
62
|
+
'[role="button"]:not([aria-disabled="true"])',
|
|
63
|
+
'[aria-haspopup]',
|
|
64
|
+
'[popovertarget]',
|
|
65
|
+
'summary',
|
|
66
|
+
'a[href^="#"]',
|
|
67
|
+
].join(', ');
|
|
68
|
+
const DEFAULT_POPUP_OVERLAYS = [
|
|
69
|
+
'dialog[open]',
|
|
70
|
+
'[popover]',
|
|
71
|
+
'[role="dialog"]',
|
|
72
|
+
'[role="alertdialog"]',
|
|
73
|
+
'[role="menu"]',
|
|
74
|
+
'[role="listbox"]',
|
|
75
|
+
'[role="tooltip"]',
|
|
76
|
+
'[data-state="open"]:not(button):not(a):not(summary)',
|
|
77
|
+
].join(', ');
|
|
78
|
+
export function resolvePopupCaptureOptions(input) {
|
|
79
|
+
if (input === false || input === undefined) {
|
|
80
|
+
return {
|
|
81
|
+
enabled: false,
|
|
82
|
+
max: 20,
|
|
83
|
+
triggers: DEFAULT_POPUP_TRIGGERS,
|
|
84
|
+
overlays: DEFAULT_POPUP_OVERLAYS,
|
|
85
|
+
timeoutMs: 750,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const options = input === true ? {} : input;
|
|
89
|
+
return {
|
|
90
|
+
enabled: options.enabled ?? true,
|
|
91
|
+
max: Math.max(0, Math.floor(options.max ?? 20)),
|
|
92
|
+
triggers: options.triggers ?? DEFAULT_POPUP_TRIGGERS,
|
|
93
|
+
overlays: options.overlays ?? DEFAULT_POPUP_OVERLAYS,
|
|
94
|
+
timeoutMs: Math.max(0, Math.floor(options.timeoutMs ?? 750)),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function popupDomSnapshot(page, options) {
|
|
98
|
+
return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0 }) => {
|
|
99
|
+
const qsa = (sel) => {
|
|
100
|
+
try {
|
|
101
|
+
return [...document.querySelectorAll(sel)];
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const visible = (el) => {
|
|
108
|
+
const rect = el.getBoundingClientRect();
|
|
109
|
+
const style = getComputedStyle(el);
|
|
110
|
+
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
|
|
111
|
+
};
|
|
112
|
+
const pathOf = (el) => {
|
|
113
|
+
const parts = [];
|
|
114
|
+
let cur = el;
|
|
115
|
+
while (cur && cur !== document.documentElement) {
|
|
116
|
+
const parent = cur.parentElement;
|
|
117
|
+
const tag = cur.tagName.toLowerCase();
|
|
118
|
+
const id = cur.id ? `#${cur.id}` : '';
|
|
119
|
+
const role = cur.getAttribute('role');
|
|
120
|
+
const sameTag = parent ? [...parent.children].filter((child) => child.tagName === cur.tagName) : [cur];
|
|
121
|
+
const nth = sameTag.length > 1 ? `:nth-of-type(${sameTag.indexOf(cur) + 1})` : '';
|
|
122
|
+
parts.unshift(`${tag}${id}${role ? `[role="${role}"]` : ''}${nth}`);
|
|
123
|
+
cur = cur.parentElement;
|
|
124
|
+
}
|
|
125
|
+
return parts.join(' > ');
|
|
126
|
+
};
|
|
127
|
+
const popups = qsa(popupSelector).filter(visible);
|
|
128
|
+
const keys = popups.map(pathOf);
|
|
129
|
+
if (!triggerSelector || !attr)
|
|
130
|
+
return { keys, indexes: [] };
|
|
131
|
+
const safeTrigger = (el) => {
|
|
132
|
+
const tag = el.tagName.toLowerCase();
|
|
133
|
+
if (el.matches(':disabled, [aria-disabled="true"]'))
|
|
134
|
+
return false;
|
|
135
|
+
if (tag === 'input' || tag === 'select' || tag === 'textarea')
|
|
136
|
+
return false;
|
|
137
|
+
if (tag === 'a')
|
|
138
|
+
return el.getAttribute('href')?.startsWith('#') ?? false;
|
|
139
|
+
return true;
|
|
140
|
+
};
|
|
141
|
+
for (const el of qsa(`[${attr}]`))
|
|
142
|
+
el.removeAttribute(attr);
|
|
143
|
+
const candidates = qsa(triggerSelector).filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el));
|
|
144
|
+
candidates.slice(0, max).forEach((el, index) => el.setAttribute(attr, String(index)));
|
|
145
|
+
return { keys, indexes: candidates.slice(0, max).map((_, index) => index) };
|
|
146
|
+
}, options);
|
|
147
|
+
}
|
|
148
|
+
async function visiblePopupKeys(page, selector) {
|
|
149
|
+
return (await popupDomSnapshot(page, { popupSelector: selector })).keys;
|
|
150
|
+
}
|
|
151
|
+
async function markPopupCandidates(page, options) {
|
|
152
|
+
const snapshot = await popupDomSnapshot(page, {
|
|
153
|
+
triggerSelector: options.triggers,
|
|
154
|
+
popupSelector: options.overlays,
|
|
155
|
+
attr: POPUP_TRIGGER_ATTR,
|
|
156
|
+
max: options.max,
|
|
157
|
+
});
|
|
158
|
+
return snapshot.indexes.map((index) => ({ index }));
|
|
159
|
+
}
|
|
59
160
|
function expandOne(surface, variant, variantKind) {
|
|
60
161
|
return {
|
|
61
162
|
key: `${surface.key}-${variant.key}`,
|
|
@@ -67,6 +168,7 @@ function expandOne(surface, variant, variantKind) {
|
|
|
67
168
|
ignore: mergeIgnore(surface.ignore, variant.ignore),
|
|
68
169
|
widths: variant.widths ?? surface.widths,
|
|
69
170
|
height: variant.height ?? surface.height,
|
|
171
|
+
popups: surface.popups,
|
|
70
172
|
metadata: { surfaceKey: surface.key, variantKey: variant.key, variantKind },
|
|
71
173
|
};
|
|
72
174
|
}
|
|
@@ -152,6 +254,85 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
|
|
|
152
254
|
throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
|
|
153
255
|
}
|
|
154
256
|
}
|
|
257
|
+
async function openPopupCandidate(page, surface, width, height, options, candidate) {
|
|
258
|
+
await page.setViewportSize({ width, height });
|
|
259
|
+
await page.keyboard.press('Escape').catch(() => { });
|
|
260
|
+
await surface.go(page);
|
|
261
|
+
const before = new Set(await visiblePopupKeys(page, options.overlays));
|
|
262
|
+
const candidates = await markPopupCandidates(page, options);
|
|
263
|
+
if (!candidates.some((c) => c.index === candidate.index))
|
|
264
|
+
return undefined;
|
|
265
|
+
await page
|
|
266
|
+
.locator(`[${POPUP_TRIGGER_ATTR}="${candidate.index}"]`)
|
|
267
|
+
.first()
|
|
268
|
+
.click({ timeout: Math.max(500, options.timeoutMs), noWaitAfter: true })
|
|
269
|
+
.catch(() => undefined);
|
|
270
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
271
|
+
do {
|
|
272
|
+
const opened = (await visiblePopupKeys(page, options.overlays)).find((key) => !before.has(key));
|
|
273
|
+
if (opened)
|
|
274
|
+
return opened;
|
|
275
|
+
await page.waitForTimeout(50);
|
|
276
|
+
} while (Date.now() < deadline);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
function popupMetadata(surface, popupId) {
|
|
280
|
+
return {
|
|
281
|
+
surfaceKey: surface.metadata?.surfaceKey ?? surface.key,
|
|
282
|
+
variantKey: surface.metadata?.variantKey ? `${surface.metadata.variantKey}/${popupId}` : popupId,
|
|
283
|
+
variantKind: 'popup',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
async function captureOpenedPopupMap(page, surface, s, pending, popupId) {
|
|
287
|
+
return captureStyleMap(page, {
|
|
288
|
+
ignore: surface.ignore ?? [],
|
|
289
|
+
captureText: s.captureText,
|
|
290
|
+
captureComponent: s.captureComponent,
|
|
291
|
+
pendingRequests: pending,
|
|
292
|
+
metadata: popupMetadata(surface, popupId),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending) {
|
|
296
|
+
const againKey = await openPopupCandidate(page, surface, width, height, options, candidate);
|
|
297
|
+
if (!againKey)
|
|
298
|
+
throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen`);
|
|
299
|
+
const again = await captureOpenedPopupMap(page, surface, s, pending, popupId);
|
|
300
|
+
const drift = diffStyleMaps(first, again);
|
|
301
|
+
if (drift.length) {
|
|
302
|
+
throw new Error(selfCheckErrorMessage(`${surface.key}-${popupId}`, drift, first.volatile, first.liveCandidates));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function capturePopupCandidate(page, surface, width, height, s, options, candidate, seen) {
|
|
306
|
+
const requests = trackInflightRequests(page);
|
|
307
|
+
try {
|
|
308
|
+
const popupKey = await openPopupCandidate(page, surface, width, height, options, candidate);
|
|
309
|
+
if (!popupKey || seen.has(popupKey))
|
|
310
|
+
return;
|
|
311
|
+
seen.add(popupKey);
|
|
312
|
+
const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
|
|
313
|
+
const map = await captureOpenedPopupMap(page, surface, s, requests.pending, popupId);
|
|
314
|
+
if (s.selfCheck)
|
|
315
|
+
await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending);
|
|
316
|
+
const stem = path.join(s.baseDir, s.dir, `${surface.key}-${popupId}@${width}`);
|
|
317
|
+
saveStyleMap(`${stem}.json.gz`, map);
|
|
318
|
+
if (s.screenshots)
|
|
319
|
+
await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
requests.dispose();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async function capturePopupSurfaces(page, surface, width, height, s) {
|
|
326
|
+
const options = resolvePopupCaptureOptions(surface.popups ?? s.popups);
|
|
327
|
+
if (!options.enabled || options.max === 0)
|
|
328
|
+
return;
|
|
329
|
+
await surface.go(page);
|
|
330
|
+
const candidates = await markPopupCandidates(page, options);
|
|
331
|
+
const seen = new Set();
|
|
332
|
+
for (const candidate of candidates) {
|
|
333
|
+
await capturePopupCandidate(page, surface, width, height, s, options, candidate, seen);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
155
336
|
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
|
156
337
|
* The caller owns the test timeout (one-per-test for explicit surfaces, one budget for
|
|
157
338
|
* the whole crawl) so a multi-surface crawl can't reset its own deadline mid-loop. */
|
|
@@ -181,6 +362,7 @@ async function captureSurface(page, surface, width, s) {
|
|
|
181
362
|
// state the map describes.
|
|
182
363
|
await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
|
|
183
364
|
}
|
|
365
|
+
await capturePopupSurfaces(page, surface, width, height, s);
|
|
184
366
|
}
|
|
185
367
|
finally {
|
|
186
368
|
requests.dispose();
|
|
@@ -198,17 +380,16 @@ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCH
|
|
|
198
380
|
}
|
|
199
381
|
/**
|
|
200
382
|
* Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
|
|
201
|
-
* default. Lets
|
|
202
|
-
*
|
|
203
|
-
* the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
|
|
383
|
+
* default. Lets CLIs and CI redirect capture into cache/fallback dirs without
|
|
384
|
+
* editing the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
|
|
204
385
|
*/
|
|
205
386
|
export function resolveBaseDir(baseDir, env = process.env.STYLEPROOF_BASEDIR) {
|
|
206
387
|
return baseDir ?? env ?? '__stylemaps__';
|
|
207
388
|
}
|
|
208
389
|
/**
|
|
209
390
|
* Whether to save full-page screenshots: explicit `screenshots` wins, else
|
|
210
|
-
* `STYLEPROOF_SCREENSHOTS=0` turns them off
|
|
211
|
-
*
|
|
391
|
+
* `STYLEPROOF_SCREENSHOTS=0` turns them off. On by default so restored map bundles
|
|
392
|
+
* can generate reviewable reports without recapturing.
|
|
212
393
|
*/
|
|
213
394
|
export function resolveScreenshots(screenshots, env = process.env.STYLEPROOF_SCREENSHOTS) {
|
|
214
395
|
return screenshots ?? env !== '0';
|
|
@@ -232,6 +413,7 @@ function resolveSettings(c) {
|
|
|
232
413
|
selfCheck: c.selfCheck ?? defaultSelfCheck(replayFrom),
|
|
233
414
|
captureText: c.captureText ?? false,
|
|
234
415
|
captureComponent: c.captureComponent ?? false,
|
|
416
|
+
popups: resolvePopupCaptureOptions(c.popups),
|
|
235
417
|
};
|
|
236
418
|
}
|
|
237
419
|
/**
|
|
@@ -314,7 +496,7 @@ export function defineStyleMapCapture(options) {
|
|
|
314
496
|
* ```
|
|
315
497
|
*/
|
|
316
498
|
export function defineCrawlCapture(options) {
|
|
317
|
-
const { from, match, key, widths, height, ignore, variants, liveStates, linkTimeout = 15_000, dir } = options;
|
|
499
|
+
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir } = options;
|
|
318
500
|
const settings = resolveSettings(options);
|
|
319
501
|
test.describe('styleproof crawl-capture', () => {
|
|
320
502
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
@@ -339,6 +521,7 @@ export function defineCrawlCapture(options) {
|
|
|
339
521
|
height,
|
|
340
522
|
variants,
|
|
341
523
|
liveStates,
|
|
524
|
+
popups,
|
|
342
525
|
}));
|
|
343
526
|
// Budget the whole sweep up front: one test captures every surface, and
|
|
344
527
|
// captureSurface no longer sets its own timeout, so size it to the work found.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
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",
|
|
@@ -58,12 +58,14 @@
|
|
|
58
58
|
"typecheck": "tsc --noEmit",
|
|
59
59
|
"lint": "eslint .",
|
|
60
60
|
"lint:fix": "eslint . --fix",
|
|
61
|
-
"format": "prettier --write \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
|
|
62
|
-
"format:check": "prettier --check \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
|
|
61
|
+
"format": "prettier --write \"{src,bin,test,example,scripts}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
|
|
62
|
+
"format:check": "prettier --check \"{src,bin,test,example,scripts}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
|
|
63
63
|
"test": "npm run build && node --test test/*.test.mjs",
|
|
64
64
|
"test:watch": "node --test --watch test/*.test.mjs",
|
|
65
65
|
"test:e2e": "npm run build && playwright test",
|
|
66
66
|
"bench": "npm run build && node bench/gate-speed.mjs",
|
|
67
|
+
"demo:report": "npm run build && node scripts/demo-report.mjs",
|
|
68
|
+
"demo:check": "npm run build && node scripts/demo-report.mjs --check",
|
|
67
69
|
"init": "node ./bin/styleproof-init.mjs",
|
|
68
70
|
"prepare": "npm run build && husky",
|
|
69
71
|
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:e2e"
|
package/dist/cli-base-ref.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export interface BaseRefCaptureDirs {
|
|
2
|
-
beforeDir: string;
|
|
3
|
-
afterDir: string;
|
|
4
|
-
baseRef: string;
|
|
5
|
-
mapsDir: string;
|
|
6
|
-
tmpBase: string;
|
|
7
|
-
}
|
|
8
|
-
export declare function resolveBaseRefCaptureDirs(options: {
|
|
9
|
-
command: string;
|
|
10
|
-
baseRef: string | null;
|
|
11
|
-
mapsDir: string;
|
|
12
|
-
args: string[];
|
|
13
|
-
usage: string;
|
|
14
|
-
}): BaseRefCaptureDirs;
|
|
15
|
-
export declare function cleanupBaseRefCaptureDirs(captureDirs: BaseRefCaptureDirs | null): void;
|
package/dist/cli-base-ref.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import { inferBaseRef, materializeRef, GitRefError } from './gitref.js';
|
|
3
|
-
import { baseInferenceMessage, baseMapsMessage, missingWorkingMapsMessage } from './cli-errors.js';
|
|
4
|
-
function errorMessage(error) {
|
|
5
|
-
return error instanceof Error ? error.message : String(error);
|
|
6
|
-
}
|
|
7
|
-
function exitUsage(usage) {
|
|
8
|
-
throw new Error(usage);
|
|
9
|
-
}
|
|
10
|
-
function inferBaseRefOrExit(command) {
|
|
11
|
-
try {
|
|
12
|
-
return inferBaseRef();
|
|
13
|
-
}
|
|
14
|
-
catch (e) {
|
|
15
|
-
throw new Error(e instanceof GitRefError ? baseInferenceMessage(command, e.message) : errorMessage(e), {
|
|
16
|
-
cause: e,
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
function selectBaseRef(options) {
|
|
21
|
-
const { command, args, usage } = options;
|
|
22
|
-
if (options.baseRef) {
|
|
23
|
-
if (args.length > 1)
|
|
24
|
-
exitUsage(usage);
|
|
25
|
-
return { baseRef: options.baseRef, mapsDir: args[0] ?? options.mapsDir };
|
|
26
|
-
}
|
|
27
|
-
if (args.length === 1)
|
|
28
|
-
return { baseRef: args[0], mapsDir: options.mapsDir };
|
|
29
|
-
return { baseRef: inferBaseRefOrExit(command), mapsDir: options.mapsDir };
|
|
30
|
-
}
|
|
31
|
-
function materializeBaseRefOrExit(command, baseRef, mapsDir) {
|
|
32
|
-
try {
|
|
33
|
-
return materializeRef(baseRef, mapsDir);
|
|
34
|
-
}
|
|
35
|
-
catch (e) {
|
|
36
|
-
throw new Error(e instanceof GitRefError ? baseMapsMessage(command, e.message, baseRef, mapsDir) : errorMessage(e), {
|
|
37
|
-
cause: e,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
export function resolveBaseRefCaptureDirs(options) {
|
|
42
|
-
const { command } = options;
|
|
43
|
-
const { baseRef, mapsDir } = selectBaseRef(options);
|
|
44
|
-
if (!fs.existsSync(mapsDir)) {
|
|
45
|
-
throw new Error(missingWorkingMapsMessage(command, mapsDir));
|
|
46
|
-
}
|
|
47
|
-
const tmpBase = materializeBaseRefOrExit(command, baseRef, mapsDir);
|
|
48
|
-
return { beforeDir: tmpBase, afterDir: mapsDir, baseRef, mapsDir, tmpBase };
|
|
49
|
-
}
|
|
50
|
-
export function cleanupBaseRefCaptureDirs(captureDirs) {
|
|
51
|
-
if (captureDirs)
|
|
52
|
-
fs.rmSync(captureDirs.tmpBase, { recursive: true, force: true });
|
|
53
|
-
}
|