@crouton-kit/capture 1.3.8 → 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 (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  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 +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  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/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,390 @@
1
+ /**
2
+ * The `measure snap` orchestrator — drives the settledness gate, then (when
3
+ * captured) runs the collectors in two phases: `baseline` collectors in
4
+ * parallel, then `screenshot.png`/`dom.html` at the baseline boundary,
5
+ * then `mutating` collectors serialized (so their DOM/focus/scroll/
6
+ * background changes can't contaminate the baseline artifacts). `meta.json`
7
+ * is ALWAYS written last. This is the one function
8
+ * `src/cdp/commands/measure/snap.ts` (U15) calls; nothing else drives a
9
+ * snapshot capture.
10
+ */
11
+
12
+ import * as path from 'path';
13
+
14
+ import { ensurePrivateDir, writeJsonPrivate, writeBinaryPrivate, writePrivateFile } from '../../session/artifacts.js';
15
+ import { enableDomainsForSnap } from '../domains.js';
16
+ import {
17
+ DEFAULT_SETTLE_TIMEOUT_MS,
18
+ DEFAULT_QUIET_THRESHOLD_MS,
19
+ DEFAULT_POLL_INTERVAL_MS,
20
+ pollForSettle,
21
+ buildDomSettleSampler,
22
+ domSignaturesEqual,
23
+ injectChurnObservers,
24
+ collectChurnEvidence,
25
+ freezeAnimationsBeforeCapture,
26
+ restoreAnimationsAfterCapture,
27
+ collectAnimationEvidence,
28
+ groupChurnEvidence,
29
+ } from './settle.js';
30
+ import type { AnimationFreezeHandle } from './settle.js';
31
+ import { sanitizeString } from './redaction.js';
32
+ import type {
33
+ CaptureSnapshotOptions,
34
+ CaptureSnapshotResult,
35
+ SnapshotContext,
36
+ SnapshotMeta,
37
+ SnapshotWriter,
38
+ UnstableRegion,
39
+ CollectorDescriptor,
40
+ } from './types.js';
41
+
42
+ import { collectGeometry } from './collectors/geometry.js';
43
+ import { collectHittest } from './collectors/hittest.js';
44
+ import { collectStyles } from './collectors/styles.js';
45
+ import { collectQueries } from './collectors/queries.js';
46
+ import { collectAx } from './collectors/ax.js';
47
+ import { collectText } from './collectors/text.js';
48
+ import { collectForms } from './collectors/forms.js';
49
+ import { collectAnimation } from './collectors/animation.js';
50
+ import { collectFocus } from './collectors/focus.js';
51
+ import { collectScroll } from './collectors/scroll.js';
52
+ import { collectLayers } from './collectors/layers.js';
53
+ import { collectPixels } from './collectors/pixels.js';
54
+ import { collectStates } from './collectors/states.js';
55
+ import { collectMedia } from './collectors/media.js';
56
+
57
+ /**
58
+ * The collector set, split by phase. `baseline` collectors run in parallel
59
+ * and are all finished before `screenshot.png`/`dom.html` are captured;
60
+ * `mutating` collectors run afterward, serialized, so their
61
+ * DOM/focus/scroll/background mutations cannot contaminate the baseline
62
+ * artifacts. Geometry/hittest/text/forms are safely `baseline`: none of
63
+ * them ever mutates the DOM — each resolves its elements' `backendNodeId`
64
+ * through the read-only CDP object-id bridge (`geometry.ts`'s
65
+ * `resolveIndexedObjectIds`/`describeBackendNodeId`, off a held
66
+ * `{ facts, elements }` container returned by value and never assigned to
67
+ * `window`), so nothing another baseline collector or the
68
+ * `screenshot.png`/`dom.html` capture could observe ever changes.
69
+ */
70
+ const COLLECTORS: readonly CollectorDescriptor[] = [
71
+ { name: 'ax', phase: 'baseline', fn: collectAx },
72
+ { name: 'styles', phase: 'baseline', fn: collectStyles },
73
+ { name: 'queries', phase: 'baseline', fn: collectQueries },
74
+ { name: 'animation', phase: 'baseline', fn: collectAnimation },
75
+ { name: 'layers', phase: 'baseline', fn: collectLayers },
76
+ { name: 'media', phase: 'baseline', fn: collectMedia },
77
+ { name: 'geometry', phase: 'baseline', fn: collectGeometry },
78
+ { name: 'hittest', phase: 'baseline', fn: collectHittest },
79
+ { name: 'text', phase: 'baseline', fn: collectText },
80
+ { name: 'forms', phase: 'baseline', fn: collectForms },
81
+ { name: 'focus', phase: 'mutating', fn: collectFocus },
82
+ { name: 'scroll', phase: 'mutating', fn: collectScroll },
83
+ { name: 'states', phase: 'mutating', fn: collectStates },
84
+ { name: 'pixels', phase: 'mutating', fn: collectPixels },
85
+ ];
86
+
87
+ /**
88
+ * Resolves `filename` against the snap `dir` and rejects (throws) anything
89
+ * that would escape it — an absolute path, or a `path.relative(dir, target)`
90
+ * that starts climbing out (`..`). The U03 secure-fs helpers only enforce the
91
+ * global `CAPTURE_ROOT` boundary, so this is what actually scopes a
92
+ * collector's writes to the snap directory `SnapshotWriter` documents.
93
+ */
94
+ function resolveScopedArtifactPath(dir: string, filename: string): string {
95
+ const target = path.resolve(dir, filename);
96
+ const rel = path.relative(dir, target);
97
+ const escapesDir = rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
98
+ if (escapesDir) {
99
+ throw new Error(`Refusing to write snapshot artifact "${filename}" outside its snap dir ${dir}`);
100
+ }
101
+ return target;
102
+ }
103
+
104
+ function makeWriter(dir: string, artifacts: string[]): SnapshotWriter {
105
+ return {
106
+ json(filename: string, value: unknown): void {
107
+ writeJsonPrivate(resolveScopedArtifactPath(dir, filename), value);
108
+ artifacts.push(filename);
109
+ },
110
+ binary(filename: string, data: Buffer): void {
111
+ writeBinaryPrivate(resolveScopedArtifactPath(dir, filename), data);
112
+ artifacts.push(filename);
113
+ },
114
+ };
115
+ }
116
+
117
+ // ============================================================================
118
+ // Orchestrator
119
+ // ============================================================================
120
+
121
+ /**
122
+ * Captures one snapshot substrate directory: enables the CDP domains,
123
+ * runs the settledness gate, and — when the page settled or the caller
124
+ * forced `captureUnsettled` — writes the full collector substrate plus
125
+ * `screenshot.png`/`dom.html`. `meta.json` is written on every branch,
126
+ * unconditionally, last.
127
+ */
128
+ export async function captureSnapshotSubstrate(options: CaptureSnapshotOptions): Promise<CaptureSnapshotResult> {
129
+ const client = options.target.client;
130
+ const dir = ensurePrivateDir(options.path);
131
+ const snapId = options.snapId ?? path.basename(dir);
132
+ const settleTimeoutMs = options.settleTimeout ?? DEFAULT_SETTLE_TIMEOUT_MS;
133
+ const quietThresholdMs = options.quietThresholdMs ?? DEFAULT_QUIET_THRESHOLD_MS;
134
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
135
+ const freezeAnimations = options.freezeAnimations ?? false;
136
+ const captureUnsettled = options.captureUnsettled ?? false;
137
+ const pixels = options.pixels ?? false;
138
+ const state = options.state ?? [];
139
+ const viewport = options.viewport ?? null;
140
+ // Apply the shared artifact-string cap once at the source. URL contents
141
+ // otherwise survive unchanged in meta.json, the result, and collector context.
142
+ const url = options.url ? sanitizeString(options.url) : null;
143
+
144
+ await enableDomainsForSnap(client);
145
+
146
+ // I-6: capture each animation's pre-freeze origin BEFORE this, the first
147
+ // mutation, and restore it exception-safely — `animationFreezeHandle`/
148
+ // `animationsRestored` are threaded through the whole function (via the
149
+ // outer `finally` below) so a throw anywhere after this point still gets
150
+ // a real restore attempt, never a page left silently forced.
151
+ let animationFreezeHandle: AnimationFreezeHandle | undefined;
152
+ // Pessimistic default: only flips to `true` on a confirmed restore, so an
153
+ // origin-capture failure (handle stays `undefined`, restoreAnimationsOnce
154
+ // never runs) still reports the honest `false` rather than omitting the
155
+ // fact entirely.
156
+ let animationsRestored: boolean | undefined = freezeAnimations ? false : undefined;
157
+ // #8: capture the freeze-OVERRIDE-apply fact right here, at origin-capture
158
+ // time — `restoreAnimationsOnce` below unconditionally nulls out
159
+ // `animationFreezeHandle` once consumed, so `handle.rateOverrideApplied`
160
+ // would no longer be reachable by the time `meta.json` is built if it
161
+ // weren't captured into its own variable now. Stays `undefined` when
162
+ // freeze wasn't requested at all, OR when the origin capture itself
163
+ // failed (no handle was ever returned — nothing was mutated, and that
164
+ // failure is already surfaced honestly via the pessimistic
165
+ // `animationsRestored:false` default below).
166
+ let freezeOverrideApplied: boolean | undefined;
167
+ // #72: same capture-now rationale as `freezeOverrideApplied` above —
168
+ // `restoreAnimationsOnce` nulls out `animationFreezeHandle` once consumed,
169
+ // so the per-animation pause-failure facts would no longer be reachable by
170
+ // the time `meta.json` is built if they weren't captured into their own
171
+ // variables now. Both stay `undefined` when freeze wasn't requested, OR
172
+ // when the origin capture itself failed (no handle was ever returned).
173
+ let freezeIncomplete: boolean | undefined;
174
+ let unfrozenCount: number | undefined;
175
+ if (freezeAnimations) {
176
+ animationFreezeHandle = await freezeAnimationsBeforeCapture(client);
177
+ freezeOverrideApplied = animationFreezeHandle?.rateOverrideApplied;
178
+ freezeIncomplete = animationFreezeHandle?.freezeIncomplete;
179
+ unfrozenCount = animationFreezeHandle?.unfrozenCount;
180
+ }
181
+ // Idempotent — the second (or only) call is a no-op once the handle has
182
+ // been consumed, so calling it both at the natural restore point AND from
183
+ // the `finally` safety net below never double-restores.
184
+ const restoreAnimationsOnce = async (): Promise<void> => {
185
+ if (!animationFreezeHandle) return;
186
+ const handle = animationFreezeHandle;
187
+ animationFreezeHandle = undefined;
188
+ const result = await restoreAnimationsAfterCapture(client, handle);
189
+ animationsRestored = result.restored;
190
+ };
191
+
192
+ try {
193
+ const churnHandle = await injectChurnObservers(client);
194
+
195
+ // The churn observers (MutationObserver/ResizeObserver) MUST be torn down
196
+ // even if sampling throws (a CDP failure mid-poll) — otherwise they leak
197
+ // and contaminate later captures in the same tab. Teardown is attempted
198
+ // unconditionally; if the poll failed, its error is what gets rethrown
199
+ // (a secondary teardown failure never masks it). Exactly one of the two
200
+ // `collectChurnEvidence(client, churnHandle)` calls below runs per
201
+ // invocation — the catch path rethrows, so the normal-path call below it
202
+ // never also runs — and `collectChurnEvidence` owns the handle's single
203
+ // `Runtime.releaseObject`, so the handle is released exactly once either way.
204
+ let settleResult: Awaited<ReturnType<typeof pollForSettle<string>>>;
205
+ try {
206
+ settleResult = await pollForSettle({
207
+ captureSample: buildDomSettleSampler(client, churnHandle),
208
+ isEqual: domSignaturesEqual,
209
+ settleTimeoutMs,
210
+ quietThresholdMs,
211
+ pollIntervalMs,
212
+ });
213
+ } catch (pollError) {
214
+ try {
215
+ await collectChurnEvidence(client, churnHandle);
216
+ } catch {
217
+ // Secondary teardown failure — the original poll error is what matters.
218
+ }
219
+ throw pollError;
220
+ }
221
+
222
+ // Always call — cleanup, and potential evidence, regardless of settledness.
223
+ const churnEvidenceRaw = await collectChurnEvidence(client, churnHandle);
224
+
225
+ const settled = settleResult.settled;
226
+ const captured = settled || captureUnsettled;
227
+ const unstable = !settled;
228
+
229
+ const artifacts: string[] = [];
230
+ let unstableRegions: readonly UnstableRegion[] = [];
231
+ // I-5: set only inside the `captured` branch below (the only place
232
+ // `dom.html` is ever attempted) — stays `undefined` on the evidence-only
233
+ // branch, where `meta.json`'s spread omits the field entirely rather than
234
+ // implying a read that never happened.
235
+ let domHtmlFact: SnapshotMeta['domHtml'];
236
+
237
+ if (unstable) {
238
+ const animationEvidence = await collectAnimationEvidence(client);
239
+ const grouped = groupChurnEvidence(churnEvidenceRaw, animationEvidence, settleResult.elapsedMs, settleTimeoutMs);
240
+ unstableRegions = grouped.unstableRegions;
241
+ writeJsonPrivate(path.join(dir, 'churn.json'), grouped.report);
242
+ artifacts.push('churn.json');
243
+ if (!captured) {
244
+ writeJsonPrivate(path.join(dir, 'animation.json'), animationEvidence);
245
+ artifacts.push('animation.json');
246
+ }
247
+ }
248
+
249
+ if (captured) {
250
+ const ctx: SnapshotContext = {
251
+ client,
252
+ dir,
253
+ snapId,
254
+ url,
255
+ viewport,
256
+ settled,
257
+ freezeAnimations,
258
+ captureUnsettled,
259
+ pixels,
260
+ state,
261
+ unstableRegions,
262
+ write: makeWriter(dir, artifacts),
263
+ };
264
+
265
+ const collectors = options.collectors ?? COLLECTORS;
266
+ const baseline = collectors.filter((c) => c.phase === 'baseline');
267
+ const mutating = collectors.filter((c) => c.phase === 'mutating');
268
+
269
+ // Phase 1 — baseline collectors observe the page exactly as it settled;
270
+ // they run in parallel and must ALL finish before the baseline
271
+ // artifacts are captured.
272
+ await Promise.all(baseline.map((c) => c.fn(ctx)));
273
+
274
+ // Baseline boundary — capture screenshot.png + dom.html BEFORE any
275
+ // mutating collector runs, so a mutating collector's DOM/focus/scroll/
276
+ // background change (or a failed restoration) can never contaminate
277
+ // these artifacts.
278
+ const screenshotResponse = (await client.send('Page.captureScreenshot', { format: 'png' })) as { data: string };
279
+ writeBinaryPrivate(path.join(dir, 'screenshot.png'), Buffer.from(screenshotResponse.data, 'base64'));
280
+ artifacts.push('screenshot.png');
281
+
282
+ // I-5: an `outerHTML` read that throws or returns no `value` is a
283
+ // FAILED read, not the fact "the document is empty" — coercing it to
284
+ // `''` would write a valid-looking-but-empty `dom.html` indistinguishable
285
+ // from a genuinely empty document. Surface the failure as an explicit
286
+ // `meta.json` fact and skip writing the file (an absent artifact, never
287
+ // a benign empty one) rather than fabricate the read.
288
+ let domHtmlValue: string | undefined;
289
+ let domHtmlUnavailableReason: string | undefined;
290
+ try {
291
+ const domResponse = (await client.send('Runtime.evaluate', {
292
+ expression: 'document.documentElement.outerHTML',
293
+ returnByValue: true,
294
+ })) as { result?: { value?: string } };
295
+ domHtmlValue = domResponse.result?.value;
296
+ if (domHtmlValue === undefined) {
297
+ domHtmlUnavailableReason = 'dom-evaluate-returned-no-value';
298
+ }
299
+ } catch {
300
+ domHtmlValue = undefined;
301
+ domHtmlUnavailableReason = 'dom-evaluate-threw';
302
+ }
303
+ if (domHtmlValue === undefined) {
304
+ domHtmlFact = { available: false, unavailableReason: domHtmlUnavailableReason };
305
+ } else {
306
+ writePrivateFile(path.join(dir, 'dom.html'), domHtmlValue);
307
+ artifacts.push('dom.html');
308
+ domHtmlFact = { available: true };
309
+ }
310
+
311
+ // I-6: restore right after the frozen baseline artifacts are captured
312
+ // — animations must stay paused through screenshot.png/dom.html (the
313
+ // entire point of `--freeze-animations`), but a mutating-phase
314
+ // collector (focus/scroll/states probing) should observe the page as
315
+ // it would otherwise behave, not one still forced.
316
+ await restoreAnimationsOnce();
317
+
318
+ // Phase 2 — mutating collectors run AFTER the baseline artifacts are
319
+ // captured, and SERIALIZED (one at a time) so their mutations and
320
+ // restorations cannot contaminate each other.
321
+ for (const c of mutating) {
322
+ await c.fn(ctx);
323
+ }
324
+ } else {
325
+ // Evidence-only branch (unstable, not captured) — nothing depends on
326
+ // animations staying frozen any longer, so restore now.
327
+ await restoreAnimationsOnce();
328
+ }
329
+
330
+ const meta: SnapshotMeta = {
331
+ id: snapId,
332
+ url,
333
+ viewport,
334
+ settled,
335
+ capturedAt: new Date().toISOString(),
336
+ settleMs: settleResult.elapsedMs,
337
+ settleTimeoutMs,
338
+ freezeAnimations,
339
+ captureUnsettled,
340
+ pixels,
341
+ states: state.map((spec) => sanitizeString(spec)),
342
+ unstableRegionCount: unstableRegions.length,
343
+ ...(freezeAnimations ? { animationsRestored: animationsRestored ?? false } : {}),
344
+ // #8: the browser-wide `Animation.setPlaybackRate({playbackRate:0})`
345
+ // override itself failing to apply is a genuine I-6 fact distinct from
346
+ // `animationsRestored` above (which reflects only the RESTORE step) —
347
+ // a page can restore cleanly from whatever WAS frozen at origin-capture
348
+ // time while still never having had the browser-wide override applied,
349
+ // meaning any animation created after freeze was never covered. Only
350
+ // ever written when the override genuinely failed; a successful
351
+ // override (or freeze never requested / origin capture itself failed,
352
+ // both already covered elsewhere) leaves this field absent — never a
353
+ // fabricated affirmative value standing in for "we don't actually know".
354
+ ...(freezeOverrideApplied === false ? { freezeOverrideApplied: false } : {}),
355
+ // #72: only the noteworthy (incomplete) case is ever written — same
356
+ // convention as `freezeOverrideApplied` above. A clean freeze (or
357
+ // freeze not requested, or origin capture itself failed) leaves this
358
+ // absent; a consumer must never read the absence as "confirmed clean"
359
+ // on its own, only alongside the other freeze facts already emitted
360
+ // (`animationsRestored`, `freezeOverrideApplied`) that cover those
361
+ // other cases. `unfrozenCount` is only ever emitted alongside a `true`
362
+ // `freezeIncomplete`, matching the handle's own documented contract.
363
+ ...(freezeIncomplete === true ? { freezeIncomplete: true as const, unfrozenCount: unfrozenCount ?? 0 } : {}),
364
+ ...(domHtmlFact ? { domHtml: domHtmlFact } : {}),
365
+ };
366
+ writeJsonPrivate(path.join(dir, 'meta.json'), meta);
367
+ artifacts.push('meta.json');
368
+
369
+ return {
370
+ id: snapId,
371
+ dir,
372
+ url,
373
+ viewport,
374
+ settled,
375
+ captured,
376
+ settleMs: settleResult.elapsedMs,
377
+ settleTimeoutMs,
378
+ unstableRegions,
379
+ artifacts,
380
+ meta,
381
+ };
382
+ } finally {
383
+ // I-6 exception safety net: if anything above threw before either
384
+ // natural restore point ran, still restore the page against the
385
+ // captured origin — `restoreAnimationsAfterCapture` never throws, so
386
+ // this can't mask the original error, and it's a no-op once the handle
387
+ // has already been consumed.
388
+ await restoreAnimationsOnce();
389
+ }
390
+ }