@crouton-kit/capture 1.3.6 → 1.3.8

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 (43) hide show
  1. package/bin/capture +3269 -1281
  2. package/package.json +4 -1
  3. package/src/capture.ts +30 -0
  4. package/src/cdp/args.ts +113 -10
  5. package/src/cdp/bridge/client.ts +49 -0
  6. package/src/cdp/bridge/protocol.ts +137 -0
  7. package/src/cdp/bridge/server.ts +241 -0
  8. package/src/cdp/bridge/spawn.ts +127 -0
  9. package/src/cdp/client.ts +7 -1
  10. package/src/cdp/commands/bridge-serve.ts +43 -0
  11. package/src/cdp/commands/cdp.ts +203 -0
  12. package/src/cdp/commands/measure/census.ts +31 -0
  13. package/src/cdp/commands/measure/check.ts +35 -0
  14. package/src/cdp/commands/measure/diff.ts +32 -0
  15. package/src/cdp/commands/measure/explain.ts +31 -0
  16. package/src/cdp/commands/measure/index.ts +114 -0
  17. package/src/cdp/commands/measure/map-focus.ts +24 -0
  18. package/src/cdp/commands/measure/map-layers.ts +25 -0
  19. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  20. package/src/cdp/commands/measure/snap.ts +36 -0
  21. package/src/cdp/commands/measure/sweep.ts +30 -0
  22. package/src/cdp/commands/motion/index.ts +60 -0
  23. package/src/cdp/commands/motion/jank.ts +22 -0
  24. package/src/cdp/commands/motion/mask.ts +23 -0
  25. package/src/cdp/commands/motion/rec.ts +33 -0
  26. package/src/cdp/commands/motion/response.ts +26 -0
  27. package/src/cdp/commands/motion/timeline.ts +27 -0
  28. package/src/cdp/coordinates.ts +158 -0
  29. package/src/cdp/dispatch.ts +8 -0
  30. package/src/cdp/domains.ts +75 -0
  31. package/src/cdp/recorder-bridge.ts +521 -0
  32. package/src/cdp/source-map.ts +309 -0
  33. package/src/cdp/targets.ts +8 -1
  34. package/src/cdp/timing.ts +82 -0
  35. package/src/cdp/types.ts +71 -0
  36. package/src/cdp/usage.ts +9 -0
  37. package/src/output/artifact.ts +520 -0
  38. package/src/output/diff.ts +111 -0
  39. package/src/output/render.ts +593 -0
  40. package/src/output/selector.ts +185 -0
  41. package/src/session/artifacts.ts +229 -0
  42. package/src/session/commands.ts +183 -20
  43. package/src/session-context.ts +50 -8
