styleproof 3.21.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 +138 -0
- package/README.md +437 -321
- package/bin/styleproof-capture.mjs +4 -0
- package/bin/styleproof-diff.mjs +96 -6
- 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 +51 -0
- package/dist/capture.js +105 -5
- 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.js +31 -2
- 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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** One data-boundary request that FAILED during capture — an embedded fallback branch. */
|
|
2
|
+
export type DataResidueEntry = {
|
|
3
|
+
/**
|
|
4
|
+
* Stable identity across captures: `<surface>·<endpoint>` where `endpoint` is the
|
|
5
|
+
* request URL's `pathname` (query stripped so `?all=1` vs `?all=2` don't fork the
|
|
6
|
+
* key). Escaped so it can't inject Markdown into the report/PR-comment summary.
|
|
7
|
+
*/
|
|
8
|
+
key: string;
|
|
9
|
+
/** The captured surface key this failure was observed on. */
|
|
10
|
+
surface: string;
|
|
11
|
+
/** The failing endpoint's URL pathname (query stripped). */
|
|
12
|
+
endpoint: string;
|
|
13
|
+
/** Why it failed: a network error text (`net::ERR_CONNECTION_REFUSED`) or `HTTP 503`. */
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
/** `key -> reason` — failing endpoints that are intentional/known and on the record. */
|
|
17
|
+
export type AcknowledgedResidue = Record<string, string>;
|
|
18
|
+
/** Acknowledgement file, parallel to the inventory guard's `styleproof.inventory.json`. */
|
|
19
|
+
export declare const DATA_RESIDUE_ACK_FILE = "styleproof.data-residue.json";
|
|
20
|
+
/**
|
|
21
|
+
* Read the acknowledged-residue file (`$STYLEPROOF_DATA_RESIDUE` or
|
|
22
|
+
* `styleproof.data-residue.json`). `{}` when absent; THROWS on malformed JSON — the
|
|
23
|
+
* caller picks the policy (the CI gate fails loud so a broken ack file can't silently
|
|
24
|
+
* un-acknowledge a real failure; the advisory report degrades to `{}`). Mirrors
|
|
25
|
+
* `readAckFile` in the inventory guard exactly.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readResidueAckFile(): AcknowledgedResidue;
|
|
28
|
+
/** Build a residue key from a surface and an endpoint URL. Query stripped, escaped. */
|
|
29
|
+
export declare function residueKey(surface: string, endpoint: string): string;
|
|
30
|
+
/** The endpoint pathname a URL resolves to, query stripped. Falls back to the raw URL. */
|
|
31
|
+
export declare function endpointOf(url: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Union the per-surface `map.dataResidue` of a whole run into one deduped set, keyed by
|
|
34
|
+
* `key` (surface·endpoint), so the same failure seen across widths / a self-check re-run
|
|
35
|
+
* is ONE entry, not a spray. Sorted for a stable rendering order.
|
|
36
|
+
*/
|
|
37
|
+
export declare function unionResidue(perSurface: Array<{
|
|
38
|
+
dataResidue?: DataResidueEntry[];
|
|
39
|
+
} | undefined>): DataResidueEntry[];
|
|
40
|
+
export type ResidueAudit = {
|
|
41
|
+
/** Every failing endpoint observed across the run (union, deduped). */
|
|
42
|
+
residue: DataResidueEntry[];
|
|
43
|
+
/** Failing endpoints NOT acknowledged in the ledger — the gate fails on a non-empty set. */
|
|
44
|
+
unacknowledged: DataResidueEntry[];
|
|
45
|
+
/** Acknowledged keys no longer present in the residue (the endpoint is fixtured/gone) —
|
|
46
|
+
* a rotted opt-out, so the ledger can't quietly rot (mirrors the `exclude` guard). */
|
|
47
|
+
staleAcknowledgements: string[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The gate. A failing endpoint whose key isn't in `acknowledged` (key -> reason) is
|
|
51
|
+
* unacknowledged — the caller fails on a non-empty result. An `acknowledged` key that
|
|
52
|
+
* isn't actually failing is stale, returned separately so the ledger can't rot. Unlike
|
|
53
|
+
* the inventory guard (a base-vs-head REMOVAL), residue is present-on-HEAD: the head
|
|
54
|
+
* capture's failing endpoints are audited directly, so only the head maps matter.
|
|
55
|
+
*/
|
|
56
|
+
export declare function auditResidue(headMaps: Array<{
|
|
57
|
+
dataResidue?: DataResidueEntry[];
|
|
58
|
+
} | undefined>, acknowledged?: AcknowledgedResidue): ResidueAudit;
|
|
59
|
+
/**
|
|
60
|
+
* Run-level entry point for a gate/report: audit the HEAD bundle's residue against the
|
|
61
|
+
* acknowledgement ledger, carrying whether the guard was ARMED to gate. `armed` comes
|
|
62
|
+
* from the head coverage ledger's `dataResidue: 'gate'` (the default; only an explicit
|
|
63
|
+
* `'warn'` — or an older bundle with no field — is unarmed). When not armed, the caller
|
|
64
|
+
* still surfaces residue (warn is the explicit opt-out) but must not block.
|
|
65
|
+
*/
|
|
66
|
+
export declare function auditRunResidue(headMaps: Array<{
|
|
67
|
+
dataResidue?: DataResidueEntry[];
|
|
68
|
+
} | undefined>, acknowledged: AcknowledgedResidue, armed: boolean): ResidueAudit & {
|
|
69
|
+
armed: boolean;
|
|
70
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Data-residue guard — name the data-boundary requests that FAILED during capture.
|
|
2
|
+
//
|
|
3
|
+
// The certification diff proves "did surface X change?" but is structurally blind to
|
|
4
|
+
// a whole class of high-stakes miss: a surface requests a data endpoint that nothing
|
|
5
|
+
// controls — no fixture routes it, so it falls through and FAILS (network error, or a
|
|
6
|
+
// 4xx/5xx) — and the view silently renders its FALLBACK branch. Capture after capture
|
|
7
|
+
// embeds that fallback state; the response-driven state its real data would produce is
|
|
8
|
+
// never captured, so a restyle confined to that state ships green. StyleProof's
|
|
9
|
+
// request tracker watched the request fail every time and said nothing.
|
|
10
|
+
//
|
|
11
|
+
// This module records, per surface, any request matching the data boundary (the
|
|
12
|
+
// `replayUrl` glob) that failed or errored. The residue travels on the StyleMap (like
|
|
13
|
+
// `inventory`), so the diff/report can surface it; a stderr warning names it at capture
|
|
14
|
+
// time; and the gate (on by default, opted down via `dataResidue: 'warn'`) blocks an
|
|
15
|
+
// unacknowledged failing endpoint — mirroring the `exclude`/inventory-ack discipline.
|
|
16
|
+
//
|
|
17
|
+
// Observing that a data request failed needs NO app knowledge — it's the same move as
|
|
18
|
+
// the unreadable-stylesheet residue: convert silent blindness into a name. Declaring
|
|
19
|
+
// the app's data STATES stays app-owned (see issue #202); this only observes failures.
|
|
20
|
+
//
|
|
21
|
+
// Deliberately OUT OF SCOPE (unsound or noisy):
|
|
22
|
+
// - Flagging endpoints that responded 2xx but weren't fixtured. In recording mode
|
|
23
|
+
// every live response is legitimately recorded, so a blanket "uncontrolled" flag
|
|
24
|
+
// would fire on every healthy record run. A sound record-fulfilled-vs-network
|
|
25
|
+
// discriminator (if Playwright exposes one) could be a follow-up; we do not ship a
|
|
26
|
+
// heuristic version. Only FAILED requests (network error / 4xx / 5xx) are residue.
|
|
27
|
+
// - Synthesising payloads or surfaces for un-exercised response variants (app
|
|
28
|
+
// knowledge; issue #202's territory).
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { safeKey } from './change-groups.js';
|
|
32
|
+
/** Acknowledgement file, parallel to the inventory guard's `styleproof.inventory.json`. */
|
|
33
|
+
export const DATA_RESIDUE_ACK_FILE = 'styleproof.data-residue.json';
|
|
34
|
+
/**
|
|
35
|
+
* Read the acknowledged-residue file (`$STYLEPROOF_DATA_RESIDUE` or
|
|
36
|
+
* `styleproof.data-residue.json`). `{}` when absent; THROWS on malformed JSON — the
|
|
37
|
+
* caller picks the policy (the CI gate fails loud so a broken ack file can't silently
|
|
38
|
+
* un-acknowledge a real failure; the advisory report degrades to `{}`). Mirrors
|
|
39
|
+
* `readAckFile` in the inventory guard exactly.
|
|
40
|
+
*/
|
|
41
|
+
export function readResidueAckFile() {
|
|
42
|
+
const p = path.resolve(process.env.STYLEPROOF_DATA_RESIDUE ?? DATA_RESIDUE_ACK_FILE);
|
|
43
|
+
if (!fs.existsSync(p))
|
|
44
|
+
return {};
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
throw new Error(`${p} is not valid JSON — ${e.message}`, { cause: e });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Build a residue key from a surface and an endpoint URL. Query stripped, escaped. */
|
|
53
|
+
export function residueKey(surface, endpoint) {
|
|
54
|
+
return `${safeKey(surface)}·${safeKey(endpoint)}`;
|
|
55
|
+
}
|
|
56
|
+
/** The endpoint pathname a URL resolves to, query stripped. Falls back to the raw URL. */
|
|
57
|
+
export function endpointOf(url) {
|
|
58
|
+
try {
|
|
59
|
+
return new URL(url).pathname;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ── pure union / audit / reconciliation ─────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* Union the per-surface `map.dataResidue` of a whole run into one deduped set, keyed by
|
|
68
|
+
* `key` (surface·endpoint), so the same failure seen across widths / a self-check re-run
|
|
69
|
+
* is ONE entry, not a spray. Sorted for a stable rendering order.
|
|
70
|
+
*/
|
|
71
|
+
export function unionResidue(perSurface) {
|
|
72
|
+
const byKey = new Map();
|
|
73
|
+
for (const map of perSurface) {
|
|
74
|
+
for (const entry of map?.dataResidue ?? [])
|
|
75
|
+
if (!byKey.has(entry.key))
|
|
76
|
+
byKey.set(entry.key, entry);
|
|
77
|
+
}
|
|
78
|
+
return Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The gate. A failing endpoint whose key isn't in `acknowledged` (key -> reason) is
|
|
82
|
+
* unacknowledged — the caller fails on a non-empty result. An `acknowledged` key that
|
|
83
|
+
* isn't actually failing is stale, returned separately so the ledger can't rot. Unlike
|
|
84
|
+
* the inventory guard (a base-vs-head REMOVAL), residue is present-on-HEAD: the head
|
|
85
|
+
* capture's failing endpoints are audited directly, so only the head maps matter.
|
|
86
|
+
*/
|
|
87
|
+
export function auditResidue(headMaps, acknowledged = {}) {
|
|
88
|
+
const residue = unionResidue(headMaps);
|
|
89
|
+
const residueKeys = new Set(residue.map((r) => r.key));
|
|
90
|
+
return {
|
|
91
|
+
residue,
|
|
92
|
+
unacknowledged: residue.filter((r) => !(r.key in acknowledged)),
|
|
93
|
+
staleAcknowledgements: Object.keys(acknowledged).filter((k) => !residueKeys.has(k)),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run-level entry point for a gate/report: audit the HEAD bundle's residue against the
|
|
98
|
+
* acknowledgement ledger, carrying whether the guard was ARMED to gate. `armed` comes
|
|
99
|
+
* from the head coverage ledger's `dataResidue: 'gate'` (the default; only an explicit
|
|
100
|
+
* `'warn'` — or an older bundle with no field — is unarmed). When not armed, the caller
|
|
101
|
+
* still surfaces residue (warn is the explicit opt-out) but must not block.
|
|
102
|
+
*/
|
|
103
|
+
export function auditRunResidue(headMaps, acknowledged, armed) {
|
|
104
|
+
return { armed, ...auditResidue(headMaps, acknowledged) };
|
|
105
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests, trackDataResidue, urlMatcher, } from './capture.js';
|
|
2
2
|
export * from './inventory.js';
|
|
3
|
+
export * from './data-residue.js';
|
|
3
4
|
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
4
5
|
export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
|
|
5
6
|
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests, trackDataResidue, urlMatcher, } from './capture.js';
|
|
2
2
|
export * from './inventory.js';
|
|
3
|
+
export * from './data-residue.js';
|
|
3
4
|
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
4
5
|
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
5
6
|
export { loadSetupSteps } from './capture-url.js';
|
package/dist/map-store.d.ts
CHANGED
|
@@ -61,7 +61,12 @@ export declare function expectedCompatibilityKey(options?: {
|
|
|
61
61
|
}): string;
|
|
62
62
|
export declare function currentGitSha(cwd?: string, env?: NodeJS.ProcessEnv): string;
|
|
63
63
|
export declare function refSha(ref: string, cwd?: string): string;
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* True if any tracked file is modified/added/deleted. `ignorePrefix` (a repo-relative
|
|
66
|
+
* directory) is excluded — pass the map OUTPUT dir when re-sampling AFTER a capture, so
|
|
67
|
+
* the maps the capture just wrote don't read as tree dirt and mask a real source edit.
|
|
68
|
+
*/
|
|
69
|
+
export declare function workingTreeDirty(cwd?: string, ignorePrefix?: string): boolean;
|
|
65
70
|
export declare function remoteExists(remote?: string, cwd?: string): boolean;
|
|
66
71
|
export declare function writeMapManifest(options: {
|
|
67
72
|
dir: string;
|
|
@@ -72,7 +77,38 @@ export declare function writeMapManifest(options: {
|
|
|
72
77
|
cwd?: string;
|
|
73
78
|
env?: NodeJS.ProcessEnv;
|
|
74
79
|
}): MapManifest;
|
|
80
|
+
/**
|
|
81
|
+
* Write a `styleproof-manifest.json` for a one-shot `styleproof-capture` output dir,
|
|
82
|
+
* so a two-directory `styleproof-diff design <build>` has the same-environment guard
|
|
83
|
+
* on both sides (v4 refuses to compare a manifest-less side). Unlike
|
|
84
|
+
* {@link writeMapManifest}, this may run OUTSIDE a git repo (a design mockup, a static
|
|
85
|
+
* export), so the git-derived fields degrade gracefully: `sha` falls back to
|
|
86
|
+
* `'uncommitted'` and `dirty` to `true` rather than throwing. The parts the guard
|
|
87
|
+
* actually consumes — `compatibilityKey`, `platform`/`arch`/`nodeMajor`,
|
|
88
|
+
* `playwrightVersion`, `browserVersion`, `baseUrl` — are recorded the same way as a
|
|
89
|
+
* spec capture. Overwrites any existing manifest in `dir`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function writeCaptureManifest(options: {
|
|
92
|
+
dir: string;
|
|
93
|
+
screenshots: boolean;
|
|
94
|
+
cwd?: string;
|
|
95
|
+
env?: NodeJS.ProcessEnv;
|
|
96
|
+
}): MapManifest;
|
|
75
97
|
export declare function readMapManifest(dir: string): MapManifest | null;
|
|
98
|
+
/** Which side(s) of a two-directory compare hold captured maps but NO
|
|
99
|
+
* `styleproof-manifest.json` — a legacy committed-map bundle. Since v4 that is
|
|
100
|
+
* unsupported: without a manifest the same-environment guard can't be enforced, so the
|
|
101
|
+
* CLI refuses (exit 2). `null` means every side WITH maps also has a manifest (nothing to
|
|
102
|
+
* refuse). A side with zero maps is NOT flagged — an empty/bare dir is "no baseline yet",
|
|
103
|
+
* handled by the base/head-missing guards, not this one. Pure: presence reads only, so the
|
|
104
|
+
* CLI layer owns the exit code and the library stays side-effect-free. */
|
|
105
|
+
export declare function manifestlessSide(beforeDir: string, afterDir: string): 'before' | 'after' | 'both' | null;
|
|
106
|
+
/** Fail-loud message for a manifest-less compare. Since v4 a side without a
|
|
107
|
+
* `styleproof-manifest.json` is unsupported: the same-environment guard can't be
|
|
108
|
+
* enforced, so captures from different browser builds or platforms would diff as
|
|
109
|
+
* false changes. The CLI raises this and exits 2 (usage/capture error) — the
|
|
110
|
+
* legacy "compare anyway" tolerance is gone. */
|
|
111
|
+
export declare function manifestlessError(side: 'before' | 'after' | 'both'): string;
|
|
76
112
|
export declare function assertCompatibleMapDirs(beforeDir: string, afterDir: string): void;
|
|
77
113
|
export declare function publishMapBundle(options: {
|
|
78
114
|
dir: string;
|
package/dist/map-store.js
CHANGED
|
@@ -153,28 +153,40 @@ export function refSha(ref, cwd = process.cwd()) {
|
|
|
153
153
|
throw new MapStoreError(`could not resolve ${ref} to a commit`);
|
|
154
154
|
return sha;
|
|
155
155
|
}
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* True if any tracked file is modified/added/deleted. `ignorePrefix` (a repo-relative
|
|
158
|
+
* directory) is excluded — pass the map OUTPUT dir when re-sampling AFTER a capture, so
|
|
159
|
+
* the maps the capture just wrote don't read as tree dirt and mask a real source edit.
|
|
160
|
+
*/
|
|
161
|
+
export function workingTreeDirty(cwd = process.cwd(), ignorePrefix) {
|
|
157
162
|
const r = runGit(cwd, ['status', '--porcelain']);
|
|
158
163
|
const status = r.status === 0 ? r.stdout.trimEnd() : '';
|
|
159
164
|
if (!status)
|
|
160
165
|
return false;
|
|
166
|
+
const prefix = ignorePrefix ? `${ignorePrefix.replace(/\/+$/, '')}/` : undefined;
|
|
161
167
|
return status.split(/\r?\n/).some((line) => {
|
|
162
168
|
const file = line.slice(3).trim();
|
|
163
|
-
|
|
169
|
+
if (!file || GENERATED_DIRTY_ALLOWLIST.has(file))
|
|
170
|
+
return false;
|
|
171
|
+
if (prefix && (file === prefix.slice(0, -1) || file.startsWith(prefix)))
|
|
172
|
+
return false;
|
|
173
|
+
return true;
|
|
164
174
|
});
|
|
165
175
|
}
|
|
166
176
|
export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
|
|
167
177
|
return runGit(cwd, ['remote', 'get-url', remote], 1 << 20).status === 0;
|
|
168
178
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
179
|
+
/** Assemble a {@link MapManifest} from the compatibility inputs and the caller-resolved
|
|
180
|
+
* git fields. Shared by {@link writeMapManifest} (spec capture) and
|
|
181
|
+
* {@link writeCaptureManifest} (one-shot capture) so the object shape lives in one place. */
|
|
182
|
+
function buildManifest(options) {
|
|
183
|
+
const { dir, input } = options;
|
|
184
|
+
const browserVersion = readBrowserBuildSidecar(dir);
|
|
185
|
+
return {
|
|
174
186
|
version: 1,
|
|
175
187
|
packageVersion: input.packageVersion,
|
|
176
|
-
sha: options.sha
|
|
177
|
-
dirty: options.dirty
|
|
188
|
+
sha: options.sha,
|
|
189
|
+
dirty: options.dirty,
|
|
178
190
|
spec: input.spec,
|
|
179
191
|
specHash: input.specHash,
|
|
180
192
|
...(input.lockfile ? { lockfile: input.lockfile } : {}),
|
|
@@ -186,10 +198,49 @@ export function writeMapManifest(options) {
|
|
|
186
198
|
nodeMajor: input.nodeMajor,
|
|
187
199
|
...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
|
|
188
200
|
screenshots: options.screenshots,
|
|
189
|
-
har: hasHar(
|
|
201
|
+
har: hasHar(dir),
|
|
190
202
|
compatibilityKey: hash(JSON.stringify(input)).slice(0, 16),
|
|
191
203
|
createdAt: new Date().toISOString(),
|
|
192
204
|
};
|
|
205
|
+
}
|
|
206
|
+
export function writeMapManifest(options) {
|
|
207
|
+
const cwd = options.cwd ?? process.cwd();
|
|
208
|
+
const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
|
|
209
|
+
const manifest = buildManifest({
|
|
210
|
+
dir: options.dir,
|
|
211
|
+
input,
|
|
212
|
+
sha: options.sha ?? currentGitSha(cwd, options.env),
|
|
213
|
+
dirty: options.dirty ?? workingTreeDirty(cwd),
|
|
214
|
+
screenshots: options.screenshots,
|
|
215
|
+
});
|
|
216
|
+
fs.writeFileSync(path.join(options.dir, MAP_MANIFEST), JSON.stringify(manifest, null, 2));
|
|
217
|
+
return manifest;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Write a `styleproof-manifest.json` for a one-shot `styleproof-capture` output dir,
|
|
221
|
+
* so a two-directory `styleproof-diff design <build>` has the same-environment guard
|
|
222
|
+
* on both sides (v4 refuses to compare a manifest-less side). Unlike
|
|
223
|
+
* {@link writeMapManifest}, this may run OUTSIDE a git repo (a design mockup, a static
|
|
224
|
+
* export), so the git-derived fields degrade gracefully: `sha` falls back to
|
|
225
|
+
* `'uncommitted'` and `dirty` to `true` rather than throwing. The parts the guard
|
|
226
|
+
* actually consumes — `compatibilityKey`, `platform`/`arch`/`nodeMajor`,
|
|
227
|
+
* `playwrightVersion`, `browserVersion`, `baseUrl` — are recorded the same way as a
|
|
228
|
+
* spec capture. Overwrites any existing manifest in `dir`.
|
|
229
|
+
*/
|
|
230
|
+
export function writeCaptureManifest(options) {
|
|
231
|
+
const cwd = options.cwd ?? process.cwd();
|
|
232
|
+
// A one-shot capture has no spec file; key comparability off the capture inputs only.
|
|
233
|
+
const input = compatibilityInput({ cwd, spec: MAP_MANIFEST, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
|
|
234
|
+
// Degrade the git fields gracefully outside a repo (a design mockup): no HEAD → uncommitted/dirty.
|
|
235
|
+
const sha = gitOutput(cwd, ['rev-parse', 'HEAD']) || 'uncommitted';
|
|
236
|
+
const manifest = buildManifest({
|
|
237
|
+
dir: options.dir,
|
|
238
|
+
input,
|
|
239
|
+
sha,
|
|
240
|
+
dirty: sha === 'uncommitted' ? true : workingTreeDirty(cwd),
|
|
241
|
+
screenshots: options.screenshots,
|
|
242
|
+
});
|
|
243
|
+
fs.mkdirSync(options.dir, { recursive: true });
|
|
193
244
|
fs.writeFileSync(path.join(options.dir, MAP_MANIFEST), JSON.stringify(manifest, null, 2));
|
|
194
245
|
return manifest;
|
|
195
246
|
}
|
|
@@ -201,6 +252,48 @@ export function readMapManifest(dir) {
|
|
|
201
252
|
return null;
|
|
202
253
|
}
|
|
203
254
|
}
|
|
255
|
+
/** True if `dir` holds at least one captured surface map (`<key>@<width>.json[.gz]`),
|
|
256
|
+
* ignoring metadata sidecars. Distinguishes a real capture that merely lacks a manifest
|
|
257
|
+
* (a legacy committed-map bundle — v4 refuses it) from an empty/bare dir (no baseline
|
|
258
|
+
* yet — the missing-map guards own that). */
|
|
259
|
+
function dirHasMaps(dir) {
|
|
260
|
+
try {
|
|
261
|
+
return fs.readdirSync(dir).some(isMapFile);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** Which side(s) of a two-directory compare hold captured maps but NO
|
|
268
|
+
* `styleproof-manifest.json` — a legacy committed-map bundle. Since v4 that is
|
|
269
|
+
* unsupported: without a manifest the same-environment guard can't be enforced, so the
|
|
270
|
+
* CLI refuses (exit 2). `null` means every side WITH maps also has a manifest (nothing to
|
|
271
|
+
* refuse). A side with zero maps is NOT flagged — an empty/bare dir is "no baseline yet",
|
|
272
|
+
* handled by the base/head-missing guards, not this one. Pure: presence reads only, so the
|
|
273
|
+
* CLI layer owns the exit code and the library stays side-effect-free. */
|
|
274
|
+
export function manifestlessSide(beforeDir, afterDir) {
|
|
275
|
+
const before = dirHasMaps(beforeDir) && readMapManifest(beforeDir) == null;
|
|
276
|
+
const after = dirHasMaps(afterDir) && readMapManifest(afterDir) == null;
|
|
277
|
+
if (before && after)
|
|
278
|
+
return 'both';
|
|
279
|
+
if (before)
|
|
280
|
+
return 'before';
|
|
281
|
+
if (after)
|
|
282
|
+
return 'after';
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
/** Fail-loud message for a manifest-less compare. Since v4 a side without a
|
|
286
|
+
* `styleproof-manifest.json` is unsupported: the same-environment guard can't be
|
|
287
|
+
* enforced, so captures from different browser builds or platforms would diff as
|
|
288
|
+
* false changes. The CLI raises this and exits 2 (usage/capture error) — the
|
|
289
|
+
* legacy "compare anyway" tolerance is gone. */
|
|
290
|
+
export function manifestlessError(side) {
|
|
291
|
+
const carry = side === 'both' ? 'before and after carry' : `${side} carries`;
|
|
292
|
+
return (`styleproof: ${carry} no ${MAP_MANIFEST} — environment compatibility can't be verified, so ` +
|
|
293
|
+
'captures from different browser builds or platforms would diff as false changes. ' +
|
|
294
|
+
'Re-capture with current StyleProof (styleproof-map, or styleproof-capture for a one-shot ' +
|
|
295
|
+
'diff); maps without a manifest are unsupported since v4.');
|
|
296
|
+
}
|
|
204
297
|
export function assertCompatibleMapDirs(beforeDir, afterDir) {
|
|
205
298
|
const before = readMapManifest(beforeDir);
|
|
206
299
|
const after = readMapManifest(afterDir);
|
package/dist/report.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
|
-
import { loadStyleMap, readInventories, surfaceElementPaths, } from './capture.js';
|
|
4
|
+
import { loadStyleMap, readInventories, readResidue, surfaceElementPaths, } from './capture.js';
|
|
5
5
|
import { isMapFile } from './map-store.js';
|
|
6
6
|
import { fillRect } from './png-util.js';
|
|
7
7
|
import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
|
|
8
8
|
import { describeChange, tokenIndex, toHex } from './describe.js';
|
|
9
9
|
import { auditCoverage, auditDeterminism, COVERAGE_LEDGER, } from './coverage.js';
|
|
10
10
|
import { auditRunInventory, readAckFile } from './inventory.js';
|
|
11
|
+
import { auditRunResidue, readResidueAckFile } from './data-residue.js';
|
|
11
12
|
// The pure grouping / classification brain — shared with the CLI. report.ts keeps
|
|
12
13
|
// the crop-and-PNG rendering on top of these.
|
|
13
14
|
import { cleanFindings, groupByPath, groupTitle, isNonValue, prettyLabel, safeKey, signatureOf, summarizeProps, surfaceBase, surfaceWidth, pushSurfaceWidth, renderSurfaceGroups, formatSurfaceList, classifyChrome, } from './change-groups.js';
|
|
@@ -625,6 +626,29 @@ function readAcknowledgedRemovals() {
|
|
|
625
626
|
return {};
|
|
626
627
|
}
|
|
627
628
|
}
|
|
629
|
+
// Lenient acknowledged-residue read — same advisory degradation as the inventory one.
|
|
630
|
+
function readAcknowledgedResidue() {
|
|
631
|
+
try {
|
|
632
|
+
return readResidueAckFile();
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
return {};
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// One data-residue clause. A failing data endpoint captured the fallback branch, so its
|
|
639
|
+
// response-driven states are unproven; an ARMED gate escalates an unacknowledged one to ✗.
|
|
640
|
+
function dataResidueLine(res) {
|
|
641
|
+
const { residue, unacknowledged, staleAcknowledgements, armed } = res;
|
|
642
|
+
if (armed && (unacknowledged.length > 0 || staleAcknowledgements.length > 0)) {
|
|
643
|
+
const stale = staleAcknowledgements.length ? `; ${staleAcknowledgements.length} stale acknowledgement(s)` : '';
|
|
644
|
+
return `- **Data residue** — ✗ ${unacknowledged.length} failing data endpoint(s), unacknowledged: ${keyList(unacknowledged)}${stale}`;
|
|
645
|
+
}
|
|
646
|
+
if (unacknowledged.length > 0)
|
|
647
|
+
return `- **Data residue** — ⚠ ${unacknowledged.length} failing data endpoint(s) (fallback branch captured): ${keyList(unacknowledged)} — recorded, not gating (\`dataResidue: 'warn'\` opt-out)`;
|
|
648
|
+
if (residue.length > 0)
|
|
649
|
+
return `- **Data residue** — ✓ ${residue.length} failing endpoint(s), all acknowledged`;
|
|
650
|
+
return '- **Data residue** — ✓ no failing data-boundary request during capture';
|
|
651
|
+
}
|
|
628
652
|
/**
|
|
629
653
|
* The certification block a reviewer reads FIRST — the source-of-truth gates (coverage
|
|
630
654
|
* complete? determinism proven? did the navigable set shrink?), not just the pixel diff.
|
|
@@ -634,15 +658,20 @@ function certificationLines(beforeDir, afterDir) {
|
|
|
634
658
|
const baseLedger = readLedgerFile(beforeDir);
|
|
635
659
|
const headLedger = readLedgerFile(afterDir);
|
|
636
660
|
const inv = auditRunInventory(readInventories(beforeDir), readInventories(afterDir), readAcknowledgedRemovals());
|
|
661
|
+
const res = auditRunResidue(readResidue(afterDir), readAcknowledgedResidue(), headLedger?.dataResidue === 'gate');
|
|
637
662
|
const hasLedger = baseLedger !== null || headLedger !== null;
|
|
638
663
|
const hasInvChange = inv.delta.removed.length > 0 || inv.delta.added.length > 0;
|
|
639
|
-
|
|
664
|
+
const hasResidue = res.residue.length > 0 || res.armed;
|
|
665
|
+
if (!hasLedger && !hasInvChange && !hasResidue)
|
|
640
666
|
return [];
|
|
641
667
|
return [
|
|
642
668
|
'**Certification**',
|
|
643
669
|
coverageLine(auditCoverage(surfaceKeysIn(afterDir), headLedger)),
|
|
644
670
|
determinismLine(auditDeterminism(baseLedger, headLedger)),
|
|
645
671
|
inventoryLine(inv),
|
|
672
|
+
// Only add the residue line when there's residue or the gate was armed — an ordinary
|
|
673
|
+
// bundle (no failing endpoint, not armed) keeps its exact prior 3-line block.
|
|
674
|
+
...(hasResidue ? [dataResidueLine(res)] : []),
|
|
646
675
|
'',
|
|
647
676
|
];
|
|
648
677
|
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -177,6 +177,20 @@ export type DefineOptions = {
|
|
|
177
177
|
* See `docs/inventory-guard.md`.
|
|
178
178
|
*/
|
|
179
179
|
inventory?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Data-residue guard. During capture, any request matching the data boundary
|
|
182
|
+
* (`replayUrl`, default `**\/api/**`) that FAILS — a network error or a 4xx/5xx —
|
|
183
|
+
* means the captured state renders that endpoint's FALLBACK branch, so states driven
|
|
184
|
+
* by its real responses are uncaptured and unproven (issue #205). Such a failure is
|
|
185
|
+
* ALWAYS named on stderr and recorded on the capture (`StyleMap.dataResidue`) so the
|
|
186
|
+
* diff/report can surface it. `'gate'` (the default) makes an UNACKNOWLEDGED failing
|
|
187
|
+
* endpoint block `styleproof-diff` (exit 1); acknowledge intentional ones in
|
|
188
|
+
* `styleproof.data-residue.json` (`key -> reason`). `'warn'` is the explicit opt-out —
|
|
189
|
+
* failures are still named + recorded but never block. A capture with no failing data
|
|
190
|
+
* request is byte-identical either way. A 2xx that merely wasn't fixtured is NEVER
|
|
191
|
+
* flagged (recording legitimately records live 2xx).
|
|
192
|
+
*/
|
|
193
|
+
dataResidue?: 'warn' | 'gate';
|
|
180
194
|
};
|
|
181
195
|
export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
|
|
182
196
|
type ResolvedPopupCaptureOptions = Required<PopupCaptureOptions>;
|
|
@@ -237,6 +251,12 @@ export declare function resolveBaseDir(baseDir: string | undefined, env?: string
|
|
|
237
251
|
* can generate reviewable reports without recapturing.
|
|
238
252
|
*/
|
|
239
253
|
export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
|
|
254
|
+
/**
|
|
255
|
+
* The data-residue guard mode: `'gate'` (the v4 default) blocks the diff on an
|
|
256
|
+
* unacknowledged failing data endpoint; `'warn'` is the explicit opt-out that records +
|
|
257
|
+
* warns without gating. Single source of truth for the default, so the flip lives here.
|
|
258
|
+
*/
|
|
259
|
+
export declare function resolveDataResidue(mode: 'warn' | 'gate' | undefined): 'warn' | 'gate';
|
|
240
260
|
/** The capture settings every capturer shares (everything bar the surface set). */
|
|
241
261
|
type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
|
|
242
262
|
export declare function defineStyleMapCapture(options: DefineOptions): void;
|
package/dist/runner.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { test, expect } from '@playwright/test';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
4
|
+
import { captureStyleMap, saveStyleMap, trackInflightRequests, trackDataResidue, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
6
|
import { coverageGaps, coverageKeys, translateExpected, COVERAGE_LEDGER, } from './coverage.js';
|
|
7
|
-
import { writeBrowserBuildSidecar } from './map-store.js';
|
|
7
|
+
import { writeBrowserBuildSidecar, writeCaptureManifest } from './map-store.js';
|
|
8
8
|
import { detectViewportWidths } from './breakpoints.js';
|
|
9
9
|
import { selectCrawlLinks, crawlCoverageError } from './crawl.js';
|
|
10
10
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
@@ -476,6 +476,10 @@ async function captureSurface(page, surface, width, s) {
|
|
|
476
476
|
// count toward the network-aware settle — a request that starts during navigation
|
|
477
477
|
// fired its event before captureStyleMap could attach a listener of its own.
|
|
478
478
|
const requests = trackInflightRequests(page);
|
|
479
|
+
// Same timing for the residue watcher: a data request that fails during navigation
|
|
480
|
+
// must be seen. Keyed on the BASE surface key so a liveStates split (`-loading`/
|
|
481
|
+
// `-loaded`) and every width dedupe to one `<surface>·<endpoint>` residue entry.
|
|
482
|
+
const residue = trackDataResidue(page, s.replayUrl, surface.metadata?.surfaceKey ?? surface.key);
|
|
479
483
|
try {
|
|
480
484
|
await surface.go(page);
|
|
481
485
|
const map = await captureStyleMap(page, {
|
|
@@ -488,6 +492,9 @@ async function captureSurface(page, surface, width, s) {
|
|
|
488
492
|
});
|
|
489
493
|
if (s.selfCheck)
|
|
490
494
|
await assertDeterministic(page, surface, map, s.captureText, requests.pending);
|
|
495
|
+
// Attach data-residue AFTER the self-check re-run so both runs' failures are folded
|
|
496
|
+
// (deduped in the watcher). Warn always; the recorded residue is what the gate reads.
|
|
497
|
+
attachDataResidue(map, residue.residue());
|
|
491
498
|
const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
|
|
492
499
|
saveStyleMap(`${stem}.json.gz`, map);
|
|
493
500
|
if (s.screenshots) {
|
|
@@ -499,6 +506,21 @@ async function captureSurface(page, surface, width, s) {
|
|
|
499
506
|
}
|
|
500
507
|
finally {
|
|
501
508
|
requests.dispose();
|
|
509
|
+
residue.dispose();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** Attach any observed data-residue to the map and NAME each failure on stderr — what
|
|
513
|
+
* failed, what it means (fallback branch captured), what to do. One warning per
|
|
514
|
+
* (surface, endpoint); the watcher already deduped across widths / the self-check. */
|
|
515
|
+
function attachDataResidue(map, residue) {
|
|
516
|
+
if (!residue.length)
|
|
517
|
+
return;
|
|
518
|
+
map.dataResidue = residue;
|
|
519
|
+
for (const r of residue) {
|
|
520
|
+
// eslint-disable-next-line no-console
|
|
521
|
+
console.warn(`styleproof: surface '${r.surface}' — data request ${r.endpoint} FAILED during capture (${r.reason}). ` +
|
|
522
|
+
`The captured state renders this endpoint's fallback branch; states driven by its real responses are ` +
|
|
523
|
+
`uncaptured and unproven. Fixture it (page.route / liveStates) or acknowledge it in styleproof.data-residue.json.`);
|
|
502
524
|
}
|
|
503
525
|
}
|
|
504
526
|
/**
|
|
@@ -527,6 +549,14 @@ export function resolveBaseDir(baseDir, env = process.env.STYLEPROOF_BASEDIR) {
|
|
|
527
549
|
export function resolveScreenshots(screenshots, env = process.env.STYLEPROOF_SCREENSHOTS) {
|
|
528
550
|
return screenshots ?? env !== '0';
|
|
529
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* The data-residue guard mode: `'gate'` (the v4 default) blocks the diff on an
|
|
554
|
+
* unacknowledged failing data endpoint; `'warn'` is the explicit opt-out that records +
|
|
555
|
+
* warns without gating. Single source of truth for the default, so the flip lives here.
|
|
556
|
+
*/
|
|
557
|
+
export function resolveDataResidue(mode) {
|
|
558
|
+
return mode ?? 'gate';
|
|
559
|
+
}
|
|
530
560
|
/**
|
|
531
561
|
* Apply the capture defaults once, so explicit-surface and crawl capture can't
|
|
532
562
|
* drift — the replay boundary, frozen clock and self-check policy resolve to the
|
|
@@ -541,6 +571,7 @@ function resolveSettings(c) {
|
|
|
541
571
|
screenshots: resolveScreenshots(c.screenshots),
|
|
542
572
|
replayFrom,
|
|
543
573
|
replayUrl: c.replayUrl ?? process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**',
|
|
574
|
+
dataResidue: resolveDataResidue(c.dataResidue),
|
|
544
575
|
freezeClock: c.freezeClock ?? true,
|
|
545
576
|
clockTime: c.clockTime ?? '2025-01-01T00:00:00Z',
|
|
546
577
|
selfCheck: c.selfCheck ?? defaultSelfCheck(replayFrom),
|
|
@@ -584,19 +615,42 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude, captureSurfac
|
|
|
584
615
|
// the GATE (which reads expanded map filenames and can't see `surfaceKey` metadata)
|
|
585
616
|
// compares literally — a liveStates surface's `-loading`/`-loaded` splits satisfy it.
|
|
586
617
|
const ledgerExpected = expected == null ? null : translateExpected(expected, captureSurfaces);
|
|
587
|
-
|
|
618
|
+
// Carry the residue-gate mode in the bundle so styleproof-diff knows whether an
|
|
619
|
+
// unacknowledged failing endpoint should BLOCK ('gate', the v4 default) or only
|
|
620
|
+
// inform ('warn', the explicit opt-out). Recorded verbatim so the bundle is
|
|
621
|
+
// self-documenting; an absent field (older bundles) is still read as warn, so
|
|
622
|
+
// maps captured before this default flip never start gating retroactively.
|
|
623
|
+
const ledger = {
|
|
624
|
+
version: 1,
|
|
625
|
+
expected: ledgerExpected,
|
|
626
|
+
exclude,
|
|
627
|
+
determinism,
|
|
628
|
+
dataResidue: settings.dataResidue,
|
|
629
|
+
};
|
|
588
630
|
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
589
631
|
});
|
|
590
632
|
}
|
|
591
|
-
/** Record the real browser build into the capture bundle. The
|
|
592
|
-
* version (in the manifest) is only a proxy — the actual Chromium
|
|
593
|
-
* it holds constant (a re-download, a different browser store, a
|
|
594
|
-
* compatibility guard reads this back to refuse a cross-build compare
|
|
595
|
-
* a PR with false diffs. Best-effort: an unavailable version leaves
|
|
633
|
+
/** Record the real browser build into the capture bundle, then stamp the manifest. The
|
|
634
|
+
* npm `@playwright/test` version (in the manifest) is only a proxy — the actual Chromium
|
|
635
|
+
* binary can change while it holds constant (a re-download, a different browser store, a
|
|
636
|
+
* CI image bump). The compatibility guard reads this back to refuse a cross-build compare
|
|
637
|
+
* instead of walling a PR with false diffs. Best-effort: an unavailable version leaves
|
|
638
|
+
* the guard as-is.
|
|
639
|
+
*
|
|
640
|
+
* The manifest is stamped HERE, at the runner level, so every capture flow produces a
|
|
641
|
+
* manifest-bearing dir — including a raw `STYLEMAP_DIR=x npx playwright test` run (the
|
|
642
|
+
* fork capture workflow's shape), which never goes through the styleproof-map CLI. Since
|
|
643
|
+
* v4 the diff refuses a map-bearing dir without one. It's written in the same test as
|
|
644
|
+
* the sidecar (not a sibling test) because the manifest reads the sidecar back for
|
|
645
|
+
* `browserVersion`, and fullyParallel gives sibling tests no ordering. styleproof-map
|
|
646
|
+
* re-stamps a richer manifest (real spec hash, git identity) after the run; the runtime
|
|
647
|
+
* fields the compare guard reads are the same either way. */
|
|
596
648
|
function writeBrowserBuildTest(settings, dir) {
|
|
597
649
|
test('styleproof browser build', ({ page }) => {
|
|
598
650
|
const version = page.context().browser()?.version();
|
|
599
|
-
|
|
651
|
+
const outDir = path.join(settings.baseDir, dir);
|
|
652
|
+
writeBrowserBuildSidecar(outDir, version);
|
|
653
|
+
writeCaptureManifest({ dir: outDir, screenshots: settings.screenshots });
|
|
600
654
|
});
|
|
601
655
|
}
|
|
602
656
|
export function defineStyleMapCapture(options) {
|