styleproof 2.0.0 → 2.2.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 +78 -0
- package/README.md +101 -22
- package/bin/styleproof-init.mjs +132 -45
- package/dist/capture.d.ts +62 -5
- package/dist/capture.js +156 -37
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.js +35 -0
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +30 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/report.js +54 -18
- package/dist/routes.d.ts +29 -0
- package/dist/routes.js +84 -0
- package/dist/runner.d.ts +47 -6
- package/dist/runner.js +59 -16
- package/package.json +2 -2
package/dist/capture.js
CHANGED
|
@@ -30,15 +30,12 @@ const FRAMEWORK_IGNORE = [
|
|
|
30
30
|
export function isUnder(path, roots) {
|
|
31
31
|
return roots.some((r) => path === r || path.startsWith(r + ' > '));
|
|
32
32
|
}
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
|
|
40
|
-
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
41
|
-
const pathOf = (el) => {
|
|
33
|
+
// Defines the structural-path helper on `window` once so the two functions
|
|
34
|
+
// serialized into the page (capturePage, markInteractiveElements) share ONE
|
|
35
|
+
// implementation — page.evaluate can't reference a module-scope helper, so the
|
|
36
|
+
// alternative is an identical copy in each. Injected at the top of captureStyleMap.
|
|
37
|
+
function injectPathOf() {
|
|
38
|
+
window.__spPathOf = (el) => {
|
|
42
39
|
if (el === document.documentElement)
|
|
43
40
|
return 'html';
|
|
44
41
|
if (el === document.body)
|
|
@@ -54,6 +51,16 @@ function capturePage({ ignore, motionOnly, captureText }) {
|
|
|
54
51
|
}
|
|
55
52
|
return 'body > ' + parts.join(' > ');
|
|
56
53
|
};
|
|
54
|
+
}
|
|
55
|
+
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
56
|
+
// Pre-existing, grandfathered in the health baseline; the content layer adds
|
|
57
|
+
// one small captureText block, not new structure.
|
|
58
|
+
// fallow-ignore-next-line complexity
|
|
59
|
+
function capturePage({ ignore, motionOnly, captureText, captureComponent }) {
|
|
60
|
+
const MOTION = /^(transition|animation)/;
|
|
61
|
+
const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
|
|
62
|
+
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
63
|
+
const pathOf = window.__spPathOf;
|
|
57
64
|
// Per-tag (and per-tag-per-pseudo) UA defaults from a stylesheet-free iframe,
|
|
58
65
|
// used to prune the maps. A pseudo-element's UA defaults are NOT the host
|
|
59
66
|
// element's defaults, so they are measured and cached separately under a
|
|
@@ -96,6 +103,53 @@ function capturePage({ ignore, motionOnly, captureText }) {
|
|
|
96
103
|
}
|
|
97
104
|
return o;
|
|
98
105
|
};
|
|
106
|
+
// Display name from a fiber `type`: function/class, or a forwardRef/memo wrapper
|
|
107
|
+
// object; '' for host fibers (string type), which keeps the walk going upward.
|
|
108
|
+
const nameOfType = (t) => {
|
|
109
|
+
if (typeof t === 'function') {
|
|
110
|
+
const f = t;
|
|
111
|
+
return f.displayName || f.name || '';
|
|
112
|
+
}
|
|
113
|
+
if (t && typeof t === 'object') {
|
|
114
|
+
const w = t;
|
|
115
|
+
const inner = w.render || w.type;
|
|
116
|
+
return w.displayName || inner?.displayName || inner?.name || '';
|
|
117
|
+
}
|
|
118
|
+
return '';
|
|
119
|
+
};
|
|
120
|
+
// Keep only primitive props (drop children/className/style/handlers/objects),
|
|
121
|
+
// capped — advisory, so never anything that could be huge or non-serializable.
|
|
122
|
+
const sanitizeProps = (mp) => {
|
|
123
|
+
const props = {};
|
|
124
|
+
for (const k of Object.keys(mp)) {
|
|
125
|
+
if (k === 'children' || k === 'className' || k === 'style')
|
|
126
|
+
continue;
|
|
127
|
+
const ty = typeof mp[k];
|
|
128
|
+
if (ty === 'string' || ty === 'number' || ty === 'boolean')
|
|
129
|
+
props[k] = String(mp[k]).slice(0, 80);
|
|
130
|
+
}
|
|
131
|
+
return props;
|
|
132
|
+
};
|
|
133
|
+
const reactComponent = (el) => {
|
|
134
|
+
const fiberKey = Object.keys(el).find((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
|
|
135
|
+
if (!fiberKey)
|
|
136
|
+
return undefined;
|
|
137
|
+
let fiber = el[fiberKey] ?? null;
|
|
138
|
+
for (let hops = 0; fiber && hops < 30; fiber = fiber.return, hops++) {
|
|
139
|
+
const name = nameOfType(fiber.type);
|
|
140
|
+
if (!name || name === 'Symbol(react.fragment)')
|
|
141
|
+
continue;
|
|
142
|
+
const out = { name };
|
|
143
|
+
const mp = fiber.memoizedProps;
|
|
144
|
+
if (mp && typeof mp === 'object') {
|
|
145
|
+
const props = sanitizeProps(mp);
|
|
146
|
+
if (Object.keys(props).length)
|
|
147
|
+
out.props = props;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
};
|
|
99
153
|
const elements = {};
|
|
100
154
|
const all = [document.documentElement, document.body, ...document.querySelectorAll('body *')];
|
|
101
155
|
// Surface untraversed shadow roots and same-origin iframes so a refactor
|
|
@@ -147,6 +201,16 @@ function capturePage({ ignore, motionOnly, captureText }) {
|
|
|
147
201
|
if (t)
|
|
148
202
|
entry.text = t;
|
|
149
203
|
}
|
|
204
|
+
if (captureComponent) {
|
|
205
|
+
try {
|
|
206
|
+
const comp = reactComponent(el);
|
|
207
|
+
if (comp)
|
|
208
|
+
entry.component = comp;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// non-React node or inaccessible fiber — component stays absent
|
|
212
|
+
}
|
|
213
|
+
}
|
|
150
214
|
}
|
|
151
215
|
for (const ps of PSEUDOS) {
|
|
152
216
|
if (ps === '::marker' && getComputedStyle(el).display !== 'list-item')
|
|
@@ -233,22 +297,7 @@ const STATE_ID_ATTR = 'data-styleproof-state-id';
|
|
|
233
297
|
* but different ordering, which produces phantom forced-state diffs.
|
|
234
298
|
*/
|
|
235
299
|
function markInteractiveElements({ selector, skipSel, attr }) {
|
|
236
|
-
const pathOf =
|
|
237
|
-
if (el === document.documentElement)
|
|
238
|
-
return 'html';
|
|
239
|
-
if (el === document.body)
|
|
240
|
-
return 'body';
|
|
241
|
-
const parts = [];
|
|
242
|
-
let n = el;
|
|
243
|
-
while (n && n !== document.body) {
|
|
244
|
-
const parent = n.parentElement;
|
|
245
|
-
if (!parent)
|
|
246
|
-
break;
|
|
247
|
-
parts.unshift(`${n.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, n) + 1})`);
|
|
248
|
-
n = parent;
|
|
249
|
-
}
|
|
250
|
-
return 'body > ' + parts.join(' > ');
|
|
251
|
-
};
|
|
300
|
+
const pathOf = window.__spPathOf;
|
|
252
301
|
let i = 0;
|
|
253
302
|
return [...document.querySelectorAll(selector)].flatMap((el) => {
|
|
254
303
|
if (skipSel && el.matches(skipSel))
|
|
@@ -322,7 +371,7 @@ function changedElementPaths(a, b) {
|
|
|
322
371
|
* Reuses `capturePage` (motion already frozen by the caller), so only
|
|
323
372
|
* content/layout churn — not an animation frame — keeps it from settling.
|
|
324
373
|
*/
|
|
325
|
-
async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText) {
|
|
374
|
+
async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText, pending) {
|
|
326
375
|
// captureText is threaded in so text churn participates in settle detection:
|
|
327
376
|
// a clock/ticker whose text changes (with no style change) keeps the map from
|
|
328
377
|
// settling and is excluded as a live region, exactly as style churn already is.
|
|
@@ -340,8 +389,16 @@ async function stabilizePage(page, ignore, interval, quietFor, timeout, captureT
|
|
|
340
389
|
lastChangeAt = Date.now();
|
|
341
390
|
recent = changed;
|
|
342
391
|
}
|
|
392
|
+
else if (pending() > 0) {
|
|
393
|
+
// The DOM is momentarily quiet, but data requests are still in flight — this
|
|
394
|
+
// is the lull BEFORE the response paints, not a settled state. Hold the quiet
|
|
395
|
+
// window so we wait for the content to ARRIVE (no live-region path to record;
|
|
396
|
+
// network activity isn't a mutating element). Long-lived streams are excluded
|
|
397
|
+
// by the caller, so this can't hang on an SSE that never finishes.
|
|
398
|
+
lastChangeAt = Date.now();
|
|
399
|
+
}
|
|
343
400
|
else if (Date.now() - lastChangeAt >= quietFor) {
|
|
344
|
-
return []; // unchanged for the full quiet window → settled
|
|
401
|
+
return []; // DOM unchanged AND network idle for the full quiet window → settled
|
|
345
402
|
}
|
|
346
403
|
}
|
|
347
404
|
return recent; // never went quiet for quietFor within budget → still-moving paths are live
|
|
@@ -374,19 +431,75 @@ function capturePageTokens() {
|
|
|
374
431
|
probe.remove();
|
|
375
432
|
return tokens;
|
|
376
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Track in-flight DATA requests on `page` so the network-aware settle can wait for
|
|
436
|
+
* late-loading content to ARRIVE, not just for the DOM to go briefly quiet (the lull
|
|
437
|
+
* before a response paints). Long-lived streams (EventSource/WebSocket) never finish,
|
|
438
|
+
* so they're excluded — their painted state is handled by the live-region pass.
|
|
439
|
+
*
|
|
440
|
+
* Attach BEFORE navigation to count the page's OWN load fetches: a request already in
|
|
441
|
+
* flight when you call `captureStyleMap` fired its `request` event before any listener
|
|
442
|
+
* attached there, so only a tracker armed earlier (the runner does this before `go()`)
|
|
443
|
+
* can see it.
|
|
444
|
+
*/
|
|
445
|
+
export function trackInflightRequests(page) {
|
|
446
|
+
const inflight = new Set();
|
|
447
|
+
const isStream = (r) => {
|
|
448
|
+
const t = r.resourceType();
|
|
449
|
+
return t === 'eventsource' || t === 'websocket';
|
|
450
|
+
};
|
|
451
|
+
const onStart = (r) => {
|
|
452
|
+
if (!isStream(r))
|
|
453
|
+
inflight.add(r);
|
|
454
|
+
};
|
|
455
|
+
const onEnd = (r) => {
|
|
456
|
+
inflight.delete(r);
|
|
457
|
+
};
|
|
458
|
+
page.on('request', onStart);
|
|
459
|
+
page.on('requestfinished', onEnd);
|
|
460
|
+
page.on('requestfailed', onEnd);
|
|
461
|
+
return {
|
|
462
|
+
pending: () => inflight.size,
|
|
463
|
+
dispose: () => {
|
|
464
|
+
page.off('request', onStart);
|
|
465
|
+
page.off('requestfinished', onEnd);
|
|
466
|
+
page.off('requestfailed', onEnd);
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
377
470
|
/** Settle the page and return the paths of live regions to exclude. */
|
|
378
|
-
async function detectVolatile(page, ignore, stabilize, captureText) {
|
|
471
|
+
async function detectVolatile(page, ignore, stabilize, captureText, externalPending) {
|
|
379
472
|
if (stabilize === false)
|
|
380
473
|
return [];
|
|
381
474
|
const opt = typeof stabilize === 'object' ? stabilize : {};
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
475
|
+
const waitForRequests = opt.waitForRequests ?? true;
|
|
476
|
+
// Prefer the runner's pre-navigation tracker (it counts the page's load fetches);
|
|
477
|
+
// otherwise arm one here as a fallback for requests that fire after this call.
|
|
478
|
+
let pending = () => 0;
|
|
479
|
+
let dispose = () => { };
|
|
480
|
+
if (waitForRequests) {
|
|
481
|
+
if (externalPending) {
|
|
482
|
+
pending = externalPending;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
const t = trackInflightRequests(page);
|
|
486
|
+
pending = t.pending;
|
|
487
|
+
dispose = t.dispose;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000, captureText, pending);
|
|
492
|
+
if (volatile.length) {
|
|
493
|
+
// eslint-disable-next-line no-console
|
|
494
|
+
console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
|
|
495
|
+
'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
|
|
496
|
+
'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
|
|
497
|
+
}
|
|
498
|
+
return volatile;
|
|
499
|
+
}
|
|
500
|
+
finally {
|
|
501
|
+
dispose();
|
|
388
502
|
}
|
|
389
|
-
return volatile;
|
|
390
503
|
}
|
|
391
504
|
/** Drop live regions (and their subtrees) from the base capture, in Node so the
|
|
392
505
|
* serialized capturePage stays a pure snapshot. */
|
|
@@ -433,6 +546,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
433
546
|
const maxInteractive = options.maxInteractive ?? 800;
|
|
434
547
|
const stabilize = options.stabilize ?? true;
|
|
435
548
|
const captureText = options.captureText ?? false;
|
|
549
|
+
const captureComponent = options.captureComponent ?? false;
|
|
436
550
|
// Neutralise real focus the same way FREEZE_CSS neutralises motion: blur
|
|
437
551
|
// whatever element holds focus so every read below is the no-interaction
|
|
438
552
|
// resting state. Real :focus is nondeterministic across runs (autofocus, late
|
|
@@ -443,6 +557,11 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
443
557
|
// deterministic" failure). Interaction states are certified deterministically
|
|
444
558
|
// via CDP forcePseudoState below, never via whatever happened to be focused.
|
|
445
559
|
await page.evaluate(() => document.activeElement?.blur?.());
|
|
560
|
+
// Inject the structural-path helper once so both capturePage and
|
|
561
|
+
// markInteractiveElements (each serialized into the page by page.evaluate, which
|
|
562
|
+
// can't share a module-scope function) call ONE definition — no duplicated source.
|
|
563
|
+
// It persists on `window` for every evaluate below (no navigation between them).
|
|
564
|
+
await page.evaluate(injectPathOf);
|
|
446
565
|
// Motion longhands first (FREEZE_CSS would null them), then everything else.
|
|
447
566
|
// Motion never carries text — it's a settled end state of declared motion.
|
|
448
567
|
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
|
|
@@ -451,8 +570,8 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
451
570
|
// the same loaded state, and collect any region still changing on its own
|
|
452
571
|
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
453
572
|
// real content/layout churn lands here.
|
|
454
|
-
const volatile = await detectVolatile(page, ignore, stabilize, captureText);
|
|
455
|
-
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText });
|
|
573
|
+
const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
|
|
574
|
+
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
|
|
456
575
|
dropVolatile(base.elements, volatile);
|
|
457
576
|
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
|
458
577
|
mergeMotion(base.elements, motion.elements);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage guard for the surface list.
|
|
3
|
+
*
|
|
4
|
+
* StyleProof captures exactly the surfaces a spec declares, and the diff matches
|
|
5
|
+
* surfaces by key — so a route nobody added to `surfaces` is invisible to the
|
|
6
|
+
* gate: the change it introduces has no baseline capture AND no head capture, so
|
|
7
|
+
* it never appears in any diff. The gate goes green having never looked at it.
|
|
8
|
+
* This is the one failure StyleProof can't catch from the captures alone, because
|
|
9
|
+
* it's about a capture that was never taken.
|
|
10
|
+
*
|
|
11
|
+
* `expected` closes the hole: a spec declares its full route/surface universe
|
|
12
|
+
* (e.g. an app's view registry), and the guard fails when that universe drifts
|
|
13
|
+
* from what's actually captured — turning a silent coverage hole into a red test,
|
|
14
|
+
* in the app's own suite, the moment the route is added.
|
|
15
|
+
*/
|
|
16
|
+
export type CoverageGaps = {
|
|
17
|
+
/** Expected surfaces that are neither captured nor explicitly excluded. */
|
|
18
|
+
uncovered: string[];
|
|
19
|
+
/** `exclude` entries absent from `expected` — a renamed or removed route whose
|
|
20
|
+
* opt-out has gone stale (the same drift, in reverse). */
|
|
21
|
+
staleExclusions: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Compare the captured surface keys against a declared `expected` universe.
|
|
25
|
+
*
|
|
26
|
+
* A surface is covered if it's captured OR listed in `exclude` (a deliberate,
|
|
27
|
+
* documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
|
|
28
|
+
* allowed: one route legitimately has several captured states (`landing`,
|
|
29
|
+
* `landing-nav-open`), and only the routes themselves form the universe.
|
|
30
|
+
*
|
|
31
|
+
* Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
|
|
32
|
+
* it in a Playwright test that runs in the normal suite (not gated on a capture
|
|
33
|
+
* dir), and it's exported so a consumer can assert coverage however it likes.
|
|
34
|
+
*/
|
|
35
|
+
export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
|
package/dist/coverage.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage guard for the surface list.
|
|
3
|
+
*
|
|
4
|
+
* StyleProof captures exactly the surfaces a spec declares, and the diff matches
|
|
5
|
+
* surfaces by key — so a route nobody added to `surfaces` is invisible to the
|
|
6
|
+
* gate: the change it introduces has no baseline capture AND no head capture, so
|
|
7
|
+
* it never appears in any diff. The gate goes green having never looked at it.
|
|
8
|
+
* This is the one failure StyleProof can't catch from the captures alone, because
|
|
9
|
+
* it's about a capture that was never taken.
|
|
10
|
+
*
|
|
11
|
+
* `expected` closes the hole: a spec declares its full route/surface universe
|
|
12
|
+
* (e.g. an app's view registry), and the guard fails when that universe drifts
|
|
13
|
+
* from what's actually captured — turning a silent coverage hole into a red test,
|
|
14
|
+
* in the app's own suite, the moment the route is added.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Compare the captured surface keys against a declared `expected` universe.
|
|
18
|
+
*
|
|
19
|
+
* A surface is covered if it's captured OR listed in `exclude` (a deliberate,
|
|
20
|
+
* documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
|
|
21
|
+
* allowed: one route legitimately has several captured states (`landing`,
|
|
22
|
+
* `landing-nav-open`), and only the routes themselves form the universe.
|
|
23
|
+
*
|
|
24
|
+
* Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
|
|
25
|
+
* it in a Playwright test that runs in the normal suite (not gated on a capture
|
|
26
|
+
* dir), and it's exported so a consumer can assert coverage however it likes.
|
|
27
|
+
*/
|
|
28
|
+
export function coverageGaps(capturedKeys, expected, exclude = {}) {
|
|
29
|
+
const captured = new Set(capturedKeys);
|
|
30
|
+
const expectedList = [...expected];
|
|
31
|
+
const expectedSet = new Set(expectedList);
|
|
32
|
+
const uncovered = expectedList.filter((k) => !captured.has(k) && !(k in exclude));
|
|
33
|
+
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
|
|
34
|
+
return { uncovered, staleExclusions };
|
|
35
|
+
}
|
package/dist/diff.d.ts
CHANGED
package/dist/diff.js
CHANGED
|
@@ -35,11 +35,39 @@ export function diffStyleMaps(a, b) {
|
|
|
35
35
|
const ea = a.elements[p];
|
|
36
36
|
const eb = b.elements[p];
|
|
37
37
|
if (!ea || !eb) {
|
|
38
|
-
|
|
38
|
+
const present = (ea ?? eb);
|
|
39
|
+
findings.push({
|
|
40
|
+
kind: 'dom',
|
|
41
|
+
path: p,
|
|
42
|
+
cls: present.cls,
|
|
43
|
+
change: !ea ? 'added' : 'removed',
|
|
44
|
+
...(!ea && eb.component ? { component: eb.component } : {}),
|
|
45
|
+
});
|
|
46
|
+
// An added element has no "before", so the style loop below is skipped —
|
|
47
|
+
// surface its full resting computed style (+ pseudos) as (unset)→value so a
|
|
48
|
+
// new element's styling is reviewable, not just its interaction-state
|
|
49
|
+
// deltas. Removed elements get none (there is no "after" to show).
|
|
50
|
+
if (!ea && eb) {
|
|
51
|
+
const defsB = b.defaults[eb.tag] ?? {};
|
|
52
|
+
for (const pseudo of [null, ...Object.keys(eb.pseudo ?? {})]) {
|
|
53
|
+
const propsB = pseudo ? (eb.pseudo?.[pseudo] ?? {}) : eb.style;
|
|
54
|
+
const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
|
|
55
|
+
const props = diffProps({}, propsB, {}, pdefsB, '(unset)', '(unset)');
|
|
56
|
+
if (props.length)
|
|
57
|
+
findings.push({ kind: 'style', path: p, cls: eb.cls, pseudo, props });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
39
60
|
continue;
|
|
40
61
|
}
|
|
41
62
|
if (ea.tag !== eb.tag) {
|
|
42
|
-
findings.push({
|
|
63
|
+
findings.push({
|
|
64
|
+
kind: 'dom',
|
|
65
|
+
path: p,
|
|
66
|
+
cls: ea.cls,
|
|
67
|
+
change: 'retagged',
|
|
68
|
+
detail: `<${ea.tag}> → <${eb.tag}>`,
|
|
69
|
+
...(eb.component ? { component: eb.component } : {}),
|
|
70
|
+
});
|
|
43
71
|
continue;
|
|
44
72
|
}
|
|
45
73
|
const defsA = a.defaults[ea.tag] ?? {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
|
|
3
3
|
export { defineStyleMapCapture } from './runner.js';
|
|
4
4
|
export type { Surface, DefineOptions } from './runner.js';
|
|
5
|
+
export { coverageGaps } from './coverage.js';
|
|
6
|
+
export type { CoverageGaps } from './coverage.js';
|
|
7
|
+
export { discoverNextRoutes } from './routes.js';
|
|
8
|
+
export type { DiscoveredRoute } from './routes.js';
|
|
5
9
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
6
10
|
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
7
11
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export { defineStyleMapCapture } from './runner.js';
|
|
3
|
+
export { coverageGaps } from './coverage.js';
|
|
4
|
+
export { discoverNextRoutes } from './routes.js';
|
|
3
5
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
4
6
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/report.js
CHANGED
|
@@ -432,12 +432,51 @@ function beforeAfterTable(rows) {
|
|
|
432
432
|
...rows.map((r) => `| \`${r.prop}\` | ${cell(r.before)} | ${cell(r.after)} |`),
|
|
433
433
|
];
|
|
434
434
|
}
|
|
435
|
+
// A brand-new element has no meaningful "before", so its resting style renders
|
|
436
|
+
// value-only (the After column), mirroring the added-element interaction-states table.
|
|
437
|
+
function valueTable(rows) {
|
|
438
|
+
return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.after)} |`)];
|
|
439
|
+
}
|
|
440
|
+
/** `Button (variant=primary, size=sm)` — the React component + sanitized props
|
|
441
|
+
* the element captured (advisory; present only with captureComponent). */
|
|
442
|
+
function renderComponent(c) {
|
|
443
|
+
const entries = Object.entries(c.props ?? {});
|
|
444
|
+
const props = entries.length ? ` (${entries.map(([k, v]) => `${k}=${v}`).join(', ')})` : '';
|
|
445
|
+
return `\`${c.name}\`${props}`;
|
|
446
|
+
}
|
|
435
447
|
/** One element's heading + body lines (no leading blank, no ×N suffix). */
|
|
448
|
+
// Base/pseudo style rows. Added elements render value-only (no meaningful before).
|
|
449
|
+
function styleSection(styles, added) {
|
|
450
|
+
const out = [];
|
|
451
|
+
for (const s of styles) {
|
|
452
|
+
const rows = summarizeProps(s.props);
|
|
453
|
+
if (rows.length)
|
|
454
|
+
out.push('', s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:', '', ...(added ? valueTable(rows) : beforeAfterTable(rows)));
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
// Forced :hover/:focus/:active rows. Added: value-only; changed: before → after.
|
|
459
|
+
function statesSection(states, added) {
|
|
460
|
+
const rows = [];
|
|
461
|
+
for (const st of states)
|
|
462
|
+
for (const c of summarizeProps(st.props))
|
|
463
|
+
rows.push(added
|
|
464
|
+
? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
|
|
465
|
+
: `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
|
|
466
|
+
if (!rows.length)
|
|
467
|
+
return [];
|
|
468
|
+
return [
|
|
469
|
+
'',
|
|
470
|
+
added ? 'Interactive states:' : 'Interactive-state changes:',
|
|
471
|
+
'',
|
|
472
|
+
added ? '| State | Property | Value |' : '| State | Property | Before → After |',
|
|
473
|
+
'| --- | --- | --- |',
|
|
474
|
+
...rows,
|
|
475
|
+
];
|
|
476
|
+
}
|
|
436
477
|
function renderOneElement(group) {
|
|
437
478
|
const label = prettyLabel(group[0].path, group[0].cls);
|
|
438
479
|
const dom = group.find((f) => f.kind === 'dom');
|
|
439
|
-
const styles = group.filter((f) => f.kind === 'style');
|
|
440
|
-
const states = group.filter((f) => f.kind === 'state');
|
|
441
480
|
if (dom?.change === 'removed')
|
|
442
481
|
return { head: `**Removed** \`${label}\``, body: [] };
|
|
443
482
|
const added = dom?.change === 'added';
|
|
@@ -447,21 +486,12 @@ function renderOneElement(group) {
|
|
|
447
486
|
? `**Retagged** \`${label}\` ${dom.detail ?? ''}`
|
|
448
487
|
: `**\`${label}\`**`;
|
|
449
488
|
const body = [];
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const rows = [];
|
|
457
|
-
for (const st of states)
|
|
458
|
-
for (const c of summarizeProps(st.props))
|
|
459
|
-
rows.push(added
|
|
460
|
-
? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
|
|
461
|
-
: `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
|
|
462
|
-
if (rows.length)
|
|
463
|
-
body.push('', added ? 'Interactive states:' : 'Interactive-state changes:', '', added ? '| State | Property | Value |' : '| State | Property | Before → After |', '| --- | --- | --- |', ...rows);
|
|
464
|
-
}
|
|
489
|
+
// React component that rendered the element (added/retagged carry it on the dom
|
|
490
|
+
// finding) — surfaced first so a reviewer sees `Button (variant=primary)`.
|
|
491
|
+
if (dom?.component)
|
|
492
|
+
body.push('', `React component: ${renderComponent(dom.component)}`);
|
|
493
|
+
body.push(...styleSection(group.filter((f) => f.kind === 'style'), added));
|
|
494
|
+
body.push(...statesSection(group.filter((f) => f.kind === 'state'), added));
|
|
465
495
|
// Existing element with nothing left to show (all derived) → skip; an
|
|
466
496
|
// added/removed/retagged element always renders its heading.
|
|
467
497
|
if (!dom && !body.length)
|
|
@@ -584,6 +614,10 @@ function cleanFindings(findings) {
|
|
|
584
614
|
for (const group of groupByPath(findings)) {
|
|
585
615
|
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
586
616
|
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
617
|
+
// For an ADDED element the base style is a full (unset)→value snapshot, not a
|
|
618
|
+
// delta — so a forced-state value is never an "echo" of a base *change*; keep
|
|
619
|
+
// every state row (suppressing them would drop a real :hover/:focus value).
|
|
620
|
+
const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
|
|
587
621
|
for (const f of group) {
|
|
588
622
|
if (f.kind === 'dom') {
|
|
589
623
|
out.push(f);
|
|
@@ -591,7 +625,9 @@ function cleanFindings(findings) {
|
|
|
591
625
|
}
|
|
592
626
|
const props = f.kind === 'style'
|
|
593
627
|
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
594
|
-
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
628
|
+
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
629
|
+
(isAdded || !baseChanged.has(p.prop)) &&
|
|
630
|
+
!(isNonValue(p.before) && isNonValue(p.after)));
|
|
595
631
|
if (props.length)
|
|
596
632
|
out.push({ ...f, props });
|
|
597
633
|
}
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort route discovery for Next.js projects, so the coverage guard works
|
|
3
|
+
* out of the box: a generated spec calls this at run time, so `expected` reflects
|
|
4
|
+
* the app's *current* routes — a newly added page appears automatically, and the
|
|
5
|
+
* guard fails until it has a surface. No static list to keep in sync (that drift
|
|
6
|
+
* is the whole bug the guard exists to prevent).
|
|
7
|
+
*
|
|
8
|
+
* Covers the App Router (`app/`, `src/app/` — directories with a `page.*`) and the
|
|
9
|
+
* Pages Router (`pages/`, `src/pages/` — page files, minus `_app`/`_document`/`api`).
|
|
10
|
+
* Route groups `(group)` and parallel slots `@slot` are stripped; `[param]` /
|
|
11
|
+
* `[...catchall]` segments mark a route dynamic. It reads the filesystem only —
|
|
12
|
+
* no framework internals — so it's a heuristic, not a router; edit the generated
|
|
13
|
+
* spec if your routing does something exotic.
|
|
14
|
+
*/
|
|
15
|
+
export type DiscoveredRoute = {
|
|
16
|
+
/** Filename-safe surface key (`/` → `index`, `/blog/[slug]` → `blog-slug`). */
|
|
17
|
+
key: string;
|
|
18
|
+
/** URL path to navigate (`/`, `/about`, `/blog/[slug]`). */
|
|
19
|
+
path: string;
|
|
20
|
+
/** True when a segment is a `[param]` / `[...catchall]` — can't be navigated as-is. */
|
|
21
|
+
dynamic: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Discover a Next.js project's routes under `cwd` (default `process.cwd()`).
|
|
25
|
+
* Returns one entry per route, deduped by path (App Router wins a tie) and sorted.
|
|
26
|
+
* Empty array when no `app/` or `pages/` dir is found — the caller decides whether
|
|
27
|
+
* that means "not a Next project".
|
|
28
|
+
*/
|
|
29
|
+
export declare function discoverNextRoutes(cwd?: string): DiscoveredRoute[];
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const APP_PAGE_RE = /^page\.(?:js|jsx|ts|tsx|mdx)$/;
|
|
4
|
+
const PAGES_EXT_RE = /\.(?:js|jsx|ts|tsx)$/;
|
|
5
|
+
const isGroupOrSlot = (seg) => /^\(.*\)$/.test(seg) || seg.startsWith('@');
|
|
6
|
+
const isDynamicSeg = (seg) => /\[.*\]/.test(seg);
|
|
7
|
+
/** Filename-safe, readable key from a route path. */
|
|
8
|
+
function toKey(routePath) {
|
|
9
|
+
if (routePath === '/')
|
|
10
|
+
return 'index';
|
|
11
|
+
const k = routePath
|
|
12
|
+
.replace(/^\//, '')
|
|
13
|
+
.replace(/\[\[?\.\.\.([^\]]+)\]\]?/g, 'all-$1') // [...x] / [[...x]] → all-x
|
|
14
|
+
.replace(/\[([^\]]+)\]/g, '$1') // [x] → x
|
|
15
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
16
|
+
.replace(/^-+|-+$/g, '')
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
return k || 'index';
|
|
19
|
+
}
|
|
20
|
+
function makeRoute(segs) {
|
|
21
|
+
const routePath = segs.length ? '/' + segs.join('/') : '/';
|
|
22
|
+
return { key: toKey(routePath), path: routePath, dynamic: segs.some(isDynamicSeg) };
|
|
23
|
+
}
|
|
24
|
+
function readDir(dir) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return []; // missing/unreadable dir → no routes, never throw into a spec
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** App Router: a directory holding a `page.*` is a route; nest by sub-directory. */
|
|
33
|
+
function appRoutes(appDir) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const walk = (dir, segs) => {
|
|
36
|
+
const entries = readDir(dir);
|
|
37
|
+
if (entries.some((e) => e.isFile() && APP_PAGE_RE.test(e.name))) {
|
|
38
|
+
out.push(makeRoute(segs.filter((s) => !isGroupOrSlot(s))));
|
|
39
|
+
}
|
|
40
|
+
for (const e of entries)
|
|
41
|
+
if (e.isDirectory())
|
|
42
|
+
walk(path.join(dir, e.name), [...segs, e.name]);
|
|
43
|
+
};
|
|
44
|
+
walk(appDir, []);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Pages Router: each page file is a route; `index` collapses to its directory. */
|
|
48
|
+
function pagesRoutes(pagesDir) {
|
|
49
|
+
const out = [];
|
|
50
|
+
const walk = (dir, segs) => {
|
|
51
|
+
for (const e of readDir(dir)) {
|
|
52
|
+
if (e.isDirectory()) {
|
|
53
|
+
if (segs.length === 0 && e.name === 'api')
|
|
54
|
+
continue; // API routes aren't pages
|
|
55
|
+
walk(path.join(dir, e.name), [...segs, e.name]);
|
|
56
|
+
}
|
|
57
|
+
else if (e.isFile() && PAGES_EXT_RE.test(e.name)) {
|
|
58
|
+
const base = e.name.replace(PAGES_EXT_RE, '');
|
|
59
|
+
if (base.startsWith('_'))
|
|
60
|
+
continue; // _app, _document, _error
|
|
61
|
+
out.push(makeRoute(base === 'index' ? segs : [...segs, base]));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(pagesDir, []);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const firstExisting = (cwd, candidates) => candidates.map((d) => path.join(cwd, d)).find((p) => fs.existsSync(p));
|
|
69
|
+
/**
|
|
70
|
+
* Discover a Next.js project's routes under `cwd` (default `process.cwd()`).
|
|
71
|
+
* Returns one entry per route, deduped by path (App Router wins a tie) and sorted.
|
|
72
|
+
* Empty array when no `app/` or `pages/` dir is found — the caller decides whether
|
|
73
|
+
* that means "not a Next project".
|
|
74
|
+
*/
|
|
75
|
+
export function discoverNextRoutes(cwd = process.cwd()) {
|
|
76
|
+
const appDir = firstExisting(cwd, ['app', 'src/app']);
|
|
77
|
+
const pagesDir = firstExisting(cwd, ['pages', 'src/pages']);
|
|
78
|
+
const found = [...(appDir ? appRoutes(appDir) : []), ...(pagesDir ? pagesRoutes(pagesDir) : [])];
|
|
79
|
+
const byPath = new Map();
|
|
80
|
+
for (const r of found)
|
|
81
|
+
if (!byPath.has(r.path))
|
|
82
|
+
byPath.set(r.path, r);
|
|
83
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
84
|
+
}
|