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/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
- // Serialized into the browser by page.evaluate; cannot call module helpers.
34
- // Pre-existing, grandfathered in the health baseline; the content layer adds
35
- // one small captureText block, not new structure.
36
- // fallow-ignore-next-line complexity
37
- function capturePage({ ignore, motionOnly, captureText }) {
38
- const MOTION = /^(transition|animation)/;
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 = (el) => {
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 volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000, captureText);
383
- if (volatile.length) {
384
- // eslint-disable-next-line no-console
385
- console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
386
- 'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
387
- 'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
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;
@@ -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
@@ -15,6 +15,10 @@ export type Finding = {
15
15
  cls: string;
16
16
  change: 'added' | 'removed' | 'retagged';
17
17
  detail?: string;
18
+ component?: {
19
+ name: string;
20
+ props?: Record<string, string>;
21
+ };
18
22
  } | {
19
23
  kind: 'style';
20
24
  path: string;
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
- findings.push({ kind: 'dom', path: p, cls: (ea ?? eb).cls, change: !ea ? 'added' : 'removed' });
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({ kind: 'dom', path: p, cls: ea.cls, change: 'retagged', detail: `<${ea.tag}> → <${eb.tag}>` });
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
- for (const s of styles) {
451
- const rows = summarizeProps(s.props);
452
- if (rows.length)
453
- body.push('', s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:', '', ...beforeAfterTable(rows));
454
- }
455
- if (states.length) {
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) && !baseChanged.has(p.prop) && !(isNonValue(p.before) && isNonValue(p.after)));
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
  }
@@ -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
+ }