styleproof 3.20.0 → 4.0.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 +192 -0
- package/README.md +437 -321
- package/bin/styleproof-capture.mjs +4 -0
- package/bin/styleproof-diff.mjs +192 -27
- package/bin/styleproof-init.mjs +75 -7
- package/bin/styleproof-map.mjs +23 -9
- package/bin/styleproof-report.mjs +6 -0
- package/dist/capture-url.js +5 -0
- package/dist/capture.d.ts +59 -0
- package/dist/capture.js +127 -5
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/coverage.d.ts +7 -0
- package/dist/data-residue.d.ts +70 -0
- package/dist/data-residue.js +105 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/map-store.d.ts +37 -1
- package/dist/map-store.js +103 -10
- package/dist/report.d.ts +1 -4
- package/dist/report.js +100 -353
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +63 -9
- package/example/styleproof-approve.yml +108 -0
- package/package.json +7 -4
|
@@ -20,6 +20,7 @@ import { chromium } from '@playwright/test';
|
|
|
20
20
|
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
21
21
|
import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
|
|
22
22
|
import { crawlAndCapture } from '../dist/crawl-surfaces.js';
|
|
23
|
+
import { writeCaptureManifest } from '../dist/map-store.js';
|
|
23
24
|
|
|
24
25
|
const COMMAND = 'styleproof-capture';
|
|
25
26
|
|
|
@@ -122,6 +123,9 @@ async function runCrawl() {
|
|
|
122
123
|
console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
|
|
123
124
|
};
|
|
124
125
|
const report = await crawlAndCapture(page, crawlOpts);
|
|
126
|
+
// Stamp a manifest so a two-directory diff against this crawl output has the
|
|
127
|
+
// same-environment guard on both sides (v4 refuses a manifest-less side).
|
|
128
|
+
writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
|
|
125
129
|
console.log(
|
|
126
130
|
`✓ ${report.captured}/${report.surfaces.length} surface(s) × ${crawlOpts.widths.length} width(s) → ${opts.out} ` +
|
|
127
131
|
`(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -25,11 +25,27 @@
|
|
|
25
25
|
import fs from 'node:fs';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
28
|
+
// The shared grouping brain (leaf — no Playwright-adjacent imports) that already
|
|
29
|
+
// dedupes the report: group identical change-sets across surfaces and fold derived
|
|
30
|
+
// longhands. Used for the HUMAN output only; --json stays the raw machine contract.
|
|
31
|
+
import {
|
|
32
|
+
cleanFindings,
|
|
33
|
+
groupBySignature,
|
|
34
|
+
groupByPath,
|
|
35
|
+
groupTitle,
|
|
36
|
+
summarizeProps,
|
|
37
|
+
derivedLonghandCount,
|
|
38
|
+
formatSurfaceList,
|
|
39
|
+
classifyChrome,
|
|
40
|
+
surfaceBase,
|
|
41
|
+
} from '../dist/change-groups.js';
|
|
28
42
|
import {
|
|
29
43
|
DEFAULT_MAP_STORE_BRANCH,
|
|
30
44
|
DEFAULT_REMOTE,
|
|
31
45
|
assertCompatibleMapDirs,
|
|
32
46
|
cleanupCachedCaptureDirs,
|
|
47
|
+
manifestlessError,
|
|
48
|
+
manifestlessSide,
|
|
33
49
|
resolveCachedCaptureDirs,
|
|
34
50
|
} from '../dist/map-store.js';
|
|
35
51
|
import {
|
|
@@ -39,8 +55,9 @@ import {
|
|
|
39
55
|
showHelpAndExit,
|
|
40
56
|
unknownFlagMessage,
|
|
41
57
|
} from '../dist/cli-errors.js';
|
|
42
|
-
import { readInventories } from '../dist/capture.js';
|
|
58
|
+
import { readInventories, readResidue, surfaceElementPaths } from '../dist/capture.js';
|
|
43
59
|
import { auditRunInventory, readAckFile } from '../dist/inventory.js';
|
|
60
|
+
import { auditRunResidue, readResidueAckFile } from '../dist/data-residue.js';
|
|
44
61
|
import { auditCoverage, auditDeterminism, COVERAGE_LEDGER } from '../dist/coverage.js';
|
|
45
62
|
import { isMapFile } from '../dist/map-store.js';
|
|
46
63
|
|
|
@@ -100,6 +117,70 @@ function printInventoryAudit(audit) {
|
|
|
100
117
|
return unexplained.length;
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
// ── data-residue guard (gate by default) ─────────────────────────────────────────
|
|
121
|
+
// A data-boundary request (matching `replayUrl`) that FAILED during capture means the
|
|
122
|
+
// captured state embedded that endpoint's fallback branch — its response-driven states
|
|
123
|
+
// are unproven (issue #205). Residue is recorded + warned at capture time; here the diff
|
|
124
|
+
// SURFACES it, and — unless the head bundle opted down to `dataResidue: 'warn'` — an
|
|
125
|
+
// unacknowledged failing endpoint BLOCKS (exit 1). Acknowledge intentional ones in
|
|
126
|
+
// styleproof.data-residue.json. (Bundles captured before this field existed read as warn.)
|
|
127
|
+
|
|
128
|
+
// `key -> reason` acknowledged failing endpoints. Malformed JSON fails loud (exit 2),
|
|
129
|
+
// like the inventory ack file, so a broken file can't silently un-acknowledge a failure.
|
|
130
|
+
function loadAcknowledgedResidue() {
|
|
131
|
+
try {
|
|
132
|
+
return readResidueAckFile();
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.error(`${COMMAND}: ${e.message}`);
|
|
135
|
+
process.exit(2);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Audit the HEAD bundle's residue against the ack ledger, carrying whether the head
|
|
140
|
+
// ledger armed the gate. Returns null when no captured map carried residue AND the
|
|
141
|
+
// gate wasn't armed — so a clean healthy run prints/gates nothing (byte-identical).
|
|
142
|
+
function readResidueAudit(dirB, armed) {
|
|
143
|
+
const headResidue = readResidue(dirB);
|
|
144
|
+
if (!armed && !headResidue.some((m) => m.dataResidue?.length)) return null;
|
|
145
|
+
const acknowledged = loadAcknowledgedResidue();
|
|
146
|
+
return { acknowledged, ...auditRunResidue(headResidue, acknowledged, armed) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Print the Data-residue section; return the count of UNACKNOWLEDGED failing endpoints
|
|
150
|
+
// that BLOCK (only when the gate is armed). No-op/0 when there was nothing to audit.
|
|
151
|
+
/** One line per residue entry: acknowledged entries show their reason; the rest are
|
|
152
|
+
* marked ✗ (armed — will block) or ⚠ (warn mode). */
|
|
153
|
+
function residueLine(r, ackReason, armed) {
|
|
154
|
+
if (ackReason !== undefined) return ` ${r.surface} · ${r.endpoint} (${r.reason}) — acknowledged: ${ackReason}`;
|
|
155
|
+
return ` ${armed ? '✗ ' : '⚠ '}${r.surface} · ${r.endpoint} (${r.reason})${armed ? ', unacknowledged' : ''}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The action footer: the gate (default) names the remedy; warn mode is the opt-out. */
|
|
159
|
+
function residueFooter(armed, unacknowledgedCount) {
|
|
160
|
+
if (!unacknowledgedCount) return null;
|
|
161
|
+
return armed
|
|
162
|
+
? ` → ${unacknowledgedCount} unacknowledged failing endpoint(s): fixture each (page.route / liveStates), acknowledge intentional ones in styleproof.data-residue.json {"<key>":"<why>"}, or opt down with \`dataResidue: "warn"\` in the capture spec.`
|
|
163
|
+
: ' → recorded and warned (dataResidue: "warn" — the opt-out). Remove it to restore the default gate that BLOCKS on these.';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function printResidueAudit(audit) {
|
|
167
|
+
if (!audit) return 0;
|
|
168
|
+
const { residue, unacknowledged, staleAcknowledgements, armed } = audit;
|
|
169
|
+
if (!residue.length && !staleAcknowledgements.length) {
|
|
170
|
+
console.log('\n🩹 Data residue: no failing data-boundary request during capture');
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
console.log('\n🩹 Data residue (data-boundary requests that FAILED during capture — fallback branch captured):');
|
|
174
|
+
for (const r of residue) console.log(residueLine(r, audit.acknowledged[r.key], armed));
|
|
175
|
+
for (const k of staleAcknowledgements)
|
|
176
|
+
console.log(` ⚠ stale acknowledgement (endpoint no longer failing/present): ${k}`);
|
|
177
|
+
const footer = residueFooter(armed, unacknowledged.length);
|
|
178
|
+
if (footer) console.log(footer);
|
|
179
|
+
// Only an ARMED gate blocks; warn-mode surfaces without gating. A stale acknowledgement
|
|
180
|
+
// always blocks when armed, so the ledger can't rot (mirrors the `exclude` guard).
|
|
181
|
+
return armed ? unacknowledged.length + staleAcknowledgements.length : 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
103
184
|
// ── coverage provenance (the completeness basis of a green) ──────────────────────
|
|
104
185
|
// The head bundle carries a coverage ledger (the declared registry). The gate audits
|
|
105
186
|
// the ACTUALLY-captured surfaces against it, so "clean" states its basis — complete vs
|
|
@@ -254,7 +335,13 @@ let result;
|
|
|
254
335
|
let inventoryAudit = null;
|
|
255
336
|
let coverageVerdict = null;
|
|
256
337
|
let determinismVerdict = null;
|
|
338
|
+
let residueAudit = null;
|
|
339
|
+
let surfacePaths = new Map();
|
|
257
340
|
try {
|
|
341
|
+
// v4: a side without a manifest is unsupported — the same-environment guard can't be
|
|
342
|
+
// enforced, so refuse (exit 2 via the catch below) rather than compare on false footing.
|
|
343
|
+
const manifestless = manifestlessSide(dirA, dirB);
|
|
344
|
+
if (manifestless) throw new Error(manifestlessError(manifestless));
|
|
258
345
|
assertCompatibleMapDirs(dirA, dirB);
|
|
259
346
|
result = diffStyleMapDirs(dirA, dirB);
|
|
260
347
|
// Read inventory + the certification ledgers here, while the (possibly cached/restored)
|
|
@@ -264,6 +351,12 @@ try {
|
|
|
264
351
|
const headLedger = readLedger(dirB);
|
|
265
352
|
coverageVerdict = auditCoverage(capturedSurfaceKeys(dirB), headLedger);
|
|
266
353
|
determinismVerdict = auditDeterminism(readLedger(dirA), headLedger);
|
|
354
|
+
// Data-residue: the head bundle's failing data endpoints, gated only if its ledger
|
|
355
|
+
// armed `dataResidue: 'gate'`. Same "read while the dirs exist" rule as the ledgers.
|
|
356
|
+
residueAudit = readResidueAudit(dirB, headLedger?.dataResidue === 'gate');
|
|
357
|
+
// Element-path sets per surface, for the shared-chrome tier — same "read while
|
|
358
|
+
// the dirs exist" rule as the ledgers above.
|
|
359
|
+
surfacePaths = surfaceElementPaths(dirA, dirB);
|
|
267
360
|
} catch (e) {
|
|
268
361
|
console.error(e.message);
|
|
269
362
|
process.exit(2);
|
|
@@ -272,34 +365,92 @@ try {
|
|
|
272
365
|
}
|
|
273
366
|
const { surfaces, counts, compared } = result;
|
|
274
367
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
368
|
+
// ── grouped human output ─────────────────────────────────────────────────────
|
|
369
|
+
// Reuse the report's dedup so one real change doesn't print once per surface with
|
|
370
|
+
// its derived-longhand echo: group surfaces that changed identically, fold the
|
|
371
|
+
// size/position-derived longhands behind a count, and keep the per-surface tally
|
|
372
|
+
// in each group's header line. --json below is untouched (the raw machine feed).
|
|
373
|
+
|
|
374
|
+
// One finding's lines: a heading, then its summarised property deltas (the same
|
|
375
|
+
// dedupe the report shows). Returns [] for a DOM finding (handled separately) or a
|
|
376
|
+
// finding whose props all summarised away.
|
|
377
|
+
function findingLines(f) {
|
|
378
|
+
if (f.kind === 'dom') return [];
|
|
379
|
+
const rows = summarizeProps(f.props);
|
|
380
|
+
if (!rows.length) return [];
|
|
381
|
+
const head =
|
|
382
|
+
f.kind === 'state'
|
|
383
|
+
? ` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`
|
|
384
|
+
: ` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`;
|
|
385
|
+
return [head, ...rows.map((p) => ` ${p.prop}: ${p.before} → ${p.after}`)];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// A DOM finding's one-line heading (added/removed/retagged).
|
|
389
|
+
function domLine(dom) {
|
|
390
|
+
return dom.change === 'retagged'
|
|
391
|
+
? ` DOM retagged: ${dom.path} ${dom.detail ?? ''}`
|
|
392
|
+
: ` DOM ${dom.change}: ${findingLabel(dom.path, dom.cls)}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The element lines for one change group, from its representative's cleaned
|
|
396
|
+
// findings: one heading per element, then its summarised property deltas.
|
|
397
|
+
function elementLines(findings) {
|
|
281
398
|
const lines = [];
|
|
282
|
-
for (const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
? ` DOM retagged: ${f.path} ${f.detail}`
|
|
287
|
-
: ` DOM ${f.change}: ${findingLabel(f.path, f.cls)}`,
|
|
288
|
-
);
|
|
289
|
-
} else if (f.kind === 'style') {
|
|
290
|
-
lines.push(` ${findingLabel(f.path, f.cls)}${f.pseudo || ''}`);
|
|
291
|
-
for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
|
|
292
|
-
} else {
|
|
293
|
-
lines.push(` [:${f.state}] ${findingLabel(f.path, f.cls)}${f.sub !== f.path ? ` ⇒ ${f.sub}` : ''}`);
|
|
294
|
-
for (const p of f.props) lines.push(` ${p.prop}: ${p.before} → ${p.after}`);
|
|
295
|
-
}
|
|
399
|
+
for (const group of groupByPath(findings)) {
|
|
400
|
+
const dom = group.find((f) => f.kind === 'dom');
|
|
401
|
+
if (dom) lines.push(domLine(dom));
|
|
402
|
+
for (const f of group) lines.push(...findingLines(f));
|
|
296
403
|
}
|
|
297
|
-
|
|
404
|
+
return lines;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// New (one-sided) surfaces keep their own line, unchanged.
|
|
408
|
+
for (const sd of surfaces) {
|
|
409
|
+
if (!sd.missing) continue;
|
|
410
|
+
const side = sd.missing === 'before' ? 'after' : 'before';
|
|
411
|
+
console.log(`\n${sd.surface}: new surface — captured only in the ${side} set, no baseline to compare`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Group the changed surfaces the way the report does, so an identical change
|
|
415
|
+
// across N surfaces prints once (with the count), not N times.
|
|
416
|
+
const preparedForGrouping = surfaces
|
|
417
|
+
.filter((sd) => !sd.missing)
|
|
418
|
+
// Carry the RAW findings too, so we can report how many derived longhands the
|
|
419
|
+
// grouped view folded (the cleaned findings have them already removed).
|
|
420
|
+
.map((sd) => ({ surface: sd.surface, findings: cleanFindings(sd.findings), raw: sd.findings }))
|
|
421
|
+
.filter((p) => p.findings.length > 0);
|
|
422
|
+
|
|
423
|
+
function printGroup(cg) {
|
|
424
|
+
const lines = elementLines(cg.findings);
|
|
425
|
+
const derived = derivedLonghandCount(cg.rep.raw);
|
|
426
|
+
const foldNote = derived > 0 ? ` (+${derived} derived longhand${derived === 1 ? '' : 's'})` : '';
|
|
427
|
+
const others = cg.surfaces.length - 1;
|
|
428
|
+
const scope =
|
|
429
|
+
others > 0
|
|
430
|
+
? `${cg.rep.surface} (+${others} more surface${others === 1 ? '' : 's'}: ${formatSurfaceList(cg.surfaces)})`
|
|
431
|
+
: cg.rep.surface;
|
|
432
|
+
console.log(`\n${scope}: ${groupTitle(cg.findings)}${foldNote}`);
|
|
298
433
|
for (const line of lines.slice(0, MAX)) console.log(line);
|
|
299
434
|
if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
|
|
300
435
|
}
|
|
301
436
|
|
|
437
|
+
// Shared-chrome tier: a change that rode the frame every view draws (nav/header)
|
|
438
|
+
// gets one banner up top, then its detail — so the reviewer reads "the nav changed
|
|
439
|
+
// everywhere" once, not once per surface entry. Presentational only.
|
|
440
|
+
const grouped = groupBySignature(preparedForGrouping);
|
|
441
|
+
const { chrome, rest } = classifyChrome(grouped, surfacePaths);
|
|
442
|
+
if (chrome.length) {
|
|
443
|
+
// Base count from the pre-cleanup surface set (dirB may be deleted by now).
|
|
444
|
+
const bases = new Set([...surfacePaths.keys()].map(surfaceBase)).size;
|
|
445
|
+
console.log(
|
|
446
|
+
`\n🧱 Global chrome change(s) — across all ${bases} surface(s): ${chrome.length} change(s) rode the shared frame every view draws (a persistent nav, header, or footer).`,
|
|
447
|
+
);
|
|
448
|
+
for (const cg of chrome) printGroup(cg);
|
|
449
|
+
}
|
|
450
|
+
for (const cg of rest) printGroup(cg);
|
|
451
|
+
|
|
302
452
|
const invRemovals = printInventoryAudit(inventoryAudit);
|
|
453
|
+
const residueFails = printResidueAudit(residueAudit);
|
|
303
454
|
const coverageFails = printCoverageVerdict(coverageVerdict);
|
|
304
455
|
const determinismFails = printDeterminismVerdict(determinismVerdict);
|
|
305
456
|
|
|
@@ -335,6 +486,16 @@ if (jsonOut) {
|
|
|
335
486
|
inventoryNote:
|
|
336
487
|
'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
|
|
337
488
|
}),
|
|
489
|
+
// Additive data-residue field — the head bundle's failing data endpoints, parallel
|
|
490
|
+
// to inventory. `null` when nothing failed and the gate wasn't armed. `armed` says
|
|
491
|
+
// whether `unacknowledged` blocks; `blocking` is the CI-gating count.
|
|
492
|
+
dataResidue: residueAudit && {
|
|
493
|
+
armed: residueAudit.armed,
|
|
494
|
+
failing: residueAudit.residue.map((r) => r.key),
|
|
495
|
+
unacknowledged: residueAudit.unacknowledged.map((r) => r.key),
|
|
496
|
+
staleAcknowledgements: residueAudit.staleAcknowledgements,
|
|
497
|
+
blocking: residueFails,
|
|
498
|
+
},
|
|
338
499
|
},
|
|
339
500
|
null,
|
|
340
501
|
2,
|
|
@@ -352,17 +513,21 @@ const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
|
352
513
|
const surfaceCount = surfaces.length;
|
|
353
514
|
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
354
515
|
const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
|
|
516
|
+
// residueFails counts unacknowledged failing endpoints AND stale acknowledgements (both gate).
|
|
517
|
+
const resNote = residueFails ? ` + ${residueFails} data-residue gate failure(s) (unacknowledged or stale)` : '';
|
|
355
518
|
const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
|
|
356
519
|
const detNote = determinismFails ? ' + determinism unproven' : '';
|
|
357
|
-
const clean = total === 0 && invRemovals === 0 && !coverageFails && !determinismFails;
|
|
520
|
+
const clean = total === 0 && invRemovals === 0 && residueFails === 0 && !coverageFails && !determinismFails;
|
|
358
521
|
console.log(
|
|
359
522
|
clean
|
|
360
523
|
? newSurfaces === 0
|
|
361
524
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
362
525
|
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
363
|
-
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${covNote}${detNote}`,
|
|
526
|
+
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${resNote}${covNote}${detNote}`,
|
|
364
527
|
);
|
|
365
528
|
// 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals, an
|
|
366
|
-
//
|
|
367
|
-
// (no baseline). 2 = usage
|
|
368
|
-
process.exit(
|
|
529
|
+
// unacknowledged failing data endpoint under an armed residue gate, an incomplete coverage
|
|
530
|
+
// registry, or an unproven-determinism capture), 3 = only new surfaces (no baseline). 2 = usage.
|
|
531
|
+
process.exit(
|
|
532
|
+
total > 0 || invRemovals > 0 || residueFails > 0 || coverageFails || determinismFails ? 1 : newSurfaces > 0 ? 3 : 0,
|
|
533
|
+
);
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -19,13 +19,19 @@
|
|
|
19
19
|
* never disturbed or accidentally reused.
|
|
20
20
|
* - .github/workflows/styleproof.yml: restores reusable maps from the
|
|
21
21
|
* styleproof-maps branch and only captures in CI when the maps are missing.
|
|
22
|
+
* - .github/workflows/styleproof-approve.yml: the issue_comment handler that
|
|
23
|
+
* flips the StyleProof status when a reviewer ticks "Approve all changes".
|
|
24
|
+
* The report workflow runs with `require-approval: true`, so without this the
|
|
25
|
+
* approval checkbox is inert. GitHub only runs issue_comment workflows from the
|
|
26
|
+
* default branch, so it takes effect once the init PR merges.
|
|
22
27
|
*
|
|
23
28
|
* Idempotent: re-running never overwrites an existing spec (use --force) and
|
|
24
|
-
* never touches an existing app playwright.config.ts
|
|
25
|
-
* to do), 2 = usage error.
|
|
29
|
+
* never touches an existing app playwright.config.ts or an existing workflow.
|
|
30
|
+
* Exit 0 = done (or nothing to do), 2 = usage error.
|
|
26
31
|
*/
|
|
27
32
|
import fs from 'node:fs';
|
|
28
33
|
import path from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
29
35
|
// Import from the leaf module, not the barrel: styleproof-init only scaffolds
|
|
30
36
|
// files and never captures. Pulling `../dist/index.js` here dragged the whole
|
|
31
37
|
// library — capture, crawler, report, and six Playwright-importing modules —
|
|
@@ -53,6 +59,8 @@ What it writes:
|
|
|
53
59
|
the two diverge. Otherwise it writes one sample surface + a commented guard block.
|
|
54
60
|
- playwright.styleproof.config.ts, a dedicated production-build Playwright config
|
|
55
61
|
- .github/workflows/styleproof.yml, a cache-first PR report workflow
|
|
62
|
+
- .github/workflows/styleproof-approve.yml, the "Approve all changes" gate
|
|
63
|
+
(active once merged to your default branch)
|
|
56
64
|
|
|
57
65
|
After running, build and upload this commit's map outside CI when possible:
|
|
58
66
|
npx styleproof-map
|
|
@@ -549,6 +557,65 @@ if (ci.wrote) {
|
|
|
549
557
|
console.log(`${CI_PATH} already exists — left untouched`);
|
|
550
558
|
}
|
|
551
559
|
|
|
560
|
+
// Approval gate — the issue_comment handler that flips the StyleProof status when a
|
|
561
|
+
// reviewer ticks "Approve all changes". The report workflow above runs with
|
|
562
|
+
// `require-approval: true`, so this is what makes the checkbox live; without it the
|
|
563
|
+
// gate can never go green. It's a static workflow (no package-manager wiring), so we
|
|
564
|
+
// copy the packaged example verbatim rather than regenerate it. GitHub only runs
|
|
565
|
+
// issue_comment workflows from the DEFAULT branch, so it activates when the init PR
|
|
566
|
+
// merges — writing it to the feature branch now is correct and harmless.
|
|
567
|
+
const APPROVE_PATH = '.github/workflows/styleproof-approve.yml';
|
|
568
|
+
const approveSource = path.join(
|
|
569
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
570
|
+
'..',
|
|
571
|
+
'example',
|
|
572
|
+
'styleproof-approve.yml',
|
|
573
|
+
);
|
|
574
|
+
let approveWorkflow;
|
|
575
|
+
try {
|
|
576
|
+
approveWorkflow = fs.readFileSync(approveSource, 'utf8');
|
|
577
|
+
} catch {
|
|
578
|
+
// Packaged example missing (unexpected) — don't abort the rest of init.
|
|
579
|
+
console.warn(`could not read the approval workflow template at ${approveSource} — skipped`);
|
|
580
|
+
}
|
|
581
|
+
if (approveWorkflow !== undefined) {
|
|
582
|
+
const approve = writeFileSafe(APPROVE_PATH, approveWorkflow);
|
|
583
|
+
if (approve.wrote) {
|
|
584
|
+
touched.push(APPROVE_PATH);
|
|
585
|
+
console.log(`created ${APPROVE_PATH} (approval gate — active once merged to your default branch)`);
|
|
586
|
+
wroteSomething = true;
|
|
587
|
+
} else {
|
|
588
|
+
console.log(`${APPROVE_PATH} already exists — left untouched`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Pre-push publish hook — the default fast path. Capture locally at push time and
|
|
593
|
+
// publish to the SHA-keyed styleproof-maps branch; CI restores by SHA and stays
|
|
594
|
+
// report-only. Maps are NEVER committed to the PR branch: a shared tracked map path
|
|
595
|
+
// shows up in every PR's changed files and forces cross-PR rebases on each merge.
|
|
596
|
+
const HOOK = `#!/bin/sh
|
|
597
|
+
# StyleProof pre-push: capture this commit's map and publish it to the
|
|
598
|
+
# styleproof-maps branch, so CI restores it and reports without a browser.
|
|
599
|
+
# Maps never get committed to the PR branch.
|
|
600
|
+
# Skip (e.g. a docs-only push): STYLEPROOF_SKIP_CAPTURE=1 git push
|
|
601
|
+
set -e
|
|
602
|
+
[ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
|
|
603
|
+
${PM.exec(`styleproof-map --spec ${specPath}`)}
|
|
604
|
+
${PM.exec('styleproof-diff')} || true # advisory: show drift before CI does
|
|
605
|
+
`;
|
|
606
|
+
const hookDir = fs.existsSync('.husky') ? '.husky' : '.githooks';
|
|
607
|
+
const hookPath = path.join(hookDir, 'pre-push');
|
|
608
|
+
const hook = writeFileSafe(hookPath, HOOK);
|
|
609
|
+
if (hook.wrote) {
|
|
610
|
+
fs.chmodSync(hookPath, 0o755);
|
|
611
|
+
touched.push(hookPath);
|
|
612
|
+
console.log(`created ${hookPath} (pre-push capture → publish; maps never land on the PR branch)`);
|
|
613
|
+
if (hookDir === '.githooks') console.log(' activate with: git config core.hooksPath .githooks');
|
|
614
|
+
wroteSomething = true;
|
|
615
|
+
} else {
|
|
616
|
+
console.log(`${hookPath} already exists — left untouched`);
|
|
617
|
+
}
|
|
618
|
+
|
|
552
619
|
if (touched.length) {
|
|
553
620
|
// State exactly what init wrote, and — because adopters have blamed init for the
|
|
554
621
|
// `styleproof` entry their package manager's `install` added — say plainly that it
|
|
@@ -560,11 +627,12 @@ if (touched.length) {
|
|
|
560
627
|
console.log('\nHow the gate works — it runs on your first PR with no extra steps:');
|
|
561
628
|
console.log(' 1. Commit and open a PR. CI captures the base and head surfaces in one pinned');
|
|
562
629
|
console.log(' environment and posts the StyleProof report — no local step required.');
|
|
563
|
-
console.log(' 2.
|
|
564
|
-
console.log('
|
|
565
|
-
console.log('
|
|
566
|
-
console.log('
|
|
567
|
-
console.log('
|
|
630
|
+
console.log(' 2. The pre-push hook captures each pushed commit into .styleproof/ and publishes');
|
|
631
|
+
console.log(' the bundle to the styleproof-maps branch; CI restores it by SHA and generates');
|
|
632
|
+
console.log(' the report without a browser. Maps never get committed to the PR branch.');
|
|
633
|
+
console.log(' Skip a push that cannot affect render: STYLEPROOF_SKIP_CAPTURE=1 git push');
|
|
634
|
+
console.log(' 3. Merge this PR. The approval workflow only runs from your default branch, so');
|
|
635
|
+
console.log(' the "Approve all changes" checkbox goes live once styleproof-approve.yml is there.');
|
|
568
636
|
|
|
569
637
|
if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
|
|
570
638
|
process.exit(0);
|
package/bin/styleproof-map.mjs
CHANGED
|
@@ -254,11 +254,19 @@ function runVariantCrawl(env) {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
const targetDir = path.join(baseDir, dir);
|
|
257
|
+
// Sample the tree state the capture is ABOUT to render, so the manifest can bind the
|
|
258
|
+
// map to it. A capture runs for minutes; if the source is edited or HEAD moves in that
|
|
259
|
+
// window, the map renders one state but would otherwise be stamped clean@post-HEAD and
|
|
260
|
+
// published as the authoritative map for a SHA it never rendered — a stale map every
|
|
261
|
+
// future diff against that SHA silently trusts as a false green.
|
|
257
262
|
let dirtyBeforeCapture;
|
|
263
|
+
let headBeforeCapture;
|
|
258
264
|
try {
|
|
259
265
|
dirtyBeforeCapture = workingTreeDirty(process.cwd());
|
|
266
|
+
headBeforeCapture = currentGitSha(process.cwd());
|
|
260
267
|
} catch {
|
|
261
268
|
dirtyBeforeCapture = false;
|
|
269
|
+
headBeforeCapture = undefined;
|
|
262
270
|
}
|
|
263
271
|
|
|
264
272
|
if (restore) {
|
|
@@ -329,21 +337,27 @@ if (status === 0) {
|
|
|
329
337
|
);
|
|
330
338
|
process.exit(status);
|
|
331
339
|
}
|
|
340
|
+
// Bind the map to the commit it actually started rendering (headBeforeCapture), not a
|
|
341
|
+
// HEAD that may have moved mid-capture. `--sha` still wins for callers that know better.
|
|
342
|
+
const manifestSha = sha || headBeforeCapture || 'local';
|
|
343
|
+
// Re-check the tree AFTER capture (ignoring the maps this run just wrote): if the source
|
|
344
|
+
// was edited, or HEAD moved, during the capture window, the map↔SHA binding is a lie —
|
|
345
|
+
// mark it dirty so publishMapBundle refuses to push a stale map into the SHA-keyed store.
|
|
346
|
+
let dirty = dirtyBeforeCapture;
|
|
347
|
+
try {
|
|
348
|
+
const rel = path.relative(process.cwd(), targetDir) || targetDir;
|
|
349
|
+
const headAfter = currentGitSha(process.cwd(), env);
|
|
350
|
+
if (workingTreeDirty(process.cwd(), rel) || (headBeforeCapture && headAfter !== headBeforeCapture)) dirty = true;
|
|
351
|
+
} catch {
|
|
352
|
+
// git unreadable now — keep the pre-capture verdict rather than guess
|
|
353
|
+
}
|
|
332
354
|
try {
|
|
333
|
-
let manifestSha = sha || undefined;
|
|
334
|
-
if (!manifestSha) {
|
|
335
|
-
try {
|
|
336
|
-
manifestSha = currentGitSha(process.cwd(), env);
|
|
337
|
-
} catch {
|
|
338
|
-
manifestSha = 'local';
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
355
|
const manifest = writeMapManifest({
|
|
342
356
|
dir: targetDir,
|
|
343
357
|
spec,
|
|
344
358
|
sha: manifestSha,
|
|
345
359
|
screenshots: screenshots !== '0',
|
|
346
|
-
dirty
|
|
360
|
+
dirty,
|
|
347
361
|
env,
|
|
348
362
|
});
|
|
349
363
|
console.error(`styleproof-map: wrote ${targetDir} for ${manifest.sha.slice(0, 12)} (${manifest.compatibilityKey})`);
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
DEFAULT_REMOTE,
|
|
19
19
|
assertCompatibleMapDirs,
|
|
20
20
|
cleanupCachedCaptureDirs,
|
|
21
|
+
manifestlessError,
|
|
22
|
+
manifestlessSide,
|
|
21
23
|
resolveCachedCaptureDirs,
|
|
22
24
|
} from '../dist/map-store.js';
|
|
23
25
|
|
|
@@ -146,6 +148,10 @@ if (foldDetailsAt !== undefined && Number.isNaN(foldDetailsAt)) {
|
|
|
146
148
|
|
|
147
149
|
let result;
|
|
148
150
|
try {
|
|
151
|
+
// v4: refuse a manifest-less side (exit 2 via the catch) — same-environment
|
|
152
|
+
// compatibility can't be verified without a manifest on both sides.
|
|
153
|
+
const manifestless = manifestlessSide(beforeDir, afterDir);
|
|
154
|
+
if (manifestless) throw new Error(manifestlessError(manifestless));
|
|
149
155
|
assertCompatibleMapDirs(beforeDir, afterDir);
|
|
150
156
|
result = generateStyleMapReport({
|
|
151
157
|
beforeDir,
|
package/dist/capture-url.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
4
4
|
import { detectViewportWidths } from './breakpoints.js';
|
|
5
5
|
import { runSetup } from './crawl-surfaces.js';
|
|
6
|
+
import { writeCaptureManifest } from './map-store.js';
|
|
6
7
|
/**
|
|
7
8
|
* One-shot capture of a single URL's computed-style map — no spec, no config,
|
|
8
9
|
* no git. `defineStyleMapCapture` is the right tool when the surfaces live in
|
|
@@ -192,6 +193,10 @@ export async function captureUrlToDir(page, opts) {
|
|
|
192
193
|
requests.dispose();
|
|
193
194
|
}
|
|
194
195
|
}
|
|
196
|
+
// Stamp a manifest so `styleproof-diff <thisDir> <build>` has the same-environment
|
|
197
|
+
// guard on both sides — v4 refuses to compare a manifest-less side. May run outside
|
|
198
|
+
// a git repo (a design mockup); the git fields degrade gracefully.
|
|
199
|
+
writeCaptureManifest({ dir: opts.out, screenshots: opts.screenshots });
|
|
195
200
|
return results;
|
|
196
201
|
}
|
|
197
202
|
/** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
|
package/dist/capture.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Page } from '@playwright/test';
|
|
2
2
|
import { type NavigableItem } from './inventory.js';
|
|
3
|
+
import { type DataResidueEntry } from './data-residue.js';
|
|
3
4
|
/**
|
|
4
5
|
* Computed-style capture: the browser's final resolved value for every CSS
|
|
5
6
|
* longhand on every element, keyed by DOM structure (never by class name, so
|
|
@@ -132,6 +133,15 @@ export type StyleMap = {
|
|
|
132
133
|
* by the certification diff. See docs/inventory-guard.md.
|
|
133
134
|
*/
|
|
134
135
|
inventory?: NavigableItem[];
|
|
136
|
+
/**
|
|
137
|
+
* Data-boundary requests (matching `replayUrl`) that FAILED during this capture —
|
|
138
|
+
* a network error or a 4xx/5xx. Their presence means the captured state renders the
|
|
139
|
+
* endpoint's FALLBACK branch, so states driven by its real responses are uncaptured
|
|
140
|
+
* and unproven. Present only when the residue guard was armed (`dataResidue` set) and
|
|
141
|
+
* a failure actually occurred; absent (byte-identical to before) on a clean capture.
|
|
142
|
+
* Surfaced by the data-residue guard, never by the certification diff. See issue #205.
|
|
143
|
+
*/
|
|
144
|
+
dataResidue?: DataResidueEntry[];
|
|
135
145
|
};
|
|
136
146
|
export type CaptureOptions = {
|
|
137
147
|
/**
|
|
@@ -248,6 +258,39 @@ export declare function trackInflightRequests(page: Page): {
|
|
|
248
258
|
pending: () => number;
|
|
249
259
|
dispose: () => void;
|
|
250
260
|
};
|
|
261
|
+
/**
|
|
262
|
+
* Watch the data boundary (`url`, the same `replayUrl` glob record/replay uses) for
|
|
263
|
+
* requests that FAIL during capture — a network-level failure or a 4xx/5xx response.
|
|
264
|
+
* A failing data request means the captured state renders that endpoint's FALLBACK
|
|
265
|
+
* branch; the response-driven state is uncaptured and unproven (issue #205).
|
|
266
|
+
*
|
|
267
|
+
* Matches purely by listening to `requestfailed`/`response` and testing the URL against
|
|
268
|
+
* the boundary glob — NOT via a `page.route`. A passive route can't be used to tag: the
|
|
269
|
+
* tracker is armed before `go()`, and a route the surface itself adds in `go()` (an abort
|
|
270
|
+
* fixture, the HAR replay route) runs first and never falls through to a tag route added
|
|
271
|
+
* earlier, so tagged requests would be missed. Listeners see every request regardless of
|
|
272
|
+
* routing, which is exactly what a residue observer needs. Arm BEFORE navigation, like
|
|
273
|
+
* {@link trackInflightRequests}, so the page's own load fetches are seen; deduped per
|
|
274
|
+
* `<surface>·<endpoint>` so the same failure across widths / a self-check re-run is one entry.
|
|
275
|
+
*
|
|
276
|
+
* ONLY failures are recorded — never a 2xx that merely wasn't fixtured: in recording mode
|
|
277
|
+
* every live 2xx is legitimately recorded, so a blanket "uncontrolled" flag would fire on
|
|
278
|
+
* every healthy record run (issue #205, "deliberately out of scope").
|
|
279
|
+
*/
|
|
280
|
+
export declare function trackDataResidue(page: Page, url: string, surface: string): {
|
|
281
|
+
residue: () => DataResidueEntry[];
|
|
282
|
+
dispose: () => void;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* A predicate matching a URL against a Playwright-style URL glob — the same micro-syntax
|
|
286
|
+
* `page.route`/`routeFromHAR` accept for `replayUrl`, replicated because Playwright exposes
|
|
287
|
+
* no public matcher and reaching into its bundled internals is fragile across versions.
|
|
288
|
+
* `**` spans path separators, `*` matches within a segment (not `/`), `?` is a literal
|
|
289
|
+
* (URL globs, unlike shell globs, treat `?` as the query delimiter), and `{a,b}` alternates.
|
|
290
|
+
* A plain (glob-char-free) string is treated as a substring match, matching Playwright's
|
|
291
|
+
* own "contains" fallback for non-glob route URLs.
|
|
292
|
+
*/
|
|
293
|
+
export declare function urlMatcher(glob: string): (url: string) => boolean;
|
|
251
294
|
export declare function captureStyleMap(page: Page, options?: CaptureOptions): Promise<StyleMap>;
|
|
252
295
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
|
253
296
|
export declare function saveStyleMap(filePath: string, map: StyleMap): void;
|
|
@@ -261,4 +304,20 @@ export declare function loadStyleMap(filePath: string): StyleMap;
|
|
|
261
304
|
export declare function readInventories(dir: string): Array<{
|
|
262
305
|
inventory?: NavigableItem[];
|
|
263
306
|
}>;
|
|
307
|
+
/**
|
|
308
|
+
* Read every surface map's `dataResidue` from a capture dir, in the shape the
|
|
309
|
+
* data-residue audit consumes. Lives here (not in data-residue.ts) so that module
|
|
310
|
+
* stays a pure leaf — it must not import `loadStyleMap` back from this file.
|
|
311
|
+
*/
|
|
312
|
+
export declare function readResidue(dir: string): Array<{
|
|
313
|
+
dataResidue?: DataResidueEntry[];
|
|
314
|
+
}>;
|
|
315
|
+
/**
|
|
316
|
+
* Each captured surface key → the set of element paths it renders, unioned across
|
|
317
|
+
* the given dirs (so an element present on only one side still "belongs" to the
|
|
318
|
+
* surface). Feeds the shared-chrome tier, which needs to know, per surface, which
|
|
319
|
+
* paths exist to tell a frame-wide change from one view's content. Shared by the
|
|
320
|
+
* report and the diff CLI (both already read maps this way).
|
|
321
|
+
*/
|
|
322
|
+
export declare function surfaceElementPaths(...dirs: string[]): Map<string, Set<string>>;
|
|
264
323
|
export {};
|