@crouton-kit/capture 1.3.7 → 1.3.9

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.
Files changed (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,470 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ import { PNG } from 'pngjs';
5
+ import pixelmatch from 'pixelmatch';
6
+
7
+ import { diffPngs, type DiffPngsOutcome } from '../../output/diff.js';
8
+ import {
9
+ artifactPath,
10
+ annotateUnstableFacts,
11
+ readForms,
12
+ readGeometry,
13
+ readMeta,
14
+ readPixels,
15
+ readStates,
16
+ readStyles,
17
+ readText,
18
+ unstableRegionsFor,
19
+ type Rect,
20
+ type SnapRef,
21
+ type UnstableCaveat,
22
+ } from '../../output/artifact.js';
23
+
24
+ interface ElementLike {
25
+ readonly id?: string;
26
+ readonly backendNodeId?: number | null;
27
+ readonly selector?: string;
28
+ readonly rect?: Rect;
29
+ readonly [key: string]: unknown;
30
+ }
31
+
32
+ interface ElementsReport {
33
+ readonly elements?: ElementLike[];
34
+ }
35
+
36
+ interface FormsReport {
37
+ readonly controls?: ElementLike[];
38
+ }
39
+
40
+ interface SnapshotMeta {
41
+ readonly settled?: boolean;
42
+ readonly capturedAt?: string;
43
+ }
44
+
45
+ export interface PropertyDelta {
46
+ readonly property: string;
47
+ readonly before: unknown;
48
+ readonly after: unknown;
49
+ readonly beforeProvenance?: unknown;
50
+ readonly afterProvenance?: unknown;
51
+ }
52
+
53
+ export interface ProvenanceRecord {
54
+ readonly property: string;
55
+ readonly before: unknown;
56
+ readonly after: unknown;
57
+ readonly beforeProvenance?: unknown;
58
+ readonly afterProvenance?: unknown;
59
+ readonly changed: boolean;
60
+ /** The winning declaration changed even though the computed value may not have. */
61
+ readonly declarationChanged: boolean;
62
+ }
63
+
64
+ export interface DiffCaveat extends UnstableCaveat {
65
+ readonly snapshot: 'before' | 'after';
66
+ }
67
+
68
+ export interface ElementDiff {
69
+ readonly key: string;
70
+ readonly before?: ElementLike;
71
+ readonly after?: ElementLike;
72
+ readonly selector?: string;
73
+ readonly geometryChanged: boolean;
74
+ readonly textChanged: boolean;
75
+ readonly formChanged: boolean;
76
+ readonly mediaChanged: boolean;
77
+ readonly styleDeltas: readonly PropertyDelta[];
78
+ readonly provenance: readonly ProvenanceRecord[];
79
+ readonly reflow: boolean;
80
+ readonly caveats: readonly DiffCaveat[];
81
+ }
82
+
83
+ export interface StateDiff {
84
+ readonly key: string;
85
+ readonly before?: unknown;
86
+ readonly after?: unknown;
87
+ readonly changed: boolean;
88
+ }
89
+
90
+ export interface RasterRegion {
91
+ readonly x: number;
92
+ readonly y: number;
93
+ readonly w: number;
94
+ readonly h: number;
95
+ readonly changedPixels: number;
96
+ readonly explainedByGeometry: boolean;
97
+ }
98
+
99
+ export interface RasterDiff {
100
+ readonly path: string;
101
+ readonly outcome: DiffPngsOutcome;
102
+ readonly regions: readonly RasterRegion[];
103
+ readonly unexplainedRegions: number;
104
+ }
105
+
106
+ export interface MeasureDiffReport {
107
+ readonly before: SnapRef;
108
+ readonly after: SnapRef;
109
+ readonly beforeMeta: SnapshotMeta;
110
+ readonly afterMeta: SnapshotMeta;
111
+ readonly changes: readonly ElementDiff[];
112
+ readonly stateDeltas: readonly StateDiff[];
113
+ readonly raster?: RasterDiff;
114
+ readonly changed: boolean;
115
+ }
116
+
117
+ function readJson<T>(ref: SnapRef, filename: string): T {
118
+ const file = artifactPath(ref, filename);
119
+ try {
120
+ return JSON.parse(fs.readFileSync(file, 'utf8')) as T;
121
+ } catch (error) {
122
+ throw new Error(`could not read ${filename} for snapshot ${ref.id} at ${file}: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+ }
125
+
126
+ interface SnapshotFamily {
127
+ readonly records: Map<string, ElementLike>;
128
+ order: number;
129
+ selector?: string;
130
+ }
131
+
132
+ type ArtifactCollection = Record<string, readonly ElementLike[]>;
133
+
134
+ const RECORD_IDENTITY_FIELDS = new Set(['id', 'backendNodeId', 'identityUnresolved', 'selector', 'axId', 'nodeId', 'objectId', 'markId']);
135
+
136
+ function normalized(value: unknown): unknown {
137
+ if (Array.isArray(value)) return value.map(normalized);
138
+ if (!value || typeof value !== 'object') return value;
139
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>)
140
+ .sort(([a], [b]) => a.localeCompare(b))
141
+ .map(([key, entry]) => [key, normalized(entry)]));
142
+ }
143
+
144
+ /** Removes only a collector record's own snapshot-local identity fields. */
145
+ function recordMeasurementPayload(value: unknown): unknown {
146
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return normalized(value);
147
+ return normalized(Object.fromEntries(Object.entries(value as Record<string, unknown>).filter(([key]) => !RECORD_IDENTITY_FIELDS.has(key))));
148
+ }
149
+
150
+ /** sourceStyleSheetId is opaque per-CDP-session; declaration selector is provenance and must remain. */
151
+ function provenancePayload(value: unknown): unknown {
152
+ if (Array.isArray(value)) return value.map(provenancePayload);
153
+ if (!value || typeof value !== 'object') return value;
154
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>)
155
+ .filter(([key]) => key !== 'sourceStyleSheetId')
156
+ .sort(([a], [b]) => a.localeCompare(b))
157
+ .map(([key, entry]) => [key, provenancePayload(entry)]));
158
+ }
159
+
160
+ function sameValue(a: unknown, b: unknown): boolean {
161
+ return JSON.stringify(normalized(a)) === JSON.stringify(normalized(b));
162
+ }
163
+
164
+ function sameRecordValue(a: unknown, b: unknown): boolean {
165
+ return JSON.stringify(recordMeasurementPayload(a)) === JSON.stringify(recordMeasurementPayload(b));
166
+ }
167
+
168
+ function sameProvenance(a: unknown, b: unknown): boolean {
169
+ return JSON.stringify(provenancePayload(a)) === JSON.stringify(provenancePayload(b));
170
+ }
171
+
172
+ /**
173
+ * Within one snapshot, collectors are correlated by backendNodeId. Cross-snapshot
174
+ * matching deliberately does not use that CDP-runtime id: it derives a stable page
175
+ * identity from the selector plus its collision-safe occurrence ordinal instead.
176
+ */
177
+ function assembleFamilies(collections: ArtifactCollection, recordKey: (collection: string, element: ElementLike) => string = (collection) => collection): Map<string, SnapshotFamily> {
178
+ const byLocalId = new Map<string, SnapshotFamily>();
179
+ let nextOrder = 0;
180
+ for (const [collection, elements] of Object.entries(collections)) {
181
+ elements.forEach((element, index) => {
182
+ const localId = typeof element.backendNodeId === 'number'
183
+ ? `backend:${element.backendNodeId}`
184
+ : `${collection}:unresolved:${index}`;
185
+ let family = byLocalId.get(localId);
186
+ if (!family) {
187
+ family = { records: new Map(), order: nextOrder++ };
188
+ byLocalId.set(localId, family);
189
+ }
190
+ family.records.set(recordKey(collection, element), element);
191
+ if (collection === 'geometry' || family.selector === undefined) family.selector = typeof element.selector === 'string' ? element.selector : family.selector;
192
+ });
193
+ }
194
+
195
+ const selectorOrdinals = new Map<string, number>();
196
+ const indexed = new Map<string, SnapshotFamily>();
197
+ for (const family of [...byLocalId.values()].sort((a, b) => a.order - b.order)) {
198
+ const base = family.selector === undefined ? 'unidentified' : `selector:${family.selector}`;
199
+ const ordinal = selectorOrdinals.get(base) ?? 0;
200
+ selectorOrdinals.set(base, ordinal + 1);
201
+ indexed.set(`${base}#${ordinal}`, family);
202
+ }
203
+ return indexed;
204
+ }
205
+
206
+ function recordFrom(family: SnapshotFamily | undefined, collection: string): ElementLike | undefined {
207
+ return family?.records.get(collection);
208
+ }
209
+
210
+ function rectChanged(before?: ElementLike, after?: ElementLike): boolean {
211
+ return !sameValue(before?.rect, after?.rect);
212
+ }
213
+
214
+ function computed(element?: ElementLike): Record<string, unknown> {
215
+ const value = element?.computed;
216
+ return value && typeof value === 'object' ? value as Record<string, unknown> : {};
217
+ }
218
+
219
+ function declarations(element?: ElementLike): readonly Record<string, unknown>[] {
220
+ const value = element?.winningDeclarations;
221
+ return Array.isArray(value) ? value.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null) : [];
222
+ }
223
+
224
+ function provenanceFor(element: ElementLike | undefined, property: string): unknown {
225
+ return declarations(element).find((entry) => entry.property === property || entry.name === property);
226
+ }
227
+
228
+ function styleDeltas(before?: ElementLike, after?: ElementLike): PropertyDelta[] {
229
+ const beforeComputed = computed(before);
230
+ const afterComputed = computed(after);
231
+ const properties = new Set([...Object.keys(beforeComputed), ...Object.keys(afterComputed)]);
232
+ const deltas: PropertyDelta[] = [];
233
+ for (const property of properties) {
234
+ if (sameValue(beforeComputed[property], afterComputed[property])) continue;
235
+ deltas.push({
236
+ property,
237
+ before: beforeComputed[property],
238
+ after: afterComputed[property],
239
+ beforeProvenance: provenanceFor(before, property),
240
+ afterProvenance: provenanceFor(after, property),
241
+ });
242
+ }
243
+ return deltas;
244
+ }
245
+
246
+
247
+ function styleProvenance(before: ElementLike | undefined, after: ElementLike | undefined, full: boolean): ProvenanceRecord[] {
248
+ const beforeComputed = computed(before);
249
+ const afterComputed = computed(after);
250
+ const declarationProps = new Set([...declarations(before), ...declarations(after)].flatMap((entry) => [entry.property, entry.name]).filter((value): value is string => typeof value === 'string'));
251
+ const properties = new Set([...Object.keys(beforeComputed), ...Object.keys(afterComputed), ...declarationProps]);
252
+ const records: ProvenanceRecord[] = [];
253
+ for (const property of properties) {
254
+ const beforeProvenance = provenanceFor(before, property);
255
+ const afterProvenance = provenanceFor(after, property);
256
+ if (!beforeProvenance && !afterProvenance) continue;
257
+ const changed = !sameValue(beforeComputed[property], afterComputed[property]);
258
+ const declarationChanged = !sameProvenance(beforeProvenance, afterProvenance);
259
+ // Default output keeps material cascade changes even when their computed
260
+ // value is unchanged; --full additionally exposes unchanged declarations.
261
+ if (!full && !changed && !declarationChanged) continue;
262
+ records.push({ property, before: beforeComputed[property], after: afterComputed[property], beforeProvenance, afterProvenance, changed, declarationChanged });
263
+ }
264
+ return records;
265
+ }
266
+
267
+ function rectOf(element?: ElementLike): Rect | undefined {
268
+ if (!element?.rect) return undefined;
269
+ const raw = element.rect as Rect & { width?: number; height?: number };
270
+ const w = typeof raw.w === 'number' ? raw.w : raw.width;
271
+ const h = typeof raw.h === 'number' ? raw.h : raw.height;
272
+ if (typeof raw.x !== 'number' || typeof raw.y !== 'number' || typeof w !== 'number' || typeof h !== 'number') return undefined;
273
+ return { ...raw, w, h };
274
+ }
275
+
276
+ function overlaps(a: Rect, b: RasterRegion): boolean {
277
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
278
+ }
279
+
280
+ function rasterRegions(beforePath: string, afterPath: string, geometryRecords: readonly ElementDiff[]): RasterRegion[] {
281
+ const beforePng = PNG.sync.read(fs.readFileSync(beforePath));
282
+ const afterPng = PNG.sync.read(fs.readFileSync(afterPath));
283
+ if (beforePng.width !== afterPng.width || beforePng.height !== afterPng.height) return [];
284
+ const { width, height } = beforePng;
285
+ const diff = new PNG({ width, height });
286
+ pixelmatch(beforePng.data, afterPng.data, diff.data, width, height, { threshold: 0.1, diffMask: true });
287
+ const changed = new Uint8Array(width * height);
288
+ for (let i = 0; i < width * height; i += 1) {
289
+ const o = i * 4;
290
+ if (diff.data[o] || diff.data[o + 1] || diff.data[o + 2]) changed[i] = 1;
291
+ }
292
+ const seen = new Uint8Array(width * height);
293
+ const regions: RasterRegion[] = [];
294
+ const geometryRects = geometryRecords.filter((record) => record.geometryChanged).flatMap((record) => [rectOf(record.before), rectOf(record.after)].filter((rect): rect is Rect => Boolean(rect)));
295
+ for (let y = 0; y < height; y += 1) {
296
+ for (let x = 0; x < width; x += 1) {
297
+ const start = y * width + x;
298
+ if (!changed[start] || seen[start]) continue;
299
+ let minX = x, maxX = x, minY = y, maxY = y, count = 0;
300
+ const queue = [start];
301
+ seen[start] = 1;
302
+ for (let q = 0; q < queue.length; q += 1) {
303
+ const cur = queue[q];
304
+ const cx = cur % width;
305
+ const cy = Math.floor(cur / width);
306
+ count += 1; minX = Math.min(minX, cx); maxX = Math.max(maxX, cx); minY = Math.min(minY, cy); maxY = Math.max(maxY, cy);
307
+ for (const [nx, ny] of [[cx+1,cy],[cx-1,cy],[cx,cy+1],[cx,cy-1]]) {
308
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
309
+ const ni = ny * width + nx;
310
+ if (changed[ni] && !seen[ni]) { seen[ni] = 1; queue.push(ni); }
311
+ }
312
+ }
313
+ const region = { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1, changedPixels: count, explainedByGeometry: false };
314
+ regions.push({ ...region, explainedByGeometry: geometryRects.some((rect) => overlaps(rect, region)) });
315
+ }
316
+ }
317
+ return regions;
318
+ }
319
+
320
+ /**
321
+ * Producer selectors embed raw DOM ids/classes without CSS escaping. They are
322
+ * display labels, not an identity grammar: parsing them can make distinct raw
323
+ * values such as `card.one` and `card.two` share a fabricated `card` ID.
324
+ * New snapshots carry mutation-target backend identities; legacy artifacts
325
+ * without one fail closed unless their raw selector strings are exactly equal.
326
+ */
327
+ function selectorsMatch(regionSelector: string | undefined, elementSelector: string | undefined): boolean {
328
+ return regionSelector !== undefined && elementSelector !== undefined && regionSelector === elementSelector;
329
+ }
330
+
331
+ function joinCaveats(before: ElementLike | undefined, after: ElementLike | undefined, beforeRegions: ReturnType<typeof unstableRegionsFor>, afterRegions: ReturnType<typeof unstableRegionsFor>): DiffCaveat[] {
332
+ const caveatsFor = (element: ElementLike | undefined, regions: ReturnType<typeof unstableRegionsFor>, snapshot: 'before' | 'after'): DiffCaveat[] => {
333
+ if (!element) return [];
334
+ const stableElementId = typeof element.backendNodeId === 'number' ? String(element.backendNodeId) : element.id;
335
+ const matched = annotateUnstableFacts([{ elementId: stableElementId, rect: rectOf(element) }], regions)[0].caveats;
336
+ // Legacy artifacts lack the backend identity now emitted by the churn
337
+ // producer. Only those identity-unavailable rows can join raw selector
338
+ // labels, and then only by exact equality.
339
+ const selectorMatched = regions.filter((region) => !region.elementIds?.length && selectorsMatch(region.selector, element.selector))
340
+ .map((region) => ({ regionId: region.id, selector: region.selector, reason: region.reason }));
341
+ return [...matched, ...selectorMatched].map((caveat) => ({ ...caveat, snapshot }));
342
+ };
343
+ const seen = new Set<string>();
344
+ return [...caveatsFor(before, beforeRegions, 'before'), ...caveatsFor(after, afterRegions, 'after')].filter((caveat) => {
345
+ const key = `${caveat.snapshot}:${caveat.regionId}:${caveat.selector ?? ''}:${caveat.reason ?? ''}`;
346
+ if (seen.has(key)) return false;
347
+ seen.add(key);
348
+ return true;
349
+ });
350
+ }
351
+
352
+ function extractElements(report: unknown, collection: 'elements' | 'controls' = 'elements'): ElementLike[] {
353
+ if (!report || typeof report !== 'object') return [];
354
+ const values = (report as Record<string, unknown>)[collection];
355
+ return Array.isArray(values) ? values.filter((value): value is ElementLike => typeof value === 'object' && value !== null) : [];
356
+ }
357
+
358
+
359
+ /** Compare two completed snapshot artifacts without driving the browser. */
360
+ export function diffSnapshots(before: SnapRef, after: SnapRef, options: { full?: boolean; pixels?: boolean } = {}): MeasureDiffReport {
361
+ const beforeGeometry = readGeometry<ElementsReport>(before);
362
+ const afterGeometry = readGeometry<ElementsReport>(after);
363
+ const beforeStyles = readStyles<ElementsReport>(before);
364
+ const afterStyles = readStyles<ElementsReport>(after);
365
+ const beforeText = readText<ElementsReport>(before);
366
+ const afterText = readText<ElementsReport>(after);
367
+ const beforeForms = readForms<FormsReport>(before);
368
+ const afterForms = readForms<FormsReport>(after);
369
+ // media.json is a required substrate file. U04 intentionally has no typed
370
+ // reader for it, so keep its artifact-path validation in the shared API.
371
+ const beforeMedia = readJson<ElementsReport>(before, 'media.json');
372
+ const afterMedia = readJson<ElementsReport>(after, 'media.json');
373
+
374
+ const beforeRegions = unstableRegionsFor(before);
375
+ const afterRegions = unstableRegionsFor(after);
376
+ const beforeFamilies = assembleFamilies({
377
+ geometry: extractElements(beforeGeometry), styles: extractElements(beforeStyles), text: extractElements(beforeText), forms: extractElements(beforeForms, 'controls'), media: extractElements(beforeMedia),
378
+ });
379
+ const afterFamilies = assembleFamilies({
380
+ geometry: extractElements(afterGeometry), styles: extractElements(afterStyles), text: extractElements(afterText), forms: extractElements(afterForms, 'controls'), media: extractElements(afterMedia),
381
+ });
382
+
383
+ const records: ElementDiff[] = [];
384
+ for (const key of new Set([...beforeFamilies.keys(), ...afterFamilies.keys()])) {
385
+ const beforeFamily = beforeFamilies.get(key);
386
+ const afterFamily = afterFamilies.get(key);
387
+ const geometryA = recordFrom(beforeFamily, 'geometry');
388
+ const geometryB = recordFrom(afterFamily, 'geometry');
389
+ const styleA = recordFrom(beforeFamily, 'styles');
390
+ const styleB = recordFrom(afterFamily, 'styles');
391
+ const textA = recordFrom(beforeFamily, 'text');
392
+ const textB = recordFrom(afterFamily, 'text');
393
+ const formA = recordFrom(beforeFamily, 'forms');
394
+ const formB = recordFrom(afterFamily, 'forms');
395
+ const mediaA = recordFrom(beforeFamily, 'media');
396
+ const mediaB = recordFrom(afterFamily, 'media');
397
+ const styles = styleDeltas(styleA, styleB);
398
+ const provenance = styleProvenance(styleA, styleB, Boolean(options.full));
399
+ const geometryChanged = rectChanged(geometryA, geometryB) || Boolean(geometryA) !== Boolean(geometryB);
400
+ const textChanged = !sameRecordValue(textA, textB);
401
+ const formChanged = !sameRecordValue(formA, formB);
402
+ const mediaChanged = !sameRecordValue(mediaA, mediaB);
403
+ const changed = geometryChanged || textChanged || formChanged || mediaChanged || styles.length > 0;
404
+ // Cascade shifts are reportable provenance even when no rendered
405
+ // measurement changed; they do not themselves make --gate fail.
406
+ if (!options.full && !changed && !provenance.length) continue;
407
+ const primaryBefore = geometryA ?? styleA ?? textA ?? formA ?? mediaA;
408
+ const primaryAfter = geometryB ?? styleB ?? textB ?? formB ?? mediaB;
409
+ records.push({
410
+ key,
411
+ before: primaryBefore,
412
+ after: primaryAfter,
413
+ selector: primaryAfter?.selector ?? primaryBefore?.selector,
414
+ geometryChanged,
415
+ textChanged,
416
+ formChanged,
417
+ mediaChanged,
418
+ styleDeltas: styles,
419
+ provenance,
420
+ reflow: geometryChanged && (rectOf(geometryA)?.y !== rectOf(geometryB)?.y || rectOf(geometryA)?.x !== rectOf(geometryB)?.x || rectOf(geometryA)?.w !== rectOf(geometryB)?.w || rectOf(geometryA)?.h !== rectOf(geometryB)?.h),
421
+ caveats: joinCaveats(primaryBefore, primaryAfter, beforeRegions, afterRegions),
422
+ });
423
+ }
424
+
425
+ const stateDeltas: StateDiff[] = [];
426
+ if (options.full) {
427
+ const stateRecordKey = (collection: string, element: ElementLike): string => `${collection}:${typeof element.state === 'string' ? element.state : 'unknown'}`;
428
+ const beforeStates = assembleFamilies({ states: extractElements(readStates(before)) }, stateRecordKey);
429
+ const afterStates = assembleFamilies({ states: extractElements(readStates(after)) }, stateRecordKey);
430
+ for (const key of new Set([...beforeStates.keys(), ...afterStates.keys()])) {
431
+ const beforeFamily = beforeStates.get(key);
432
+ const afterFamily = afterStates.get(key);
433
+ const stateKeys = new Set([
434
+ ...[...(beforeFamily?.records.keys() ?? [])].filter((recordKey) => recordKey.startsWith('states:')),
435
+ ...[...(afterFamily?.records.keys() ?? [])].filter((recordKey) => recordKey.startsWith('states:')),
436
+ ]);
437
+ for (const stateKey of stateKeys) {
438
+ const beforeState = beforeFamily?.records.get(stateKey);
439
+ const afterState = afterFamily?.records.get(stateKey);
440
+ const state = stateKey.slice('states:'.length);
441
+ stateDeltas.push({ key: `${state}:${key}`, before: beforeState, after: afterState, changed: !sameRecordValue(beforeState, afterState) });
442
+ }
443
+ }
444
+ }
445
+
446
+ let raster: RasterDiff | undefined;
447
+ if (options.pixels) {
448
+ // Read pixels.json first: this is the explicit substrate contract for
449
+ // raster analysis, even though the full screenshots are diffed below.
450
+ readPixels(before);
451
+ readPixels(after);
452
+ const output = path.join(after.dir, 'diffs', 'raster-diff.png');
453
+ const beforePng = artifactPath(before, 'screenshot.png');
454
+ const afterPng = artifactPath(after, 'screenshot.png');
455
+ const outcome = diffPngs(beforePng, afterPng, output);
456
+ const regions = outcome.ok ? rasterRegions(beforePng, afterPng, records) : [];
457
+ raster = { path: output, outcome, regions, unexplainedRegions: regions.filter((region) => !region.explainedByGeometry).length };
458
+ }
459
+
460
+ return {
461
+ before,
462
+ after,
463
+ beforeMeta: readMeta<SnapshotMeta>(before),
464
+ afterMeta: readMeta<SnapshotMeta>(after),
465
+ changes: records,
466
+ stateDeltas,
467
+ raster,
468
+ changed: records.some((record) => record.geometryChanged || record.textChanged || record.formChanged || record.mediaChanged || record.styleDeltas.length > 0) || stateDeltas.some((state) => state.changed) || Boolean(raster?.outcome.ok && raster.outcome.diffPixelCount > 0),
469
+ };
470
+ }