@@ -0,0 +1,520 @@
1
+ /**
2
+ * Artifact resolver + reader + caveat propagation — the shared read-only API
3
+ * every `measure`/`motion` query leaf uses to turn a `[url|snap]`/`<rec>`
4
+ * argument into a validated on-disk artifact, read its collector files, and
5
+ * attach per-region nondeterminism caveats. No query leaf reads a snapshot
6
+ * or recording file directly — everything routes through here so path
7
+ * safety (via U03's `assertUnderCaptureRoot`), missing-artifact recovery
8
+ * messaging, and caveat attachment stay in one place.
9
+ *
10
+ * Contract this module fixes for upstream writers:
11
+ * - `geometry.json` is a JSON object shaped `{ elements: [...],
12
+ * unstableRegions?: UnstableRegion[] }`. `unstableRegions` is present
13
+ * only on a `--capture-unsettled` snapshot; `unstableRegionsFor()` reads
14
+ * it from here — nowhere else.
15
+ * - every per-element record collectors write (in `geometry.json` and
16
+ * elsewhere) should carry the identifying fields in `ElementRecord`
17
+ * (`src/output/selector.ts`) so selector lookups work uniformly across
18
+ * collector files.
19
+ * - `meta.json` always exists for any snapshot/recording directory this
20
+ * resolver is asked to resolve — including an unsettled, evidence-only
21
+ * `measure snap` capture — because it is what `session stop` reads to
22
+ * build the bundle manifest (U03) and what this resolver treats as the
23
+ * marker that a ref actually names something.
24
+ */
25
+
26
+ import * as fs from 'fs';
27
+ import * as path from 'path';
28
+
29
+ import { assertUnderCaptureRoot, type SnapMeta, type RecMeta } from '../session/artifacts.js';
30
+ import { getActiveSession } from '../session-context.js';
31
+
32
+ // ============================================================================
33
+ // Refs
34
+ // ============================================================================
35
+
36
+ export interface SnapRef {
37
+ readonly kind: 'snap';
38
+ readonly id: string;
39
+ /** Absolute path to `measure/snaps/{id}` (or the oneshot equivalent). */
40
+ readonly dir: string;
41
+ }
42
+
43
+ export interface RecRef {
44
+ readonly kind: 'rec';
45
+ readonly id: string;
46
+ /** Absolute path to `motion/recs/{id}` (or the oneshot equivalent). */
47
+ readonly dir: string;
48
+ }
49
+
50
+ export type ArtifactRef = SnapRef | RecRef;
51
+
52
+ /** What a pluggable URL-snap callback must return. */
53
+ export interface SnapUrlResult {
54
+ readonly id: string;
55
+ readonly dir: string;
56
+ }
57
+
58
+ /**
59
+ * Invoked by {@link resolveSnapRef} when the ref is a URL. Query leaves that
60
+ * accept a URL target (per the design's `[url|snap]` contract) pass their
61
+ * own `measure snap` invocation wrapped as this callback; leaves that don't
62
+ * accept URL targets simply omit `onUrl`, and a URL ref throws. This
63
+ * indirection exists so this module never imports the `measure snap`
64
+ * command leaf directly — that would create `output/artifact.ts` ->
65
+ * `cdp/commands/measure/snap.ts` -> ... -> `output/artifact.ts` cycles once
66
+ * later units land.
67
+ */
68
+ export type SnapUrlCallback = (url: string) => Promise<SnapUrlResult>;
69
+
70
+ export interface ResolveSnapOptions {
71
+ readonly onUrl?: SnapUrlCallback;
72
+ }
73
+
74
+ // ============================================================================
75
+ // Errors
76
+ // ============================================================================
77
+
78
+ /**
79
+ * Every resolution or read failure in this module throws this. The message
80
+ * (and the structured fields) always say three things: what ref was
81
+ * received, where this module looked for it, and — when the answer is
82
+ * "nothing is there yet" — what command creates it.
83
+ */
84
+ export class ArtifactResolutionError extends Error {
85
+ readonly ref: string;
86
+ readonly searched: readonly string[];
87
+ readonly creatingCommand?: string;
88
+
89
+ constructor(ref: string, searched: readonly string[], detail: string, creatingCommand?: string) {
90
+ const parts = [`could not resolve ref ${JSON.stringify(ref)}: ${detail}`];
91
+ if (searched.length) parts.push(`looked at: ${searched.join(', ')}`);
92
+ if (creatingCommand) parts.push(`create it with: ${creatingCommand}`);
93
+ super(parts.join(' — '));
94
+ this.name = 'ArtifactResolutionError';
95
+ this.ref = ref;
96
+ this.searched = searched;
97
+ this.creatingCommand = creatingCommand;
98
+ }
99
+ }
100
+
101
+ // ============================================================================
102
+ // Ref resolution
103
+ // ============================================================================
104
+
105
+ export function isUrlRef(ref: string): boolean {
106
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(ref);
107
+ }
108
+
109
+ interface RefKindConfig {
110
+ readonly kind: 'snap' | 'rec';
111
+ /** Path segments under a session dir an id resolves through, e.g.
112
+ * `['measure', 'snaps']`. */
113
+ readonly idSegments: readonly [string, string];
114
+ readonly creatingCommand: string;
115
+ readonly label: 'snapshot' | 'recording';
116
+ }
117
+
118
+ const SNAP_CONFIG: RefKindConfig = {
119
+ kind: 'snap',
120
+ idSegments: ['measure', 'snaps'],
121
+ creatingCommand: 'capture measure snap',
122
+ label: 'snapshot',
123
+ };
124
+
125
+ const REC_CONFIG: RefKindConfig = {
126
+ kind: 'rec',
127
+ idSegments: ['motion', 'recs'],
128
+ creatingCommand: 'capture motion rec',
129
+ label: 'recording',
130
+ };
131
+
132
+ function stripTrailingSep(p: string): string {
133
+ return p.length > 1 && (p.endsWith('/') || p.endsWith(path.sep)) ? p.slice(0, -1) : p;
134
+ }
135
+
136
+ function resolveRefFromPath(ref: string, config: RefKindConfig): ArtifactRef {
137
+ const resolved = assertUnderCaptureRoot(stripTrailingSep(ref));
138
+ const metaPath = path.join(resolved, 'meta.json');
139
+ if (!fs.existsSync(metaPath)) {
140
+ throw new ArtifactResolutionError(
141
+ ref,
142
+ [resolved],
143
+ `no ${config.label} found at this path (missing meta.json)`,
144
+ config.creatingCommand,
145
+ );
146
+ }
147
+ return { kind: config.kind, id: path.basename(resolved), dir: resolved } as ArtifactRef;
148
+ }
149
+
150
+ function resolveRefFromId(ref: string, config: RefKindConfig): ArtifactRef {
151
+ const active = getActiveSession();
152
+ if (!active) {
153
+ throw new ArtifactResolutionError(
154
+ ref,
155
+ [],
156
+ `no active capture session — a bare id resolves against the active session's ${config.idSegments.join('/')} directory`,
157
+ `capture session start (then re-run), or pass the absolute ${config.label} path printed by ${config.creatingCommand}`,
158
+ );
159
+ }
160
+ const dir = path.join(active.dir, ...config.idSegments, ref);
161
+ const resolved = assertUnderCaptureRoot(dir);
162
+ const metaPath = path.join(resolved, 'meta.json');
163
+ if (!fs.existsSync(metaPath)) {
164
+ throw new ArtifactResolutionError(
165
+ ref,
166
+ [resolved],
167
+ `no ${config.label} named ${JSON.stringify(ref)} found in the active session (${active.sessionId})`,
168
+ config.creatingCommand,
169
+ );
170
+ }
171
+ return { kind: config.kind, id: ref, dir: resolved } as ArtifactRef;
172
+ }
173
+
174
+ function rejectRelativePath(ref: string): void {
175
+ if (!path.isAbsolute(ref) && (ref.includes('/') || ref.includes(path.sep))) {
176
+ throw new ArtifactResolutionError(
177
+ ref,
178
+ [],
179
+ 'relative paths are not accepted — pass an absolute artifact path (as printed by the capturing command) or a bare id',
180
+ );
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Resolves a `measure` leaf's `[url|snap]` target into a {@link SnapRef}:
186
+ * - a URL (`onUrl` must be supplied, or this throws — see
187
+ * {@link SnapUrlCallback});
188
+ * - an absolute path to a snapshot dir (validated under the capture root,
189
+ * must contain `meta.json`);
190
+ * - a bare id (`snap-a3f2`), resolved against the active session's
191
+ * `measure/snaps/` directory.
192
+ */
193
+ export async function resolveSnapRef(ref: string, options: ResolveSnapOptions = {}): Promise<SnapRef> {
194
+ if (isUrlRef(ref)) {
195
+ if (!options.onUrl) {
196
+ throw new ArtifactResolutionError(ref, [], 'this command does not accept a URL target — pass a snapshot id or path instead');
197
+ }
198
+ const result = await options.onUrl(ref);
199
+ return { kind: 'snap', id: result.id, dir: assertUnderCaptureRoot(result.dir) };
200
+ }
201
+ if (path.isAbsolute(ref)) return resolveRefFromPath(ref, SNAP_CONFIG) as SnapRef;
202
+ rejectRelativePath(ref);
203
+ return resolveRefFromId(ref, SNAP_CONFIG) as SnapRef;
204
+ }
205
+
206
+ /**
207
+ * Resolves a `motion` leaf's `<rec>` target into a {@link RecRef}: an
208
+ * absolute path to a recording dir, or a bare id (`rec-9f31`) resolved
209
+ * against the active session's `motion/recs/` directory. Recordings are
210
+ * never created from a URL ref directly (`motion rec <url> --do ...` is the
211
+ * capture leaf, not a query resolver), so a URL ref always throws.
212
+ */
213
+ export function resolveRecRef(ref: string): RecRef {
214
+ if (isUrlRef(ref)) {
215
+ throw new ArtifactResolutionError(
216
+ ref,
217
+ [],
218
+ 'a recording ref cannot be a URL — record one first with `capture motion rec <url> --do <action>` or `--start`/`--stop`',
219
+ );
220
+ }
221
+ if (path.isAbsolute(ref)) return resolveRefFromPath(ref, REC_CONFIG) as RecRef;
222
+ rejectRelativePath(ref);
223
+ return resolveRefFromId(ref, REC_CONFIG) as RecRef;
224
+ }
225
+
226
+ // ============================================================================
227
+ // Readers
228
+ // ============================================================================
229
+
230
+ const CREATING_COMMAND_BY_FILE: Record<string, string> = {
231
+ 'geometry.json': 'capture measure snap',
232
+ 'styles.json': 'capture measure snap',
233
+ 'hittest.json': 'capture measure snap',
234
+ 'text.json': 'capture measure snap',
235
+ 'forms.json': 'capture measure snap',
236
+ 'animation.json': 'capture measure snap',
237
+ 'ax.json': 'capture measure snap',
238
+ 'queries.json': 'capture measure snap',
239
+ 'focus.json': 'capture measure snap',
240
+ 'scroll.json': 'capture measure snap',
241
+ 'layers.json': 'capture measure snap',
242
+ 'states.json': 'capture measure snap --state <state>',
243
+ 'churn.json': 'capture measure snap (written only when the page did not settle)',
244
+ 'pixels.json': 'capture measure snap --pixels',
245
+ 'dom.html': 'capture measure snap',
246
+ 'screenshot.png': 'capture measure snap',
247
+ 'meta.json': 'capture measure snap (or capture motion rec for a recording)',
248
+ 'rects.jsonl': 'capture motion rec',
249
+ 'events.jsonl': 'capture motion rec',
250
+ 'markers.json': 'capture motion rec',
251
+ };
252
+
253
+ function creatingCommandFor(filename: string): string | undefined {
254
+ return CREATING_COMMAND_BY_FILE[filename];
255
+ }
256
+
257
+ /**
258
+ * Resolves (and, by default, verifies existence of) one artifact file's
259
+ * absolute path within `ref`'s directory. Every reader below is built on
260
+ * this — a query leaf that needs a raw artifact path (a screenshot, a crop
261
+ * source) rather than parsed content can call this directly.
262
+ */
263
+ export function artifactPath(ref: ArtifactRef, filename: string, opts: { mustExist?: boolean } = {}): string {
264
+ const filePath = assertUnderCaptureRoot(path.join(ref.dir, filename));
265
+ const mustExist = opts.mustExist ?? true;
266
+ if (mustExist && !fs.existsSync(filePath)) {
267
+ throw new ArtifactResolutionError(
268
+ ref.id,
269
+ [filePath],
270
+ `${filename} is not present in ${ref.kind === 'snap' ? 'snapshot' : 'recording'} ${ref.id}`,
271
+ creatingCommandFor(filename),
272
+ );
273
+ }
274
+ return filePath;
275
+ }
276
+
277
+ export function artifactExists(ref: ArtifactRef, filename: string): boolean {
278
+ return fs.existsSync(assertUnderCaptureRoot(path.join(ref.dir, filename)));
279
+ }
280
+
281
+ function readJsonArtifactFile<T>(ref: ArtifactRef, filename: string): T {
282
+ const filePath = artifactPath(ref, filename);
283
+ let raw: string;
284
+ try {
285
+ raw = fs.readFileSync(filePath, 'utf-8');
286
+ } catch (err) {
287
+ throw new ArtifactResolutionError(
288
+ ref.id,
289
+ [filePath],
290
+ `could not read ${filename}: ${err instanceof Error ? err.message : String(err)}`,
291
+ creatingCommandFor(filename),
292
+ );
293
+ }
294
+ try {
295
+ return JSON.parse(raw) as T;
296
+ } catch (err) {
297
+ throw new ArtifactResolutionError(
298
+ ref.id,
299
+ [filePath],
300
+ `${filename} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
301
+ creatingCommandFor(filename),
302
+ );
303
+ }
304
+ }
305
+
306
+ function readNdjsonArtifactFile<T>(ref: ArtifactRef, filename: string): T[] {
307
+ const filePath = artifactPath(ref, filename);
308
+ let raw: string;
309
+ try {
310
+ raw = fs.readFileSync(filePath, 'utf-8');
311
+ } catch (err) {
312
+ throw new ArtifactResolutionError(
313
+ ref.id,
314
+ [filePath],
315
+ `could not read ${filename}: ${err instanceof Error ? err.message : String(err)}`,
316
+ creatingCommandFor(filename),
317
+ );
318
+ }
319
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
320
+ return lines.map((line, i) => {
321
+ try {
322
+ return JSON.parse(line) as T;
323
+ } catch (err) {
324
+ throw new ArtifactResolutionError(
325
+ ref.id,
326
+ [filePath],
327
+ `${filename} line ${i + 1} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
328
+ creatingCommandFor(filename),
329
+ );
330
+ }
331
+ });
332
+ }
333
+
334
+ function assertKind(ref: ArtifactRef, kind: 'snap' | 'rec', fnName: string): void {
335
+ if (ref.kind !== kind) {
336
+ throw new TypeError(`${fnName} expects a ${kind} ref, got a ${ref.kind} ref (${ref.id})`);
337
+ }
338
+ }
339
+
340
+ /** `meta.json` — the one file common to both a snapshot and a recording. */
341
+ export function readMeta<T = SnapMeta | RecMeta>(ref: ArtifactRef): T {
342
+ return readJsonArtifactFile<T>(ref, 'meta.json');
343
+ }
344
+
345
+ export function readGeometry<T = unknown>(ref: SnapRef): T {
346
+ assertKind(ref, 'snap', 'readGeometry');
347
+ return readJsonArtifactFile<T>(ref, 'geometry.json');
348
+ }
349
+
350
+ export function readStyles<T = unknown>(ref: SnapRef): T {
351
+ assertKind(ref, 'snap', 'readStyles');
352
+ return readJsonArtifactFile<T>(ref, 'styles.json');
353
+ }
354
+
355
+ export function readHittest<T = unknown>(ref: SnapRef): T {
356
+ assertKind(ref, 'snap', 'readHittest');
357
+ return readJsonArtifactFile<T>(ref, 'hittest.json');
358
+ }
359
+
360
+ export function readText<T = unknown>(ref: SnapRef): T {
361
+ assertKind(ref, 'snap', 'readText');
362
+ return readJsonArtifactFile<T>(ref, 'text.json');
363
+ }
364
+
365
+ export function readForms<T = unknown>(ref: SnapRef): T {
366
+ assertKind(ref, 'snap', 'readForms');
367
+ return readJsonArtifactFile<T>(ref, 'forms.json');
368
+ }
369
+
370
+ export function readAnimation<T = unknown>(ref: SnapRef): T {
371
+ assertKind(ref, 'snap', 'readAnimation');
372
+ return readJsonArtifactFile<T>(ref, 'animation.json');
373
+ }
374
+
375
+ export function readAx<T = unknown>(ref: SnapRef): T {
376
+ assertKind(ref, 'snap', 'readAx');
377
+ return readJsonArtifactFile<T>(ref, 'ax.json');
378
+ }
379
+
380
+ export function readQueries<T = unknown>(ref: SnapRef): T {
381
+ assertKind(ref, 'snap', 'readQueries');
382
+ return readJsonArtifactFile<T>(ref, 'queries.json');
383
+ }
384
+
385
+ export function readFocus<T = unknown>(ref: SnapRef): T {
386
+ assertKind(ref, 'snap', 'readFocus');
387
+ return readJsonArtifactFile<T>(ref, 'focus.json');
388
+ }
389
+
390
+ export function readScroll<T = unknown>(ref: SnapRef): T {
391
+ assertKind(ref, 'snap', 'readScroll');
392
+ return readJsonArtifactFile<T>(ref, 'scroll.json');
393
+ }
394
+
395
+ export function readLayers<T = unknown>(ref: SnapRef): T {
396
+ assertKind(ref, 'snap', 'readLayers');
397
+ return readJsonArtifactFile<T>(ref, 'layers.json');
398
+ }
399
+
400
+ export function readStates<T = unknown>(ref: SnapRef): T {
401
+ assertKind(ref, 'snap', 'readStates');
402
+ return readJsonArtifactFile<T>(ref, 'states.json');
403
+ }
404
+
405
+ export function readChurn<T = unknown>(ref: SnapRef): T {
406
+ assertKind(ref, 'snap', 'readChurn');
407
+ return readJsonArtifactFile<T>(ref, 'churn.json');
408
+ }
409
+
410
+ export function readPixels<T = unknown>(ref: SnapRef): T {
411
+ assertKind(ref, 'snap', 'readPixels');
412
+ return readJsonArtifactFile<T>(ref, 'pixels.json');
413
+ }
414
+
415
+ export function readRects<T = unknown>(ref: RecRef): T[] {
416
+ assertKind(ref, 'rec', 'readRects');
417
+ return readNdjsonArtifactFile<T>(ref, 'rects.jsonl');
418
+ }
419
+
420
+ export function readEvents<T = unknown>(ref: RecRef): T[] {
421
+ assertKind(ref, 'rec', 'readEvents');
422
+ return readNdjsonArtifactFile<T>(ref, 'events.jsonl');
423
+ }
424
+
425
+ export function readMarkers<T = unknown>(ref: RecRef): T {
426
+ assertKind(ref, 'rec', 'readMarkers');
427
+ return readJsonArtifactFile<T>(ref, 'markers.json');
428
+ }
429
+
430
+ // ============================================================================
431
+ // Unstable-region caveats — the only sanctioned path to a nondeterminism
432
+ // caveat on a query fact.
433
+ // ============================================================================
434
+
435
+ export interface Rect {
436
+ readonly x: number;
437
+ readonly y: number;
438
+ readonly w: number;
439
+ readonly h: number;
440
+ }
441
+
442
+ export interface UnstableRegion {
443
+ /** Stable id for the region within this snapshot, e.g. matches the
444
+ * `regionId` a collector stamps onto affected `geometry.json` elements. */
445
+ readonly id: string;
446
+ /** Selector/description identifying the region, when known. */
447
+ readonly selector?: string;
448
+ /** Axis-aligned bounding rect in top-viewport space, when known. */
449
+ readonly rect?: Rect;
450
+ /** Element ids (geometry.json `ElementRecord.id`s) known to be inside
451
+ * this region, when known. */
452
+ readonly elementIds?: readonly string[];
453
+ /** Free-text evidence, e.g. "autoplay repaint every 3.2s". */
454
+ readonly reason?: string;
455
+ }
456
+
457
+ /**
458
+ * Reads the unstable regions marked on a `--capture-unsettled` snapshot's
459
+ * `geometry.json` (`{ unstableRegions: UnstableRegion[] }`). Returns `[]`
460
+ * for a fully-settled snapshot (no unstable regions were marked). Throws
461
+ * the normal reader failure if `geometry.json` itself is absent — which
462
+ * only happens for a snapshot that never got a queryable substrate at all
463
+ * (unsettled without `--capture-unsettled`), a state no query leaf should
464
+ * be reading facts from in the first place.
465
+ */
466
+ export function unstableRegionsFor(ref: SnapRef): UnstableRegion[] {
467
+ const geometry = readGeometry<{ unstableRegions?: UnstableRegion[] }>(ref);
468
+ return geometry.unstableRegions ?? [];
469
+ }
470
+
471
+ export interface UnstableMatchable {
472
+ /** The element/backend-node id this fact is about, if any. Matched
473
+ * against a region's `elementIds`. */
474
+ readonly elementId?: string;
475
+ /** The fact's own rect in top-viewport space, if any. Matched against a
476
+ * region's `rect` by axis-aligned overlap. */
477
+ readonly rect?: Rect;
478
+ }
479
+
480
+ export interface UnstableCaveat {
481
+ readonly regionId: string;
482
+ readonly selector?: string;
483
+ readonly reason?: string;
484
+ }
485
+
486
+ export interface AnnotatedFact<T> {
487
+ readonly fact: T;
488
+ /** Zero or more caveats — empty when the fact touches no unstable
489
+ * region. A fact spanning multiple regions carries one caveat per
490
+ * region. */
491
+ readonly caveats: readonly UnstableCaveat[];
492
+ }
493
+
494
+ function rectsOverlap(a: Rect, b: Rect): boolean {
495
+ 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;
496
+ }
497
+
498
+ /**
499
+ * Attaches a per-region nondeterminism caveat to every fact whose
500
+ * `elementId` or `rect` falls inside an unstable region. This — together
501
+ * with {@link unstableRegionsFor} — is the only sanctioned way a query leaf
502
+ * marks a fact as derived from an unstable region; leaves must not
503
+ * hand-roll their own "unsettled" warning text.
504
+ */
505
+ export function annotateUnstableFacts<T extends UnstableMatchable>(
506
+ facts: readonly T[],
507
+ regions: readonly UnstableRegion[],
508
+ ): AnnotatedFact<T>[] {
509
+ return facts.map((fact) => {
510
+ const caveats: UnstableCaveat[] = [];
511
+ for (const region of regions) {
512
+ const byElement = fact.elementId !== undefined && (region.elementIds?.includes(fact.elementId) ?? false);
513
+ const byRect = fact.rect !== undefined && region.rect !== undefined && rectsOverlap(fact.rect, region.rect);
514
+ if (byElement || byRect) {
515
+ caveats.push({ regionId: region.id, selector: region.selector, reason: region.reason });
516
+ }
517
+ }
518
+ return { fact, caveats };
519
+ });
520
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Raster PNG diff helper — shared by `measure diff --pixels`, snapshot crops,
3
+ * and `motion mask`. Wraps pixelmatch + pngjs (pure JS, esbuild-safe, no
4
+ * native/system binary dependency).
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { PNG } from "pngjs";
10
+ import pixelmatch from "pixelmatch";
11
+
12
+ export interface DiffPngsOptions {
13
+ /** Matching threshold (0 to 1); smaller is more sensitive. Default 0.1. */
14
+ threshold?: number;
15
+ /** Whether to skip anti-aliasing detection. */
16
+ includeAA?: boolean;
17
+ /** Opacity of original image in diff output. */
18
+ alpha?: number;
19
+ /** Color of anti-aliased pixels in diff output. */
20
+ aaColor?: [number, number, number];
21
+ /** Color of different pixels in diff output. */
22
+ diffColor?: [number, number, number];
23
+ /** Alternate diff color for dark-on-light vs light-on-dark differences. */
24
+ diffColorAlt?: [number, number, number];
25
+ /** Draw the diff over a transparent background (a mask) instead of the alpha-blended original. */
26
+ diffMask?: boolean;
27
+ }
28
+
29
+ export interface DiffPngsResult {
30
+ ok: true;
31
+ width: number;
32
+ height: number;
33
+ diffPixelCount: number;
34
+ threshold: number;
35
+ }
36
+
37
+ export interface DiffPngsDimensionMismatchError {
38
+ ok: false;
39
+ code: "dimension_mismatch";
40
+ message: string;
41
+ before: { width: number; height: number };
42
+ after: { width: number; height: number };
43
+ }
44
+
45
+ export type DiffPngsOutcome = DiffPngsResult | DiffPngsDimensionMismatchError;
46
+
47
+ const DEFAULT_THRESHOLD = 0.1;
48
+
49
+ /**
50
+ * Reads two PNGs from disk, pixel-diffs them, and writes a diff PNG to `out`.
51
+ *
52
+ * Returns a structured `{ ok: false, code: "dimension_mismatch", ... }` result
53
+ * (never a bare throw) when the two images have different dimensions, since
54
+ * pixelmatch requires equally sized images.
55
+ */
56
+ export function diffPngs(
57
+ before: string,
58
+ after: string,
59
+ out: string,
60
+ options: DiffPngsOptions = {}
61
+ ): DiffPngsOutcome {
62
+ const beforePng = readPng(before);
63
+ const afterPng = readPng(after);
64
+
65
+ if (beforePng.width !== afterPng.width || beforePng.height !== afterPng.height) {
66
+ return {
67
+ ok: false,
68
+ code: "dimension_mismatch",
69
+ message:
70
+ `cannot diff PNGs with mismatched dimensions: ` +
71
+ `before is ${beforePng.width}x${beforePng.height} (${before}), ` +
72
+ `after is ${afterPng.width}x${afterPng.height} (${after})`,
73
+ before: { width: beforePng.width, height: beforePng.height },
74
+ after: { width: afterPng.width, height: afterPng.height },
75
+ };
76
+ }
77
+
78
+ const { width, height } = beforePng;
79
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
80
+ const diff = new PNG({ width, height });
81
+
82
+ const diffPixelCount = pixelmatch(beforePng.data, afterPng.data, diff.data, width, height, {
83
+ threshold,
84
+ includeAA: options.includeAA,
85
+ alpha: options.alpha,
86
+ aaColor: options.aaColor,
87
+ diffColor: options.diffColor,
88
+ diffColorAlt: options.diffColorAlt,
89
+ diffMask: options.diffMask,
90
+ });
91
+
92
+ writePngPrivate(out, PNG.sync.write(diff));
93
+
94
+ return { ok: true, width, height, diffPixelCount, threshold };
95
+ }
96
+
97
+ function readPng(pngPath: string): PNG {
98
+ const buf = fs.readFileSync(pngPath);
99
+ return PNG.sync.read(buf);
100
+ }
101
+
102
+ /**
103
+ * Writes a PNG buffer to `outPath` with private permissions (dir 0700, file
104
+ * 0600). Callers that already write into a session/oneshot artifact tree
105
+ * created via the shared secure artifact filesystem helpers get private dirs
106
+ * for free; this is a defensive floor for standalone use of `diffPngs`.
107
+ */
108
+ function writePngPrivate(outPath: string, data: Buffer): void {
109
+ fs.mkdirSync(path.dirname(outPath), { recursive: true, mode: 0o700 });
110
+ fs.writeFileSync(outPath, data, { mode: 0o600 });
111
+ }