styleproof 3.16.0 → 3.18.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 +126 -0
- package/README.md +62 -6
- package/bin/styleproof-capture.mjs +15 -3
- package/bin/styleproof-diff.mjs +9 -0
- package/bin/styleproof-init.mjs +7 -0
- package/bin/styleproof-map.mjs +14 -0
- package/dist/affected-surfaces.d.ts +75 -0
- package/dist/affected-surfaces.js +220 -0
- package/dist/capture-url.js +6 -1
- package/dist/crawl-surfaces.d.ts +5 -1
- package/dist/crawl-surfaces.js +15 -11
- package/dist/crawl.d.ts +35 -0
- package/dist/crawl.js +28 -0
- package/dist/diff.d.ts +26 -0
- package/dist/diff.js +54 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/map-store.d.ts +11 -0
- package/dist/map-store.js +38 -3
- package/dist/report.d.ts +9 -0
- package/dist/report.js +61 -8
- package/dist/runner.d.ts +23 -19
- package/dist/runner.js +94 -54
- package/package.json +1 -1
package/dist/report.js
CHANGED
|
@@ -385,6 +385,14 @@ export function prettyLabel(p, cls) {
|
|
|
385
385
|
const first = cls.split(/\s+/)[0] ?? '';
|
|
386
386
|
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
387
387
|
}
|
|
388
|
+
// Surface keys originate from artifact filenames — attacker-controlled in the
|
|
389
|
+
// fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
|
|
390
|
+
// (the Action slices report.md above the first `### `). Strip the Markdown/HTML
|
|
391
|
+
// control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
|
|
392
|
+
// or table into that bot comment. Escaping at the render boundary — the keys stay
|
|
393
|
+
// legible; only the injection surface is removed. (Crop FILENAMES are separately
|
|
394
|
+
// restricted to [a-z0-9-]; this is the display-side equivalent.)
|
|
395
|
+
const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
|
|
388
396
|
const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
389
397
|
const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
390
398
|
function pushSurfaceWidth(byBase, base, surface) {
|
|
@@ -396,7 +404,7 @@ function renderSurfaceGroups(byBase) {
|
|
|
396
404
|
return [...byBase]
|
|
397
405
|
.map(([base, ws]) => {
|
|
398
406
|
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
399
|
-
return widths.length ? `${base} @ ${widths.join(', ')}` : base;
|
|
407
|
+
return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
|
|
400
408
|
})
|
|
401
409
|
.join(' · ');
|
|
402
410
|
}
|
|
@@ -821,7 +829,7 @@ function renderContentSurface(ctx, surface, changes, seq) {
|
|
|
821
829
|
const mapB = loadStyleMap(findCapture(ctx.afterDir, surface));
|
|
822
830
|
const pngA = readPng(path.join(ctx.beforeDir, `${surface}.png`));
|
|
823
831
|
const pngB = readPng(path.join(ctx.afterDir, `${surface}.png`));
|
|
824
|
-
const md = ['', `### \`${surface}\` · ${changes.length} content change(s)`];
|
|
832
|
+
const md = ['', `### \`${safeKey(surface)}\` · ${changes.length} content change(s)`];
|
|
825
833
|
for (const c of changes) {
|
|
826
834
|
seq++;
|
|
827
835
|
md.push('', `**\`${prettyLabel(c.path, c.cls)}\`**`, '', `- before: \`${clipText(c.before) || '(empty)'}\``, `- after: \`${clipText(c.after) || '(empty)'}\``, ...contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq));
|
|
@@ -888,7 +896,7 @@ function coverageLine(cov) {
|
|
|
888
896
|
if (cov.basis === 'complete')
|
|
889
897
|
return `- **Coverage** — ✓ complete (all ${cov.registrySize} registered surface(s) captured)`;
|
|
890
898
|
if (cov.basis === 'incomplete')
|
|
891
|
-
return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.join(', ')})`;
|
|
899
|
+
return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.map(safeKey).join(', ')})`;
|
|
892
900
|
return '- **Coverage** — ⚠ not asserted (no `expected` registry; certifies only the captured surfaces)';
|
|
893
901
|
}
|
|
894
902
|
function determinismLine(det) {
|
|
@@ -900,7 +908,7 @@ function determinismLine(det) {
|
|
|
900
908
|
}
|
|
901
909
|
function inventoryLine(inv) {
|
|
902
910
|
if (inv.unexplained.length > 0) {
|
|
903
|
-
const keys = inv.unexplained.map((i) => i.key);
|
|
911
|
+
const keys = inv.unexplained.map((i) => safeKey(i.key));
|
|
904
912
|
return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
|
|
905
913
|
}
|
|
906
914
|
if (inv.delta.removed.length > 0)
|
|
@@ -1176,7 +1184,7 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
1176
1184
|
const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
|
|
1177
1185
|
const md = [
|
|
1178
1186
|
'',
|
|
1179
|
-
`### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`,
|
|
1187
|
+
`### \`${safeKey(p.sd.surface)}\` · new surface ${NEW_SURFACE_MARKER}`,
|
|
1180
1188
|
'',
|
|
1181
1189
|
`_${formatSurfaceWithContext(p.sd.surface, map)}_`,
|
|
1182
1190
|
];
|
|
@@ -1196,6 +1204,31 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
1196
1204
|
md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
|
|
1197
1205
|
return { md, json, cropSeq };
|
|
1198
1206
|
}
|
|
1207
|
+
/** The one-time banner where report.md switches from full detail to one-line
|
|
1208
|
+
* summaries, so the reader knows nothing is missing — only relocated to report.json. */
|
|
1209
|
+
function cappedNoticeLines(budget) {
|
|
1210
|
+
return [
|
|
1211
|
+
'',
|
|
1212
|
+
'## … more changed surfaces (summarized to keep this report renderable)',
|
|
1213
|
+
'',
|
|
1214
|
+
`_This report reached its ~${Math.round(budget / 1000)} KB display budget (GitHub does not render ` +
|
|
1215
|
+
`markdown past ~512 KB), so the surfaces below are listed as one-liners. Their full property ` +
|
|
1216
|
+
`tables are in \`report.json\` and their crops in \`crops/\` — the certification above covers every ` +
|
|
1217
|
+
`surface; only the inline detail is capped._`,
|
|
1218
|
+
'',
|
|
1219
|
+
];
|
|
1220
|
+
}
|
|
1221
|
+
/** One-line summary for a changed surface whose full detail was budget-capped: its
|
|
1222
|
+
* name (and how many surfaces share the identical change) · change count · a crop
|
|
1223
|
+
* link so the reviewer can still see it without opening report.json. */
|
|
1224
|
+
function compactChangeSummary(cg, json, img) {
|
|
1225
|
+
const surface = safeKey(cg.rep.sd.surface);
|
|
1226
|
+
const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
|
|
1227
|
+
const regions = json.regions ?? [];
|
|
1228
|
+
const composite = regions[0]?.images?.composite;
|
|
1229
|
+
const link = composite ? ` — [crop](${img(composite)})` : '';
|
|
1230
|
+
return `- \`${surface}\`${more} · ${cg.rep.findings.length} change(s)${link}`;
|
|
1231
|
+
}
|
|
1199
1232
|
export function generateStyleMapReport(opts) {
|
|
1200
1233
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '',
|
|
1201
1234
|
// Tighter than before (was 24) so the change fills the frame — the annotation
|
|
@@ -1203,7 +1236,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1203
1236
|
pad: padBy = 12, minWidth = 320, minHeight = 180, maxHeight = 1600, zoomBelow = 64,
|
|
1204
1237
|
// More, smaller crops before collapsing (was 6), so distinct changes get their
|
|
1205
1238
|
// own focused frame rather than one wide merged one.
|
|
1206
|
-
maxCrops = 8, foldDetailsAt = 0, } = opts;
|
|
1239
|
+
maxCrops = 8, foldDetailsAt = 0, maxReportBytes = 400_000, } = opts;
|
|
1207
1240
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
1208
1241
|
const includeContent = opts.includeContent ?? false;
|
|
1209
1242
|
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
@@ -1261,18 +1294,38 @@ export function generateStyleMapReport(opts) {
|
|
|
1261
1294
|
}));
|
|
1262
1295
|
let totalFindings = 0;
|
|
1263
1296
|
let cropSeq = 0;
|
|
1297
|
+
// report.md must stay renderable — GitHub refuses to render markdown past ~512 KB.
|
|
1298
|
+
// Emit full detail greedily until the byte budget is reached, then list any remaining
|
|
1299
|
+
// surfaces as one-liners. The exhaustive per-row detail is always in report.json and
|
|
1300
|
+
// every crop in crops/, so the cap changes what's shown inline, never what's certified.
|
|
1301
|
+
let reportBytes = md.join('\n').length;
|
|
1302
|
+
let capped = false;
|
|
1303
|
+
const emitDetail = (detail, summary) => {
|
|
1304
|
+
const cost = detail.join('\n').length + 1;
|
|
1305
|
+
if (!capped && reportBytes + cost <= maxReportBytes) {
|
|
1306
|
+
md.push(...detail);
|
|
1307
|
+
reportBytes += cost;
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
if (!capped) {
|
|
1311
|
+
md.push(...cappedNoticeLines(maxReportBytes));
|
|
1312
|
+
capped = true;
|
|
1313
|
+
}
|
|
1314
|
+
md.push(summary);
|
|
1315
|
+
reportBytes += summary.length + 1;
|
|
1316
|
+
};
|
|
1264
1317
|
for (const cg of changeGroups) {
|
|
1265
1318
|
const r = renderChangeGroup(cg, ctx, maxCrops, cropSeq);
|
|
1266
|
-
md.push(...r.md);
|
|
1267
1319
|
json.push(r.json);
|
|
1268
1320
|
totalFindings += r.findingCount;
|
|
1269
1321
|
cropSeq = r.cropSeq;
|
|
1322
|
+
emitDetail(r.md, compactChangeSummary(cg, r.json, img));
|
|
1270
1323
|
}
|
|
1271
1324
|
for (const p of missing) {
|
|
1272
1325
|
const r = renderNewSurface(p, ctx, cropSeq);
|
|
1273
|
-
md.push(...r.md);
|
|
1274
1326
|
json.push(r.json);
|
|
1275
1327
|
cropSeq = r.cropSeq;
|
|
1328
|
+
emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
|
|
1276
1329
|
}
|
|
1277
1330
|
md.push(...contentSection.md);
|
|
1278
1331
|
const reportMdPath = path.join(outDir, 'report.md');
|
package/dist/runner.d.ts
CHANGED
|
@@ -255,25 +255,29 @@ export type CrawlOptions = CaptureConfig & {
|
|
|
255
255
|
/** Max ms to wait for the crawl root's links to render before reading them
|
|
256
256
|
* (an SPA hydrates its nav client-side). Default 15000. */
|
|
257
257
|
linkTimeout?: number;
|
|
258
|
+
/**
|
|
259
|
+
* The full set of surface keys the app knows its nav should link to — its route
|
|
260
|
+
* universe. When set, the crawl reconciles the DISCOVERED link set against it, both
|
|
261
|
+
* directions: an `expected` key with no rendered link fails (nav regression), and a
|
|
262
|
+
* rendered link with no `expected` entry fails (a new route with no owner). For a
|
|
263
|
+
* link-crawled SPA the rendered nav IS the route universe, so this is the same
|
|
264
|
+
* list-vs-ledger discipline as `defineStyleMapCapture`'s guard with the nav as the
|
|
265
|
+
* source of truth.
|
|
266
|
+
*
|
|
267
|
+
* Unlike the spec guard, this runs INSIDE the crawl capture test — the link set
|
|
268
|
+
* isn't known until a browser renders the page — so it only fires when the capture
|
|
269
|
+
* runs (STYLEMAP_DIR set), not in every `npm test`. Omit to keep the current
|
|
270
|
+
* behaviour: capture what the nav links to, assert no completeness.
|
|
271
|
+
*/
|
|
272
|
+
expected?: string[];
|
|
273
|
+
/**
|
|
274
|
+
* Expected/rendered keys deliberately not reconciled, each mapped to its reason — a
|
|
275
|
+
* visible, reviewed opt-out ledger for links that render CONDITIONALLY (behind auth
|
|
276
|
+
* or a feature flag) and so can't be asserted present or absent on every run. An
|
|
277
|
+
* excluded key never triggers a missing- or unexpected-link failure; an `exclude`
|
|
278
|
+
* key in neither `expected` nor the rendered set fails, so the ledger can't rot.
|
|
279
|
+
*/
|
|
280
|
+
exclude?: Record<string, string>;
|
|
258
281
|
};
|
|
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
282
|
export declare function defineCrawlCapture(options: CrawlOptions): void;
|
|
279
283
|
export {};
|
package/dist/runner.js
CHANGED
|
@@ -4,8 +4,9 @@ import path from 'node:path';
|
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
6
|
import { coverageGaps, 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')
|
|
@@ -473,6 +474,17 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
|
473
474
|
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
474
475
|
});
|
|
475
476
|
}
|
|
477
|
+
/** Record the real browser build into the capture bundle. The npm `@playwright/test`
|
|
478
|
+
* version (in the manifest) is only a proxy — the actual Chromium binary can change while
|
|
479
|
+
* it holds constant (a re-download, a different browser store, a CI image bump). The
|
|
480
|
+
* compatibility guard reads this back to refuse a cross-build compare instead of walling
|
|
481
|
+
* a PR with false diffs. Best-effort: an unavailable version leaves the guard as-is. */
|
|
482
|
+
function writeBrowserBuildTest(settings, dir) {
|
|
483
|
+
test('styleproof browser build', ({ page }) => {
|
|
484
|
+
const version = page.context().browser()?.version();
|
|
485
|
+
writeBrowserBuildSidecar(path.join(settings.baseDir, dir), version);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
476
488
|
export function defineStyleMapCapture(options) {
|
|
477
489
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
478
490
|
const settings = resolveSettings(options);
|
|
@@ -498,6 +510,8 @@ export function defineStyleMapCapture(options) {
|
|
|
498
510
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
499
511
|
if (dir)
|
|
500
512
|
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
513
|
+
if (dir)
|
|
514
|
+
writeBrowserBuildTest(settings, dir);
|
|
501
515
|
for (const surface of captureSurfaces) {
|
|
502
516
|
if (surface.widths && surface.widths.length > 0) {
|
|
503
517
|
// Explicit widths: one parallelizable test per surface × width.
|
|
@@ -541,38 +555,91 @@ export function defineStyleMapCapture(options) {
|
|
|
541
555
|
* defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
|
|
542
556
|
* ```
|
|
543
557
|
*/
|
|
558
|
+
/**
|
|
559
|
+
* Load the crawl root, wait for its nav links to hydrate, and read them into a
|
|
560
|
+
* deduped, keyed surface list. A link-less page is fine for an unfiltered crawl —
|
|
561
|
+
* it still captures `from` itself (includeSelf); a `match`-filtered crawl genuinely
|
|
562
|
+
* needs links, so its hydration timeout surfaces. Throws when nothing matched.
|
|
563
|
+
*/
|
|
564
|
+
async function discoverCrawlLinks(page, { from, match, key, linkTimeout }) {
|
|
565
|
+
await page.goto(from, { waitUntil: 'load' });
|
|
566
|
+
await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
|
|
567
|
+
if (match !== undefined)
|
|
568
|
+
throw e;
|
|
569
|
+
});
|
|
570
|
+
const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
|
|
571
|
+
// Unfiltered crawl → also capture `from` itself, so the root is always covered and
|
|
572
|
+
// a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
|
|
573
|
+
// crawl captures only the links the caller asked for.
|
|
574
|
+
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
|
|
575
|
+
if (links.length === 0) {
|
|
576
|
+
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
577
|
+
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
578
|
+
}
|
|
579
|
+
return links;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Capture every discovered surface, aggregating per-surface failures so one bad
|
|
583
|
+
* surface reports without skipping the rest — they're an independent set, not a
|
|
584
|
+
* chain. Auto-width parity with explicit surfaces: no widths given → navigate once,
|
|
585
|
+
* detect the surface's @media bands, then sweep one viewport per band.
|
|
586
|
+
*/
|
|
587
|
+
async function sweepCrawlSurfaces(page, captureSurfaces, settings) {
|
|
588
|
+
const failures = [];
|
|
589
|
+
for (const surface of captureSurfaces) {
|
|
590
|
+
let sweep = surface.widths;
|
|
591
|
+
if (!sweep || sweep.length === 0) {
|
|
592
|
+
try {
|
|
593
|
+
await surface.go(page);
|
|
594
|
+
sweep = await detectViewportWidths(page);
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
failures.push(`${surface.key} @ auto: ${e.message}`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const width of sweep) {
|
|
602
|
+
try {
|
|
603
|
+
await captureSurface(page, surface, width, settings);
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
failures.push(`${surface.key} @ ${width}: ${e.message}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (failures.length) {
|
|
611
|
+
throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
544
614
|
export function defineCrawlCapture(options) {
|
|
545
|
-
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
|
|
615
|
+
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, expected, exclude = {}, } = options;
|
|
546
616
|
const settings = resolveSettings(options);
|
|
547
617
|
// Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
|
|
548
618
|
// that styleproof-map uses to select capture tests picks up crawl specs too.
|
|
549
619
|
test.describe('styleproof capture (crawl)', () => {
|
|
550
620
|
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
|
|
621
|
+
// Record the completeness basis. Without `expected` a crawl has no registry to
|
|
622
|
+
// check against, so it records `expected: null` (honestly "not asserted": it
|
|
623
|
+
// captures what the nav links to, and can't prove that's every route). With
|
|
624
|
+
// `expected` the crawl reconciles the DISCOVERED link set against it below and the
|
|
625
|
+
// ledger travels with the declared universe.
|
|
626
|
+
if (dir)
|
|
627
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
554
628
|
if (dir)
|
|
555
|
-
|
|
629
|
+
writeBrowserBuildTest(settings, dir);
|
|
556
630
|
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
|
-
}
|
|
631
|
+
// 1. Load the root and read its hydrated nav links into the surface set.
|
|
632
|
+
const links = await discoverCrawlLinks(page, { from, match, key, linkTimeout });
|
|
633
|
+
// Coverage guard for a crawled nav. The rendered link set IS the route universe
|
|
634
|
+
// for a link-crawled SPA, so reconciling it against `expected` (both directions)
|
|
635
|
+
// is the spec guard's list-vs-ledger discipline with the nav as source of truth.
|
|
636
|
+
// Runs here — inside the capture test — because the link set isn't known until
|
|
637
|
+
// the page renders (unlike the static spec guard, which runs in the plain suite).
|
|
638
|
+
const gap = expected
|
|
639
|
+
? crawlCoverageError(from, links.map((l) => l.key), expected, exclude)
|
|
640
|
+
: null;
|
|
641
|
+
if (gap)
|
|
642
|
+
throw new Error(gap);
|
|
576
643
|
const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
|
|
577
644
|
key: link.key,
|
|
578
645
|
go: async (p) => {
|
|
@@ -592,35 +659,8 @@ export function defineCrawlCapture(options) {
|
|
|
592
659
|
// With auto-width the band count isn't known until each surface renders, so
|
|
593
660
|
// assume up to 4 bands per surface.
|
|
594
661
|
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
|
-
}
|
|
662
|
+
// 2. Capture each discovered surface, aggregating per-surface failures.
|
|
663
|
+
await sweepCrawlSurfaces(page, captureSurfaces, settings);
|
|
624
664
|
});
|
|
625
665
|
});
|
|
626
666
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.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",
|