styleproof 3.17.0 → 3.19.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 +212 -0
- package/README.md +72 -7
- package/bin/styleproof-capture.mjs +19 -4
- package/bin/styleproof-diff.mjs +41 -23
- package/bin/styleproof-init.mjs +7 -0
- package/bin/styleproof-map.mjs +22 -0
- package/dist/affected-surfaces.d.ts +24 -0
- package/dist/affected-surfaces.js +115 -17
- package/dist/capture-url.d.ts +1 -1
- package/dist/capture-url.js +6 -1
- package/dist/capture.js +40 -26
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.js +54 -0
- package/dist/crawl-surfaces.d.ts +5 -1
- package/dist/crawl-surfaces.js +29 -16
- package/dist/crawl.d.ts +51 -1
- package/dist/crawl.js +83 -7
- package/dist/danger.d.ts +13 -0
- package/dist/danger.js +13 -0
- package/dist/diff.d.ts +26 -0
- package/dist/diff.js +57 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/inventory.js +9 -3
- package/dist/map-store.d.ts +15 -0
- package/dist/map-store.js +45 -3
- package/dist/report.js +38 -13
- package/dist/runner.d.ts +40 -19
- package/dist/runner.js +143 -59
- package/dist/variant-crawler.js +16 -4
- package/package.json +1 -1
package/dist/runner.d.ts
CHANGED
|
@@ -185,6 +185,23 @@ type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
|
|
|
185
185
|
metadata?: CaptureMetadata;
|
|
186
186
|
};
|
|
187
187
|
export declare function expandSurfaceVariants(surface: Surface): ExpandedSurface[];
|
|
188
|
+
/** The identity fields of an expanded surface a collision check needs. */
|
|
189
|
+
type ExpandedKeyed = {
|
|
190
|
+
key: string;
|
|
191
|
+
metadata?: CaptureMetadata;
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Fail LOUDLY on two expanded surfaces sharing a capture key.
|
|
195
|
+
*
|
|
196
|
+
* The expanded key is `surface.key-variant.key`, and that key is the map filename
|
|
197
|
+
* (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
|
|
198
|
+
* change without breaking backward compatibility. But the `-` join is ambiguous:
|
|
199
|
+
* surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
|
|
200
|
+
* `a-b-c`, and the second capture would silently overwrite the first, dropping a
|
|
201
|
+
* surface with no error. Rather than mangle the public key format, we assert
|
|
202
|
+
* uniqueness up front and name BOTH origins so the author can rename one.
|
|
203
|
+
*/
|
|
204
|
+
export declare function assertUniqueExpandedKeys(surfaces: ExpandedKeyed[]): void;
|
|
188
205
|
/**
|
|
189
206
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
190
207
|
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
@@ -255,25 +272,29 @@ export type CrawlOptions = CaptureConfig & {
|
|
|
255
272
|
/** Max ms to wait for the crawl root's links to render before reading them
|
|
256
273
|
* (an SPA hydrates its nav client-side). Default 15000. */
|
|
257
274
|
linkTimeout?: number;
|
|
275
|
+
/**
|
|
276
|
+
* The full set of surface keys the app knows its nav should link to — its route
|
|
277
|
+
* universe. When set, the crawl reconciles the DISCOVERED link set against it, both
|
|
278
|
+
* directions: an `expected` key with no rendered link fails (nav regression), and a
|
|
279
|
+
* rendered link with no `expected` entry fails (a new route with no owner). For a
|
|
280
|
+
* link-crawled SPA the rendered nav IS the route universe, so this is the same
|
|
281
|
+
* list-vs-ledger discipline as `defineStyleMapCapture`'s guard with the nav as the
|
|
282
|
+
* source of truth.
|
|
283
|
+
*
|
|
284
|
+
* Unlike the spec guard, this runs INSIDE the crawl capture test — the link set
|
|
285
|
+
* isn't known until a browser renders the page — so it only fires when the capture
|
|
286
|
+
* runs (STYLEMAP_DIR set), not in every `npm test`. Omit to keep the current
|
|
287
|
+
* behaviour: capture what the nav links to, assert no completeness.
|
|
288
|
+
*/
|
|
289
|
+
expected?: string[];
|
|
290
|
+
/**
|
|
291
|
+
* Expected/rendered keys deliberately not reconciled, each mapped to its reason — a
|
|
292
|
+
* visible, reviewed opt-out ledger for links that render CONDITIONALLY (behind auth
|
|
293
|
+
* or a feature flag) and so can't be asserted present or absent on every run. An
|
|
294
|
+
* excluded key never triggers a missing- or unexpected-link failure; an `exclude`
|
|
295
|
+
* key in neither `expected` nor the rendered set fails, so the ledger can't rot.
|
|
296
|
+
*/
|
|
297
|
+
exclude?: Record<string, string>;
|
|
258
298
|
};
|
|
259
|
-
/**
|
|
260
|
-
* Like {@link defineStyleMapCapture}, but the surface set is DISCOVERED at run time
|
|
261
|
-
* by crawling a page's links instead of being hand-listed — for a single-route SPA
|
|
262
|
-
* whose views are `?tab=`/client-routed and so invisible to the filesystem
|
|
263
|
-
* {@link discoverNextRoutes}. It navigates `from`, reads its same-origin `<a href>`s
|
|
264
|
-
* (filtered by `match`), and captures each as a surface keyed by `key`. The app just
|
|
265
|
-
* has to render its nav as real links; nothing to hand-maintain, so the surface list
|
|
266
|
-
* can't drift from the nav.
|
|
267
|
-
*
|
|
268
|
-
* One Playwright test does the whole sweep (the link set isn't known until a browser
|
|
269
|
-
* has rendered the page, so per-surface tests can't be generated at collection time).
|
|
270
|
-
* Per-surface failures are aggregated — one bad surface reports without hiding the
|
|
271
|
-
* rest. Replay/self-check/clock-freeze behave exactly as for explicit surfaces.
|
|
272
|
-
*
|
|
273
|
-
* ```ts
|
|
274
|
-
* // styleproof.spec.ts — capture every tab the nav links to
|
|
275
|
-
* defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
|
|
276
|
-
* ```
|
|
277
|
-
*/
|
|
278
299
|
export declare function defineCrawlCapture(options: CrawlOptions): void;
|
|
279
300
|
export {};
|
package/dist/runner.js
CHANGED
|
@@ -3,9 +3,10 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
|
-
import { coverageGaps, COVERAGE_LEDGER } from './coverage.js';
|
|
6
|
+
import { coverageGaps, coverageKeys, translateExpected, COVERAGE_LEDGER, } from './coverage.js';
|
|
7
|
+
import { writeBrowserBuildSidecar } from './map-store.js';
|
|
7
8
|
import { detectViewportWidths } from './breakpoints.js';
|
|
8
|
-
import { selectCrawlLinks } from './crawl.js';
|
|
9
|
+
import { selectCrawlLinks, crawlCoverageError } from './crawl.js';
|
|
9
10
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
10
11
|
function driftDesc(f) {
|
|
11
12
|
if (f.kind === 'dom')
|
|
@@ -209,6 +210,36 @@ export function expandSurfaceVariants(surface) {
|
|
|
209
210
|
return [baseSurface, ...expandedVariants];
|
|
210
211
|
return [...expandedVariants, ...liveStates.map((state) => expandOne(surface, state, 'live-state'))];
|
|
211
212
|
}
|
|
213
|
+
/** Human-readable origin of an expanded surface for a collision message. */
|
|
214
|
+
function expandedOrigin(s) {
|
|
215
|
+
const surfaceKey = s.metadata?.surfaceKey ?? s.key;
|
|
216
|
+
const variantKey = s.metadata?.variantKey;
|
|
217
|
+
return variantKey ? `surface '${surfaceKey}' variant '${variantKey}'` : `surface '${surfaceKey}'`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Fail LOUDLY on two expanded surfaces sharing a capture key.
|
|
221
|
+
*
|
|
222
|
+
* The expanded key is `surface.key-variant.key`, and that key is the map filename
|
|
223
|
+
* (`<key>@<width>.json.gz`) and the report identity — so it's public and can't
|
|
224
|
+
* change without breaking backward compatibility. But the `-` join is ambiguous:
|
|
225
|
+
* surface `a` + variant `b-c` and surface `a-b` + variant `c` both expand to
|
|
226
|
+
* `a-b-c`, and the second capture would silently overwrite the first, dropping a
|
|
227
|
+
* surface with no error. Rather than mangle the public key format, we assert
|
|
228
|
+
* uniqueness up front and name BOTH origins so the author can rename one.
|
|
229
|
+
*/
|
|
230
|
+
export function assertUniqueExpandedKeys(surfaces) {
|
|
231
|
+
const byKey = new Map();
|
|
232
|
+
for (const s of surfaces) {
|
|
233
|
+
const prior = byKey.get(s.key);
|
|
234
|
+
if (prior) {
|
|
235
|
+
throw new Error(`styleproof: capture key '${s.key}' is produced by two surfaces — ` +
|
|
236
|
+
`${expandedOrigin(prior)} collides with ${expandedOrigin(s)}. ` +
|
|
237
|
+
`Keys must expand uniquely (they name the map files and report entries); ` +
|
|
238
|
+
`rename one surface or variant.`);
|
|
239
|
+
}
|
|
240
|
+
byKey.set(s.key, s);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
212
243
|
/**
|
|
213
244
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
214
245
|
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
@@ -458,7 +489,7 @@ function resolveSettings(c) {
|
|
|
458
489
|
* `expected: null` records that the spec declared no registry, so a green can only
|
|
459
490
|
* certify the captured surfaces. Runs on a capture run (dir set) only.
|
|
460
491
|
*/
|
|
461
|
-
function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
492
|
+
function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfaces) {
|
|
462
493
|
test('styleproof coverage ledger', () => {
|
|
463
494
|
const outDir = path.join(settings.baseDir, dir);
|
|
464
495
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -469,14 +500,30 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
|
469
500
|
: settings.replayFrom
|
|
470
501
|
? 'replayed'
|
|
471
502
|
: 'unproven';
|
|
472
|
-
|
|
503
|
+
// Pre-translate the declared universe into the keys actually captured to disk, so
|
|
504
|
+
// the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
|
|
505
|
+
// compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
|
|
506
|
+
const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
|
|
507
|
+
const ledger = { version: 1, expected: ledgerExpected, exclude, determinism };
|
|
473
508
|
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
474
509
|
});
|
|
475
510
|
}
|
|
511
|
+
/** Record the real browser build into the capture bundle. The npm `@playwright/test`
|
|
512
|
+
* version (in the manifest) is only a proxy — the actual Chromium binary can change while
|
|
513
|
+
* it holds constant (a re-download, a different browser store, a CI image bump). The
|
|
514
|
+
* compatibility guard reads this back to refuse a cross-build compare instead of walling
|
|
515
|
+
* a PR with false diffs. Best-effort: an unavailable version leaves the guard as-is. */
|
|
516
|
+
function writeBrowserBuildTest(settings, dir) {
|
|
517
|
+
test('styleproof browser build', ({ page }) => {
|
|
518
|
+
const version = page.context().browser()?.version();
|
|
519
|
+
writeBrowserBuildSidecar(path.join(settings.baseDir, dir), version);
|
|
520
|
+
});
|
|
521
|
+
}
|
|
476
522
|
export function defineStyleMapCapture(options) {
|
|
477
523
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
478
524
|
const settings = resolveSettings(options);
|
|
479
525
|
const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
|
|
526
|
+
assertUniqueExpandedKeys(captureSurfaces);
|
|
480
527
|
// Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
|
|
481
528
|
// a route added without a surface fails the app's own tests — long before, and
|
|
482
529
|
// independent of, a capture run. This is the one gap captures can't catch: a
|
|
@@ -485,7 +532,10 @@ export function defineStyleMapCapture(options) {
|
|
|
485
532
|
if (expected) {
|
|
486
533
|
test.describe('styleproof coverage', () => {
|
|
487
534
|
test('every expected surface is captured or explicitly excluded', () => {
|
|
488
|
-
const { uncovered, staleExclusions } = coverageGaps(
|
|
535
|
+
const { uncovered, staleExclusions } = coverageGaps(
|
|
536
|
+
// A liveStates surface is captured only as its `-loading`/`-loaded` splits;
|
|
537
|
+
// map each back to the declared base key so the split satisfies `expected`.
|
|
538
|
+
coverageKeys(captureSurfaces), expected, exclude);
|
|
489
539
|
expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
|
|
490
540
|
`nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
|
|
491
541
|
`Missing: ${uncovered.join(', ')}`).toEqual([]);
|
|
@@ -497,7 +547,9 @@ export function defineStyleMapCapture(options) {
|
|
|
497
547
|
test.describe('styleproof capture', () => {
|
|
498
548
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
499
549
|
if (dir)
|
|
500
|
-
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
550
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, captureSurfaces);
|
|
551
|
+
if (dir)
|
|
552
|
+
writeBrowserBuildTest(settings, dir);
|
|
501
553
|
for (const surface of captureSurfaces) {
|
|
502
554
|
if (surface.widths && surface.widths.length > 0) {
|
|
503
555
|
// Explicit widths: one parallelizable test per surface × width.
|
|
@@ -541,38 +593,96 @@ export function defineStyleMapCapture(options) {
|
|
|
541
593
|
* defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
|
|
542
594
|
* ```
|
|
543
595
|
*/
|
|
596
|
+
/**
|
|
597
|
+
* Load the crawl root, wait for its nav links to hydrate, and read them into a
|
|
598
|
+
* deduped, keyed surface list. A link-less page is fine for an unfiltered crawl —
|
|
599
|
+
* it still captures `from` itself (includeSelf); a `match`-filtered crawl genuinely
|
|
600
|
+
* needs links, so its hydration timeout surfaces. Throws when nothing matched.
|
|
601
|
+
*/
|
|
602
|
+
async function discoverCrawlLinks(page, { from, match, key, linkTimeout }) {
|
|
603
|
+
await page.goto(from, { waitUntil: 'load' });
|
|
604
|
+
await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
|
|
605
|
+
if (match !== undefined)
|
|
606
|
+
throw e;
|
|
607
|
+
});
|
|
608
|
+
const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
|
|
609
|
+
// Unfiltered crawl → also capture `from` itself, so the root is always covered and
|
|
610
|
+
// a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
|
|
611
|
+
// crawl captures only the links the caller asked for.
|
|
612
|
+
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
|
|
613
|
+
if (links.length === 0) {
|
|
614
|
+
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
615
|
+
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
616
|
+
}
|
|
617
|
+
return links;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Capture every discovered surface, aggregating per-surface failures so one bad
|
|
621
|
+
* surface reports without skipping the rest — they're an independent set, not a
|
|
622
|
+
* chain. Auto-width parity with explicit surfaces: no widths given → navigate once,
|
|
623
|
+
* detect the surface's @media bands, then sweep one viewport per band.
|
|
624
|
+
*/
|
|
625
|
+
async function sweepCrawlSurfaces(page, captureSurfaces, settings) {
|
|
626
|
+
const failures = [];
|
|
627
|
+
for (const surface of captureSurfaces) {
|
|
628
|
+
let sweep = surface.widths;
|
|
629
|
+
if (!sweep || sweep.length === 0) {
|
|
630
|
+
try {
|
|
631
|
+
await surface.go(page);
|
|
632
|
+
sweep = await detectViewportWidths(page);
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
failures.push(`${surface.key} @ auto: ${e.message}`);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
for (const width of sweep) {
|
|
640
|
+
try {
|
|
641
|
+
await captureSurface(page, surface, width, settings);
|
|
642
|
+
}
|
|
643
|
+
catch (e) {
|
|
644
|
+
failures.push(`${surface.key} @ ${width}: ${e.message}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (failures.length) {
|
|
649
|
+
throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
544
652
|
export function defineCrawlCapture(options) {
|
|
545
|
-
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
|
|
653
|
+
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, expected, exclude = {}, } = options;
|
|
546
654
|
const settings = resolveSettings(options);
|
|
547
655
|
// Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
|
|
548
656
|
// that styleproof-map uses to select capture tests picks up crawl specs too.
|
|
549
657
|
test.describe('styleproof capture (crawl)', () => {
|
|
550
658
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
551
|
-
//
|
|
552
|
-
// so it records `expected: null` (
|
|
553
|
-
// captures what the nav links to, and can't prove that's every route
|
|
659
|
+
// Record the completeness basis. Without `expected` a crawl has no registry to
|
|
660
|
+
// check against, so it records `expected: null` (honestly "not asserted": it
|
|
661
|
+
// captures what the nav links to, and can't prove that's every route). With
|
|
662
|
+
// `expected` the crawl reconciles the DISCOVERED link set against it below and the
|
|
663
|
+
// ledger travels with the declared universe.
|
|
664
|
+
// The crawl applies the SAME variants/liveStates to every discovered link, so the
|
|
665
|
+
// expansion of a declared key is knowable up front (before discovery): expand each
|
|
666
|
+
// `expected` key with this crawl's variants/liveStates to get the keys captured to
|
|
667
|
+
// disk, so a liveStates crawl's ledger is pre-translated like the spec-driven one.
|
|
668
|
+
const ledgerSurfaces = (expected ?? []).flatMap((key) => expandSurfaceVariants({ key, go: async () => { }, variants, liveStates }));
|
|
669
|
+
if (dir)
|
|
670
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude, ledgerSurfaces);
|
|
554
671
|
if (dir)
|
|
555
|
-
|
|
672
|
+
writeBrowserBuildTest(settings, dir);
|
|
556
673
|
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
557
|
-
// 1. Load the root and
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
|
|
570
|
-
// crawl captures only the links the caller asked for.
|
|
571
|
-
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
|
|
572
|
-
if (links.length === 0) {
|
|
573
|
-
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
574
|
-
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
575
|
-
}
|
|
674
|
+
// 1. Load the root and read its hydrated nav links into the surface set.
|
|
675
|
+
const links = await discoverCrawlLinks(page, { from, match, key, linkTimeout });
|
|
676
|
+
// Coverage guard for a crawled nav. The rendered link set IS the route universe
|
|
677
|
+
// for a link-crawled SPA, so reconciling it against `expected` (both directions)
|
|
678
|
+
// is the spec guard's list-vs-ledger discipline with the nav as source of truth.
|
|
679
|
+
// Runs here — inside the capture test — because the link set isn't known until
|
|
680
|
+
// the page renders (unlike the static spec guard, which runs in the plain suite).
|
|
681
|
+
const gap = expected
|
|
682
|
+
? crawlCoverageError(from, links.map((l) => l.key), expected, exclude)
|
|
683
|
+
: null;
|
|
684
|
+
if (gap)
|
|
685
|
+
throw new Error(gap);
|
|
576
686
|
const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
|
|
577
687
|
key: link.key,
|
|
578
688
|
go: async (p) => {
|
|
@@ -587,40 +697,14 @@ export function defineCrawlCapture(options) {
|
|
|
587
697
|
liveStates,
|
|
588
698
|
popups,
|
|
589
699
|
}));
|
|
700
|
+
assertUniqueExpandedKeys(captureSurfaces);
|
|
590
701
|
// Budget the whole sweep up front: one test captures every surface, and
|
|
591
702
|
// captureSurface no longer sets its own timeout, so size it to the work found.
|
|
592
703
|
// With auto-width the band count isn't known until each surface renders, so
|
|
593
704
|
// assume up to 4 bands per surface.
|
|
594
705
|
test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 4), 0) * 60_000));
|
|
595
|
-
// 2. Capture each discovered surface
|
|
596
|
-
|
|
597
|
-
const failures = [];
|
|
598
|
-
for (const surface of captureSurfaces) {
|
|
599
|
-
// Auto-width parity with explicit surfaces: no widths given → navigate once and
|
|
600
|
-
// detect this surface's @media bands, then sweep one viewport per band.
|
|
601
|
-
let sweep = surface.widths;
|
|
602
|
-
if (!sweep || sweep.length === 0) {
|
|
603
|
-
try {
|
|
604
|
-
await surface.go(page);
|
|
605
|
-
sweep = await detectViewportWidths(page);
|
|
606
|
-
}
|
|
607
|
-
catch (e) {
|
|
608
|
-
failures.push(`${surface.key} @ auto: ${e.message}`);
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
for (const width of sweep) {
|
|
613
|
-
try {
|
|
614
|
-
await captureSurface(page, surface, width, settings);
|
|
615
|
-
}
|
|
616
|
-
catch (e) {
|
|
617
|
-
failures.push(`${surface.key} @ ${width}: ${e.message}`);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
if (failures.length) {
|
|
622
|
-
throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
|
|
623
|
-
}
|
|
706
|
+
// 2. Capture each discovered surface, aggregating per-surface failures.
|
|
707
|
+
await sweepCrawlSurfaces(page, captureSurfaces, settings);
|
|
624
708
|
});
|
|
625
709
|
});
|
|
626
710
|
}
|
package/dist/variant-crawler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { captureStyleMap } from './capture.js';
|
|
3
3
|
import { diffStyleMaps } from './diff.js';
|
|
4
|
+
import { DANGER_SOURCE } from './danger.js';
|
|
4
5
|
function slug(value) {
|
|
5
6
|
return (value
|
|
6
7
|
.toLowerCase()
|
|
@@ -39,8 +40,11 @@ function liveKey(candidate) {
|
|
|
39
40
|
function liveSelector(candidate) {
|
|
40
41
|
return candidate.path;
|
|
41
42
|
}
|
|
43
|
+
// `dangerSource` is the shared destructive-label pattern (see {@link DANGER_SOURCE}),
|
|
44
|
+
// passed in because this function is serialized into the browser and can't close over
|
|
45
|
+
// a Node `RegExp`.
|
|
42
46
|
// fallow-ignore-next-line complexity
|
|
43
|
-
function collectCandidates() {
|
|
47
|
+
function collectCandidates(dangerSource) {
|
|
44
48
|
const controls = [
|
|
45
49
|
'[aria-expanded]',
|
|
46
50
|
'[aria-haspopup]',
|
|
@@ -53,7 +57,7 @@ function collectCandidates() {
|
|
|
53
57
|
'select',
|
|
54
58
|
'form',
|
|
55
59
|
].join(',');
|
|
56
|
-
const dangerous =
|
|
60
|
+
const dangerous = new RegExp(dangerSource, 'i');
|
|
57
61
|
const esc = (value) => CSS.escape(value);
|
|
58
62
|
const quote = (value) => JSON.stringify(value);
|
|
59
63
|
const visible = (el) => {
|
|
@@ -93,7 +97,15 @@ function collectCandidates() {
|
|
|
93
97
|
return pathSelector(el);
|
|
94
98
|
};
|
|
95
99
|
const labelFor = (el) => {
|
|
96
|
-
|
|
100
|
+
// Include `title` so an icon-only control (no text, no aria-label) announcing
|
|
101
|
+
// itself via a native tooltip — `<button title="Delete">🗑</button>` — still
|
|
102
|
+
// yields a real label. Without it the label is "button", slipping past the
|
|
103
|
+
// destructive guard below that this harvester's clicks must respect.
|
|
104
|
+
const own = (el.getAttribute('aria-label') ||
|
|
105
|
+
el.getAttribute('name') ||
|
|
106
|
+
el.textContent ||
|
|
107
|
+
el.getAttribute('title') ||
|
|
108
|
+
'').trim();
|
|
97
109
|
return own.replace(/\s+/g, ' ').slice(0, 80) || el.tagName.toLowerCase();
|
|
98
110
|
};
|
|
99
111
|
const reasonFor = (el) => {
|
|
@@ -144,7 +156,7 @@ function collectCandidates() {
|
|
|
144
156
|
return out;
|
|
145
157
|
}
|
|
146
158
|
async function discoverCandidates(page) {
|
|
147
|
-
return page.evaluate(collectCandidates);
|
|
159
|
+
return page.evaluate(collectCandidates, DANGER_SOURCE);
|
|
148
160
|
}
|
|
149
161
|
async function perform(page, candidate) {
|
|
150
162
|
const target = page.locator(candidate.selector).first();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
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",
|