@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,1051 @@
1
+ /**
2
+ * The settledness gate — decides whether a page has stopped mutating
3
+ * (`pollForSettle`, pure) and the CDP-backed pieces that feed it (churn
4
+ * observers, animation freezing/inventory, and grouping raw mutation/
5
+ * animation evidence into `churn.json` + `UnstableRegion`s).
6
+ *
7
+ * The churn observer state (`{ lastMutationTs, mutations, resizeCount, mo,
8
+ * ro }`) is never assigned to `window` or any other page-observable
9
+ * location: `injectChurnObservers` evaluates the bootstrap script with
10
+ * `returnByValue: false` and hands back a {@link ChurnObserverHandle}
11
+ * wrapping the held CDP `objectId` of that live object. The
12
+ * MutationObserver/ResizeObserver closures capture the state object
13
+ * LEXICALLY, so they keep writing to it whether or not anything on the
14
+ * page can ever see it. `buildDomSettleSampler`/`collectChurnEvidence`
15
+ * read/mutate it back purely through `Runtime.callFunctionOn({objectId,
16
+ * returnByValue:true})` with `this` bound to the held object — never by
17
+ * re-evaluating a page-observable expression — so a page that predefines
18
+ * `Object.defineProperty(window, '__captureSettle', {set(){...}})` has
19
+ * nothing to fire against. `collectChurnEvidence` owns the handle's sole
20
+ * `Runtime.releaseObject` call once teardown reads are done.
21
+ *
22
+ * A page can never settle while an infinite CSS/WAAPI animation runs — the
23
+ * settle sampler forces `quietMs = 0` whenever one is `running` (see the
24
+ * sample script below) — which is exactly why `freezeAnimationsBeforeCapture`
25
+ * (pauses every animation, flipping `playState` away from `'running'`) is
26
+ * what lets an otherwise-forever-churning page settle.
27
+ */
28
+
29
+ import type { CDPClient } from '../client.js';
30
+ import { sanitizeString } from './redaction.js';
31
+ import type { AnimationEvidence, AnimationEvidenceRecord, ChurnReport, ChurnRegionRecord, UnstableRegion } from './types.js';
32
+
33
+ export const DEFAULT_SETTLE_TIMEOUT_MS = 5000;
34
+ export const DEFAULT_QUIET_THRESHOLD_MS = 300;
35
+ export const DEFAULT_POLL_INTERVAL_MS = 100;
36
+
37
+ // ============================================================================
38
+ // pollForSettle — pure, CDP-decoupled
39
+ // ============================================================================
40
+
41
+ export interface SettleSample<T> {
42
+ readonly signature: T;
43
+ readonly quietMs: number;
44
+ }
45
+
46
+ export interface SettleOptions<T> {
47
+ readonly captureSample: () => Promise<SettleSample<T>>;
48
+ readonly isEqual: (a: T, b: T) => boolean;
49
+ readonly settleTimeoutMs: number;
50
+ readonly quietThresholdMs?: number;
51
+ readonly pollIntervalMs?: number;
52
+ /** Injected for tests; defaults to `Date.now`. */
53
+ readonly now?: () => number;
54
+ /** Injected for tests; defaults to a real `setTimeout`-based sleep. */
55
+ readonly sleep?: (ms: number) => Promise<void>;
56
+ }
57
+
58
+ export interface SettleResult<T> {
59
+ readonly settled: boolean;
60
+ readonly elapsedMs: number;
61
+ readonly sampleCount: number;
62
+ readonly lastSample: SettleSample<T> | null;
63
+ }
64
+
65
+ /**
66
+ * Polls `captureSample` until two consecutive samples compare equal (via
67
+ * `isEqual`) AND the newer sample reports at least `quietThresholdMs` of
68
+ * quiet time, or until `settleTimeoutMs` elapses (per the injected `now`).
69
+ * Always takes at least one sample before it can time out.
70
+ *
71
+ * Deadline semantics: a sample that is matching+quiet is only accepted as
72
+ * `settled: true` if the elapsed time AT that sample is `<= settleTimeoutMs`.
73
+ * A matching+quiet sample that arrives strictly after the deadline returns
74
+ * `settled: false` instead — the settled predicate is checked against the
75
+ * deadline before being trusted, not just tested for the next-loop timeout.
76
+ * The inter-sample sleep is capped to the remaining time to the deadline so
77
+ * the loop can't overshoot it before taking its next (possibly final) sample.
78
+ */
79
+ export async function pollForSettle<T>(opts: SettleOptions<T>): Promise<SettleResult<T>> {
80
+ const quietThresholdMs = opts.quietThresholdMs ?? DEFAULT_QUIET_THRESHOLD_MS;
81
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
82
+ const now = opts.now ?? Date.now;
83
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
84
+
85
+ const start = now();
86
+ let sampleCount = 0;
87
+ let previous: SettleSample<T> | null = null;
88
+
89
+ for (;;) {
90
+ const sample = await opts.captureSample();
91
+ sampleCount += 1;
92
+ const elapsed = now() - start;
93
+
94
+ if (previous !== null && opts.isEqual(previous.signature, sample.signature) && sample.quietMs >= quietThresholdMs) {
95
+ if (elapsed <= opts.settleTimeoutMs) {
96
+ return { settled: true, elapsedMs: elapsed, sampleCount, lastSample: sample };
97
+ }
98
+ return { settled: false, elapsedMs: elapsed, sampleCount, lastSample: sample };
99
+ }
100
+
101
+ previous = sample;
102
+ if (elapsed >= opts.settleTimeoutMs) {
103
+ return { settled: false, elapsedMs: elapsed, sampleCount, lastSample: sample };
104
+ }
105
+ const remainingMs = opts.settleTimeoutMs - elapsed;
106
+ await sleep(Math.min(pollIntervalMs, remainingMs));
107
+ }
108
+ }
109
+
110
+ // ============================================================================
111
+ // CDP-backed pieces
112
+ // ============================================================================
113
+
114
+ /** One raw DOM mutation observed while the churn observers were installed. */
115
+ export interface ChurnMutationRecord {
116
+ readonly t: number;
117
+ readonly type: string;
118
+ readonly selector: string | null;
119
+ /** Stable CDP identity of the mutation target, when the held target object could be described. */
120
+ readonly backendNodeId?: number;
121
+ }
122
+
123
+ /** Fixed, factual reasons {@link collectChurnEvidence} could not read a well-formed teardown payload back (never a raw exception message). Present only when {@link ChurnEvidenceRaw.teardownUnavailable} is `true`. */
124
+ export type ChurnTeardownUnavailableReason = 'malformed-value';
125
+
126
+ /** Fixed, factual reason {@link BOOTSTRAP_SCRIPT}'s `new ResizeObserver(...)`/`.observe()` setup itself failed (never a raw exception message). Present only when {@link ChurnEvidenceRaw.resizeObserverUnavailable} is `true`. */
127
+ export type ChurnResizeObserverUnavailableReason = 'setup-threw';
128
+
129
+ /** Raw teardown payload from `collectChurnEvidence` — not yet grouped into regions. */
130
+ export interface ChurnEvidenceRaw {
131
+ /** Kept mutation records — bounded by the bootstrap script's 200-record cap. Empty/zero WITHOUT {@link teardownUnavailable} set means a genuinely quiet page; empty/zero WITH it set means the teardown read itself was malformed (I-5) — never treat the two as the same fact. */
132
+ readonly mutations: readonly ChurnMutationRecord[];
133
+ readonly resizeCount: number;
134
+ /** Total mutation records the observer counted before the 200-record cap dropped any — always `>= mutations.length` on a genuinely well-formed read. Optional so a hand-built `ChurnEvidenceRaw` test fixture that doesn't care about truncation can omit it — {@link groupChurnEvidence} marks that omission explicitly (`mutationsTruncationUnknown`) rather than silently assuming nothing was dropped (I-5). */
135
+ readonly mutationsObserved?: number;
136
+ /** `true` when the teardown `Runtime.callFunctionOn` round-tripped but handed back a malformed value (missing/wrong-typed `mutations`/`resizeCount` — {@link TEARDOWN_SCRIPT} always returns both together, so this means the read itself is corrupt, not that the page was quiet). `mutations`/`resizeCount` above are then the empty/zero DEFAULT, never a genuine observation. A CDP-level throw is NOT this case — it propagates as a real exception out of `collectChurnEvidence` instead (already honest). */
137
+ readonly teardownUnavailable?: boolean;
138
+ /** Present only when `teardownUnavailable` is `true`. */
139
+ readonly teardownUnavailableReason?: ChurnTeardownUnavailableReason;
140
+ /** `true` when {@link BOOTSTRAP_SCRIPT}'s `new ResizeObserver(...)`/`.observe()` setup itself threw — `resizeCount` above is then the empty DEFAULT (the observer was never installed to count anything), never a genuine observation of zero resizes (I-5, the installation-time counterpart to {@link teardownUnavailable}'s teardown-read defect). A well-formed teardown read that genuinely saw zero resizes from a successfully-installed observer leaves this `undefined`. */
141
+ readonly resizeObserverUnavailable?: boolean;
142
+ /** Present only when `resizeObserverUnavailable` is `true`. */
143
+ readonly resizeObserverUnavailableReason?: ChurnResizeObserverUnavailableReason;
144
+ }
145
+
146
+ async function evaluate<T>(client: CDPClient, expression: string): Promise<T> {
147
+ const response = (await client.send('Runtime.evaluate', { expression, returnByValue: true })) as {
148
+ result?: { value?: T };
149
+ };
150
+ return response.result?.value as T;
151
+ }
152
+
153
+ /**
154
+ * Evaluates `expression` HELD (`returnByValue: false`) and returns the
155
+ * resulting live object's own `objectId` — the cross-call handle that
156
+ * replaces a page-observable global. Throws if CDP doesn't hand back an
157
+ * `objectId` (the bootstrap script always returns an object literal, so a
158
+ * missing `objectId` means something is genuinely wrong, not a case to
159
+ * silently degrade from). Also throws if the evaluated expression itself
160
+ * threw: Chrome still hands back a `result.objectId` in that case (a
161
+ * remote Error object), so an `exceptionDetails`-blind read would
162
+ * silently accept the Error object's handle as if it were the real
163
+ * bootstrap state — the setup failure would then only surface later,
164
+ * confusingly, when something tries to use that handle.
165
+ */
166
+ async function evaluateHeld(client: CDPClient, expression: string): Promise<string> {
167
+ const response = (await client.send('Runtime.evaluate', { expression, returnByValue: false })) as {
168
+ result?: { objectId?: string };
169
+ exceptionDetails?: { text?: string; exception?: { description?: string } };
170
+ };
171
+ if (response.exceptionDetails) {
172
+ const detail = response.exceptionDetails.exception?.description ?? response.exceptionDetails.text;
173
+ throw new Error(`injectChurnObservers: BOOTSTRAP_SCRIPT threw during setup: ${detail}`);
174
+ }
175
+ const objectId = response.result?.objectId;
176
+ if (!objectId) {
177
+ throw new Error('injectChurnObservers: BOOTSTRAP_SCRIPT did not return a held object');
178
+ }
179
+ return objectId;
180
+ }
181
+
182
+ /** Calls `functionDeclaration` (a function expression) on a held `objectId` with `this` bound to it, and reads the result back by value. */
183
+ async function callOnHeld<T>(client: CDPClient, objectId: string, functionDeclaration: string): Promise<T> {
184
+ const response = (await client.send('Runtime.callFunctionOn', {
185
+ objectId,
186
+ functionDeclaration,
187
+ returnByValue: true,
188
+ })) as { result?: { value?: T } };
189
+ return response.result?.value as T;
190
+ }
191
+
192
+ /** Resolves the mutation targets retained in the held observer state to CDP identities without exposing a page-observable marker. */
193
+ async function mutationTargetBackendNodeIds(client: CDPClient, stateObjectId: string, count: number): Promise<Array<number | undefined>> {
194
+ const identities = new Array<number | undefined>(count);
195
+ const childObjectIds = new Set<string>();
196
+ let targetsObjectId: string | undefined;
197
+ try {
198
+ const targets = (await client.send('Runtime.callFunctionOn', {
199
+ objectId: stateObjectId,
200
+ functionDeclaration: 'function() { return this.mutations.map(function(mutation) { return mutation.target; }); }',
201
+ returnByValue: false,
202
+ })) as { result?: { objectId?: string } };
203
+ targetsObjectId = targets.result?.objectId;
204
+ if (!targetsObjectId) return identities;
205
+ type RemoteObject = { objectId?: string };
206
+ type PropertyDescriptor = { name?: string; value?: RemoteObject; get?: RemoteObject; set?: RemoteObject; symbol?: RemoteObject };
207
+ const properties = (await client.send('Runtime.getProperties', { objectId: targetsObjectId, ownProperties: true })) as {
208
+ result?: PropertyDescriptor[];
209
+ internalProperties?: PropertyDescriptor[];
210
+ privateProperties?: PropertyDescriptor[];
211
+ };
212
+ for (const descriptor of [...(properties.result ?? []), ...(properties.internalProperties ?? []), ...(properties.privateProperties ?? [])]) {
213
+ for (const object of [descriptor.value, descriptor.get, descriptor.set, descriptor.symbol]) {
214
+ if (object?.objectId) childObjectIds.add(object.objectId);
215
+ }
216
+ }
217
+ const indexesByObjectId = new Map<string, number[]>();
218
+ for (const descriptor of properties.result ?? []) {
219
+ const objectId = descriptor.value?.objectId;
220
+ const index = typeof descriptor.name === 'string' && /^\d+$/u.test(descriptor.name) ? Number(descriptor.name) : undefined;
221
+ if (index === undefined || index >= count || !objectId) continue;
222
+ const indexes = indexesByObjectId.get(objectId) ?? [];
223
+ indexes.push(index);
224
+ indexesByObjectId.set(objectId, indexes);
225
+ }
226
+ for (const [objectId, indexes] of indexesByObjectId) {
227
+ try {
228
+ const described = (await client.send('DOM.describeNode', { objectId })) as { node?: { backendNodeId?: unknown } };
229
+ if (typeof described.node?.backendNodeId === 'number') {
230
+ for (const index of indexes) identities[index] = described.node.backendNodeId;
231
+ }
232
+ } catch {
233
+ // One node's identity read failing leaves only that node selector-only;
234
+ // the remaining targets still resolve rather than the whole batch aborting.
235
+ }
236
+ }
237
+ } catch {
238
+ // Identity enrichment is optional: a failed CDP bridge leaves this legacy
239
+ // record unjoined rather than assigning a caveat through its selector.
240
+ } finally {
241
+ for (const objectId of childObjectIds) {
242
+ try {
243
+ await client.send('Runtime.releaseObject', { objectId });
244
+ } catch {
245
+ // CDP-session-scoped bridge cleanup only.
246
+ }
247
+ }
248
+ if (targetsObjectId) {
249
+ try {
250
+ await client.send('Runtime.releaseObject', { objectId: targetsObjectId });
251
+ } catch {
252
+ // CDP-session-scoped bridge cleanup only.
253
+ }
254
+ }
255
+ }
256
+ return identities;
257
+ }
258
+
259
+ const BOOTSTRAP_SCRIPT = `/* __captureSettleBootstrap */
260
+ (function() {
261
+ var state = { lastMutationTs: performance.now(), mutations: [], mutationsObserved: 0, resizeCount: 0 };
262
+ function describeTarget(node) {
263
+ try {
264
+ if (!node || node.nodeType !== 1) return null;
265
+ var tag = node.tagName ? node.tagName.toLowerCase() : null;
266
+ var id = node.id ? ('#' + node.id) : '';
267
+ var cls = (node.className && typeof node.className === 'string')
268
+ ? ('.' + node.className.trim().split(/\\s+/).join('.'))
269
+ : '';
270
+ return tag ? (tag + id + cls) : null;
271
+ } catch (e) { return null; }
272
+ }
273
+ // I-5 sweep (mirrors the ResizeObserver setup below): unlike
274
+ // ResizeObserver, MutationObserver construction/observe() here is NOT
275
+ // wrapped in a swallowing try/catch -- a genuine setup failure throws
276
+ // straight out of this whole IIFE. Chrome then reports the evaluate as
277
+ // a thrown expression: it hands back an Error remote object (which DOES
278
+ // have its own result.objectId) plus a top-level exceptionDetails.
279
+ // evaluateHeld() checks exceptionDetails BEFORE trusting objectId, so it
280
+ // rejects the Error object's handle and throws its own honest
281
+ // BOOTSTRAP_SCRIPT-threw-during-setup exception right here at injection,
282
+ // instead of accepting the Error object as if it were real state.
283
+ // That is the honest outcome (a real visible failure, not a coerced
284
+ // zero) -- no unavailable marker is needed here.
285
+ var mo = new MutationObserver(function(records) {
286
+ state.lastMutationTs = performance.now();
287
+ for (var i = 0; i < records.length; i++) {
288
+ state.mutationsObserved += 1;
289
+ if (state.mutations.length >= 200) continue;
290
+ var r = records[i];
291
+ state.mutations.push({ t: state.lastMutationTs, type: r.type, selector: describeTarget(r.target), target: r.target });
292
+ }
293
+ });
294
+ mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
295
+ state.mo = mo;
296
+ try {
297
+ var ro = new ResizeObserver(function() {
298
+ state.resizeCount += 1;
299
+ state.lastMutationTs = performance.now();
300
+ });
301
+ ro.observe(document.documentElement);
302
+ state.ro = ro;
303
+ } catch (e) {
304
+ // I-5: ResizeObserver construction/observe() itself threw -- report
305
+ // that honestly via resizeObserverUnavailable rather than silently
306
+ // leaving state.ro undefined and resizeCount stuck at 0, which would
307
+ // be indistinguishable from a genuinely-installed observer that saw
308
+ // zero resizes.
309
+ state.resizeObserverUnavailable = true;
310
+ }
311
+ return state;
312
+ })();`;
313
+
314
+ const SAMPLE_SCRIPT = `/* __captureSettleSample */ function() {
315
+ var state = this;
316
+ var now = performance.now();
317
+ var quietMs = now - state.lastMutationTs;
318
+ var infiniteRunning = false;
319
+ var animationReadFailed = false;
320
+ try {
321
+ var anims = document.getAnimations({ subtree: true });
322
+ for (var i = 0; i < anims.length; i++) {
323
+ var a = anims[i];
324
+ if (a.playState !== 'running') continue;
325
+ var timing = (a.effect && a.effect.getComputedTiming) ? a.effect.getComputedTiming() : null;
326
+ if (timing && timing.iterations === Infinity) { infiniteRunning = true; break; }
327
+ }
328
+ } catch (e) {
329
+ // I-5: document.getAnimations() (or reading one's computed timing)
330
+ // itself threw -- report that honestly via animationReadFailed rather
331
+ // than silently leaving infiniteRunning false, which would make a
332
+ // FAILED read indistinguishable from a page that genuinely has no
333
+ // infinite animation running (the caller forces quietMs to 0 whenever
334
+ // this is true, exactly like a CONFIRMED infinite running animation).
335
+ animationReadFailed = true;
336
+ }
337
+ if (infiniteRunning) quietMs = 0;
338
+ var mutationCount = state.mutations.length;
339
+ var resizeCount = state.resizeCount;
340
+ var signature = document.documentElement.outerHTML.length + ':' + document.querySelectorAll('*').length + ':' + mutationCount + ':' + resizeCount;
341
+ return { signature: signature, quietMs: quietMs, animationReadFailed: animationReadFailed };
342
+ }`;
343
+
344
+ const TEARDOWN_SCRIPT = `/* __captureSettleTeardown */ function() {
345
+ var state = this;
346
+ try { state.mo.disconnect(); } catch (e) {}
347
+ try { if (state.ro) state.ro.disconnect(); } catch (e) {}
348
+ // Project each record to its serializable fields only. The live node kept in
349
+ // state.mutations[i].target stays on the held object for identity resolution;
350
+ // it must not ride the returnByValue read, where a DOM node serializes to a
351
+ // junk {} on every record.
352
+ var mutations = state.mutations.map(function(m) { return { t: m.t, type: m.type, selector: m.selector }; });
353
+ return { mutations: mutations, resizeCount: state.resizeCount, mutationsObserved: state.mutationsObserved, resizeObserverUnavailable: state.resizeObserverUnavailable === true };
354
+ }`;
355
+
356
+ /**
357
+ * Pauses every animation and, in the SAME evaluate, captures each one's
358
+ * pre-pause `playState` alongside a live reference to it — returned HELD
359
+ * (never assigned to `window`) so {@link restoreAnimationsAfterCapture} can
360
+ * later call `.play()` back on the EXACT SAME Animation objects, not ones
361
+ * re-resolved by a second `getAnimations()` enumeration (which could add/
362
+ * drop/reorder entries between the two calls — the same reordering risk
363
+ * `animation.ts`'s D3 identity doc documents for its own bridge). This is
364
+ * I-6's origin capture: it must happen in the SAME script as the mutation,
365
+ * before any other code can observe the page, or a page-visible pause
366
+ * could happen with no matching origin fact to restore from.
367
+ */
368
+ // Exported for a test-only reason (#72, mirrors RESTORE_ANIMATIONS_SCRIPT's
369
+ // own export below): a stub-driven `Runtime.evaluate` response can only
370
+ // fake the OUTCOME of this script, never exercise its own per-`.pause()`
371
+ // catch logic. This export lets a test drive the ACTUAL script body
372
+ // (via `new Function`, against a faked global `document`) proving the
373
+ // script itself -- not just the wrapper that reads its return value --
374
+ // turns a real `.pause()` failure into an honest per-animation
375
+ // `paused[i]=false` + `pauseFailureCount` tally, without flipping the
376
+ // walk-level `ok` (which correctly stays `true`: the walk itself didn't
377
+ // throw).
378
+ export const FREEZE_ANIMATIONS_SCRIPT = `/* __captureFreezeAnimations */
379
+ (function() {
380
+ var anims = [];
381
+ var origin = [];
382
+ var paused = [];
383
+ var pauseFailureCount = 0;
384
+ var ok = true;
385
+ try {
386
+ var all = document.getAnimations({ subtree: true });
387
+ for (var i = 0; i < all.length; i++) {
388
+ anims.push(all[i]);
389
+ origin.push(all[i].playState);
390
+ try {
391
+ all[i].pause();
392
+ paused.push(true);
393
+ } catch (e) {
394
+ // I-4/I-6 (#72): a single animation's own .pause() call failing
395
+ // must NOT be swallowed into a benign "everything froze" outcome --
396
+ // track it per-animation (paused[i]=false, pauseFailureCount++) so
397
+ // the caller can tell "this specific animation was never actually
398
+ // paused" apart from a clean freeze. The surrounding walk's own
399
+ // ok:true only ever meant document.getAnimations() didn't throw,
400
+ // never that every individual pause succeeded -- that conflation
401
+ // is exactly what let --freeze-animations read as fully successful
402
+ // while one animation kept running.
403
+ paused.push(false);
404
+ pauseFailureCount += 1;
405
+ }
406
+ }
407
+ } catch (e) {
408
+ // I-5: document.getAnimations() itself threw -- report that honestly
409
+ // via ok:false rather than a fabricated empty origin that looks
410
+ // identical to "this page genuinely has nothing to freeze."
411
+ ok = false;
412
+ }
413
+ return { anims: anims, origin: origin, paused: paused, pauseFailureCount: pauseFailureCount, ok: ok };
414
+ })();`;
415
+
416
+ /**
417
+ * Function-on-held companion to {@link FREEZE_ANIMATIONS_SCRIPT}: reads
418
+ * back the script's own `ok` flag from the held container BEFORE the
419
+ * caller trusts the origin capture for anything. A held `objectId` alone
420
+ * cannot distinguish "the walk succeeded and there really are zero
421
+ * animations" from "the walk threw and `anims`/`origin` are an empty
422
+ * fallback" — both round-trip the SAME valid-looking held object.
423
+ */
424
+ const FREEZE_ORIGIN_OK_SCRIPT = `/* __captureFreezeOriginOk */ function() { return this.ok === true; }`;
425
+
426
+ /**
427
+ * Function-on-held companion to {@link FREEZE_ANIMATIONS_SCRIPT}: reads
428
+ * back the per-animation pause-failure tally the origin capture recorded
429
+ * (#72). `FREEZE_ORIGIN_OK_SCRIPT`'s `ok:true` only proves the
430
+ * `document.getAnimations()` WALK itself didn't throw -- it says nothing
431
+ * about whether every individual `.pause()` call inside that walk
432
+ * succeeded. Read separately, always AFTER `originCaptureOk` is confirmed
433
+ * (so a genuinely-failed walk never reports a bogus pause tally), so a
434
+ * page whose walk succeeds but whose pauses partially fail is still
435
+ * distinguishable from a clean freeze rather than silently folded into
436
+ * `ok`.
437
+ */
438
+ const FREEZE_PAUSE_STATUS_SCRIPT = `/* __captureFreezePauseStatus */ function() {
439
+ var total = this.anims ? this.anims.length : 0;
440
+ var failures = typeof this.pauseFailureCount === 'number' ? this.pauseFailureCount : 0;
441
+ return { total: total, pauseFailureCount: failures };
442
+ }`;
443
+
444
+ /**
445
+ * Function-on-held companion to {@link FREEZE_ANIMATIONS_SCRIPT}: resumes
446
+ * exactly the animations captured there whose pre-pause `playState` was
447
+ * `'running'`, leaving any animation the page itself had already
448
+ * paused/finished/idle untouched. Unlike the pre-I-6-fix version, a
449
+ * per-animation `.play()` failure is NOT silently swallowed into an
450
+ * overall `true` — it flips the returned `ok` false, so {@link
451
+ * restoreAnimationsAfterCapture} can report an honest `restored:false`
452
+ * instead of over-claiming success for a page that is still partially
453
+ * frozen.
454
+ */
455
+ // Exported for a test-only reason (r4 honesty-sweep review finding): a
456
+ // stub-driven `Runtime.callFunctionOn` response can only fake the OUTCOME
457
+ // of this script, never exercise its own per-`.play()` catch logic. This
458
+ // export lets a test drive the ACTUAL script body (via `new Function`)
459
+ // against a fake `this`, proving the script itself — not just the wrapper
460
+ // that gates on its return value — turns a real `.play()` failure into an
461
+ // honest `false`.
462
+ export const RESTORE_ANIMATIONS_SCRIPT = `/* __captureRestoreAnimations */ function() {
463
+ var state = this;
464
+ try {
465
+ var ok = true;
466
+ for (var i = 0; i < state.anims.length; i++) {
467
+ if (state.origin[i] !== 'running') continue;
468
+ // #72: an animation whose own .pause() call failed at freeze time
469
+ // (state.paused[i] === false) was never actually stopped -- it is
470
+ // still running right now, so calling .play() on it is not a
471
+ // restoration of anything this collector did; it would just report
472
+ // a trivial no-op success and let the freeze's per-animation failure
473
+ // masquerade as a clean restore. Guarded for state.paused possibly
474
+ // being absent (older held container shape) so this stays backward
475
+ // compatible with any pre-#72 fixture.
476
+ if (state.paused && state.paused[i] === false) continue;
477
+ try {
478
+ state.anims[i].play();
479
+ } catch (e) {
480
+ ok = false;
481
+ }
482
+ }
483
+ return ok;
484
+ } catch (e) {
485
+ return false;
486
+ }
487
+ }`;
488
+
489
+ const ANIMATION_INVENTORY_SCRIPT = `/* __captureAnimationInventory */
490
+ (function() {
491
+ function describeTarget(node) {
492
+ try {
493
+ if (!node || node.nodeType !== 1) return null;
494
+ var tag = node.tagName ? node.tagName.toLowerCase() : null;
495
+ var cls = (node.className && typeof node.className === 'string')
496
+ ? ('.' + node.className.trim().split(/\\s+/).join('.'))
497
+ : '';
498
+ return tag ? (tag + cls) : null;
499
+ } catch (e) { return null; }
500
+ }
501
+ var out = [];
502
+ try {
503
+ var anims = document.getAnimations({ subtree: true });
504
+ for (var i = 0; i < anims.length; i++) {
505
+ var a = anims[i];
506
+ var timing = (a.effect && a.effect.getComputedTiming) ? a.effect.getComputedTiming() : null;
507
+ var iterations = timing ? timing.iterations : null;
508
+ var infinite = iterations === Infinity;
509
+ out.push({
510
+ selector: (a.effect && a.effect.target) ? describeTarget(a.effect.target) : null,
511
+ animationName: a.animationName || a.id || null,
512
+ durationMs: timing ? timing.duration : null,
513
+ iterationCount: infinite ? 'infinite' : (typeof iterations === 'number' ? iterations : null),
514
+ infinite: infinite,
515
+ playState: a.playState,
516
+ });
517
+ }
518
+ } catch (e) {
519
+ // I-5: document.getAnimations() (or reading an animation's computed
520
+ // timing) itself threw -- report that honestly via ok:false rather
521
+ // than falling through to an empty-but-"successful"-looking array
522
+ // ({@link collectAnimationEvidence} turns this into available:false).
523
+ return { animations: [], ok: false };
524
+ }
525
+ return { animations: out, ok: true };
526
+ })();`;
527
+
528
+ /**
529
+ * The cross-call handle returned by {@link injectChurnObservers} — wraps
530
+ * the held CDP `objectId` of the churn-observer state object. It replaces
531
+ * the old `window.__captureSettle` global: nothing is ever page-observable,
532
+ * so a page-defined setter for that name has nothing to fire against. The
533
+ * caller must thread it through {@link buildDomSettleSampler} and
534
+ * {@link collectChurnEvidence} — the latter owns releasing it.
535
+ */
536
+ export interface ChurnObserverHandle {
537
+ readonly stateObjectId: string;
538
+ }
539
+
540
+ /** Installs the MutationObserver/ResizeObserver churn state and returns a held handle to it. Call exactly once per snapshot; the returned handle must be released via {@link collectChurnEvidence}. */
541
+ export async function injectChurnObservers(client: CDPClient): Promise<ChurnObserverHandle> {
542
+ const stateObjectId = await evaluateHeld(client, BOOTSTRAP_SCRIPT);
543
+ return { stateObjectId };
544
+ }
545
+
546
+ /**
547
+ * `buildDomSettleSampler`'s actual sample shape — `SettleSample<string>`
548
+ * plus the I-5 animation-read-availability fact {@link SAMPLE_SCRIPT}'s
549
+ * own catch branch now reports explicitly.
550
+ */
551
+ export interface DomSettleSample extends SettleSample<string> {
552
+ /** `true` when the per-sample `document.getAnimations()` read (inside {@link SAMPLE_SCRIPT}) itself threw. A failed read must never look "quiet": `quietMs` above is forced to `0` whenever this is `true` — exactly the same fail-safe treatment `SAMPLE_SCRIPT` already gives a CONFIRMED running-infinite animation — and this fact rides along on the sample so a consumer can still tell a real failed read apart from a genuinely quiet page. */
553
+ readonly animationReadUnavailable: boolean;
554
+ }
555
+
556
+ /** Builds a `pollForSettle`-compatible sampler backed by the injected churn observers, read back through the held handle. */
557
+ export function buildDomSettleSampler(client: CDPClient, handle: ChurnObserverHandle): () => Promise<DomSettleSample> {
558
+ return async () => {
559
+ const value = await callOnHeld<{ signature: string; quietMs: number; animationReadFailed?: boolean }>(client, handle.stateObjectId, SAMPLE_SCRIPT);
560
+ const animationReadUnavailable = value.animationReadFailed === true;
561
+ return {
562
+ signature: value.signature,
563
+ quietMs: animationReadUnavailable ? 0 : value.quietMs,
564
+ animationReadUnavailable,
565
+ };
566
+ };
567
+ }
568
+
569
+ export function domSignaturesEqual(a: string, b: string): boolean {
570
+ return a === b;
571
+ }
572
+
573
+ /**
574
+ * Tears down the churn observers, returns everything they recorded, and
575
+ * releases the held handle — the ONLY place `handle.stateObjectId` is
576
+ * released. ALWAYS call this after the settle poll finishes (settled or
577
+ * not) — it is cleanup, not conditional evidence-gathering. The release is
578
+ * attempted even if reading the teardown result throws, and a release
579
+ * failure can never leak page-observable state: the handle is only ever
580
+ * CDP-session-scoped remote-object memory, freed at the latest when the
581
+ * tab closes.
582
+ */
583
+ export async function collectChurnEvidence(client: CDPClient, handle: ChurnObserverHandle): Promise<ChurnEvidenceRaw> {
584
+ try {
585
+ const value = await callOnHeld<{
586
+ mutations?: unknown;
587
+ resizeCount?: unknown;
588
+ mutationsObserved?: unknown;
589
+ resizeObserverUnavailable?: unknown;
590
+ }>(client, handle.stateObjectId, TEARDOWN_SCRIPT);
591
+ // I-5: a fully-missing `value` (the callFunctionOn round trip returned
592
+ // no usable result at all) already throws honestly right here —
593
+ // `value.mutations` below rejects on `undefined` before any `??` could
594
+ // ever mask it. The case this guards is different: `value` IS a real
595
+ // object (TEARDOWN_SCRIPT ran) but its shape is wrong — which `??`
596
+ // alone silently turned into an empty/zero "successful" churn read.
597
+ const mutationsValid = Array.isArray(value.mutations);
598
+ const resizeCountValid = typeof value.resizeCount === 'number';
599
+ if (!mutationsValid || !resizeCountValid) {
600
+ return { mutations: [], resizeCount: 0, teardownUnavailable: true, teardownUnavailableReason: 'malformed-value' };
601
+ }
602
+ // I-5: `resizeObserverUnavailable` is a DIFFERENT fact from
603
+ // `teardownUnavailable` above — the teardown read itself is well-formed
604
+ // here, but it is faithfully reporting that BOOTSTRAP_SCRIPT's own
605
+ // ResizeObserver setup failed at installation time, so `resizeCount`
606
+ // is the empty DEFAULT rather than a genuine zero-resize observation.
607
+ // Never conflate the two: a malformed teardown read never sets this,
608
+ // and a resize-observer setup failure never sets `teardownUnavailable`.
609
+ const mutations = value.mutations as ChurnMutationRecord[];
610
+ const targetBackendNodeIds = await mutationTargetBackendNodeIds(client, handle.stateObjectId, mutations.length);
611
+ return {
612
+ mutations: mutations.map((mutation, index) => ({
613
+ ...mutation,
614
+ ...(targetBackendNodeIds[index] === undefined ? {} : { backendNodeId: targetBackendNodeIds[index] }),
615
+ })),
616
+ resizeCount: value.resizeCount as number,
617
+ mutationsObserved: typeof value.mutationsObserved === 'number' ? value.mutationsObserved : undefined,
618
+ ...(value.resizeObserverUnavailable === true
619
+ ? { resizeObserverUnavailable: true, resizeObserverUnavailableReason: 'setup-threw' as const }
620
+ : {}),
621
+ };
622
+ } finally {
623
+ try {
624
+ await client.send('Runtime.releaseObject', { objectId: handle.stateObjectId });
625
+ } catch {
626
+ // Best-effort — nothing page-observable to leak from a release failure.
627
+ }
628
+ }
629
+ }
630
+
631
+ /** Held handle to the origin capture {@link freezeAnimationsBeforeCapture} returns — the cross-call reference {@link restoreAnimationsAfterCapture} restores from and releases. */
632
+ export interface AnimationFreezeHandle {
633
+ readonly containerObjectId: string;
634
+ /** `false` when the browser-wide `Animation.setPlaybackRate({playbackRate:0})` override itself failed to apply (I-6) — the per-animation `.pause()` calls captured in the SAME evaluate as the origin capture are still the authoritative freeze for whatever animations existed AT freeze time, but any animation the page creates AFTER this point will NOT be frozen, and previously no fact ever recorded that. `true` only means Chrome accepted the override call, not that every future animation is guaranteed frozen. */
635
+ readonly rateOverrideApplied: boolean;
636
+ /** `true` when at least one animation's own `.pause()` call inside {@link FREEZE_ANIMATIONS_SCRIPT} threw (#72) — that animation is still running right now, so the freeze is only PARTIAL even though the surrounding walk itself (and `rateOverrideApplied`) can both be genuinely successful. `false` means every animation the walk enumerated was confirmed paused, or a read-back failure of the tally itself was treated pessimistically as incomplete (never silently assumed clean). The caller must not report `--freeze-animations` as fully successful when this is `true`. */
637
+ readonly freezeIncomplete: boolean;
638
+ /** Count of animations whose `.pause()` call failed (#72) — `0` when every enumerated animation was confirmed paused (matching `freezeIncomplete:false`), OR when the tally itself could not be read back (matching `freezeIncomplete:true` — the count is then genuinely UNKNOWN, not confirmed zero; `freezeIncomplete` is the operative gate for that case, not this count). Present alongside `freezeIncomplete` so a caller can report how many animations were never frozen when that number IS known. */
639
+ readonly unfrozenCount: number;
640
+ }
641
+
642
+ /**
643
+ * Best-effort-pauses every running animation so a page that would
644
+ * otherwise never settle (because of an infinite CSS/WAAPI animation) can,
645
+ * capturing each animation's pre-pause `playState` (I-6's required origin
646
+ * capture) in the SAME evaluate as the pause — see {@link
647
+ * FREEZE_ANIMATIONS_SCRIPT}. Call BEFORE {@link injectChurnObservers} so
648
+ * freeze-induced style settling isn't itself recorded as churn.
649
+ *
650
+ * I-6 ordering (fixed): the restorable origin handle is acquired FIRST,
651
+ * via {@link FREEZE_ANIMATIONS_SCRIPT}'s own evaluate — BEFORE the
652
+ * browser-wide `Animation.setPlaybackRate(0)` override is ever applied.
653
+ * Previously the override ran first; if the subsequent evaluate then
654
+ * failed or returned no held object, the page was left with the override
655
+ * applied and NO handle to ever reset it from (restoreAnimationsAfterCapture
656
+ * is only ever reachable through a defined handle) — a silently-permanent
657
+ * freeze. Now, a failure acquiring the origin handle returns `undefined`
658
+ * WITHOUT ever touching `Animation.setPlaybackRate`, so a failed freeze
659
+ * attempt never mutates the page at all.
660
+ *
661
+ * Returns the held handle {@link restoreAnimationsAfterCapture} needs, or
662
+ * `undefined` when the evaluate didn't hand back a held object (nothing
663
+ * was mutated — there is simply no origin reference to restore from). The
664
+ * caller must treat an `undefined` handle as "restoration cannot be
665
+ * guaranteed" and surface `animationsRestored: false` — never silently
666
+ * treat the forced state as already clean.
667
+ *
668
+ * #72: a DEFINED handle is not, by itself, proof every animation actually
669
+ * froze — `handle.freezeIncomplete`/`handle.unfrozenCount` (read back via
670
+ * {@link FREEZE_PAUSE_STATUS_SCRIPT}) report whether one or more
671
+ * individual `.pause()` calls inside the SAME origin-capture evaluate
672
+ * failed. A caller surfacing `--freeze-animations` success must check
673
+ * these alongside `rateOverrideApplied` — a defined handle with a
674
+ * successfully-applied override can still be a PARTIAL freeze.
675
+ */
676
+ export async function freezeAnimationsBeforeCapture(client: CDPClient): Promise<AnimationFreezeHandle | undefined> {
677
+ let containerObjectId: string | undefined;
678
+ try {
679
+ const response = (await client.send('Runtime.evaluate', {
680
+ expression: FREEZE_ANIMATIONS_SCRIPT,
681
+ returnByValue: false,
682
+ })) as { result?: { objectId?: string } };
683
+ containerObjectId = response.result?.objectId;
684
+ } catch {
685
+ // The pause/origin-capture script itself failed to evaluate — nothing
686
+ // was held AND nothing was mutated (the browser-wide override below
687
+ // never runs), so there is no origin to restore from and nothing to
688
+ // undo either.
689
+ return undefined;
690
+ }
691
+ if (!containerObjectId) {
692
+ // The evaluate round-tripped but handed back no held object — same
693
+ // "nothing to restore from" outcome as above, and for the same reason
694
+ // the browser-wide override must NOT be applied here either.
695
+ return undefined;
696
+ }
697
+
698
+ // I-5: FREEZE_ANIMATIONS_SCRIPT's own document.getAnimations() walk can
699
+ // throw INSIDE its try/catch and still hand back a valid-looking held
700
+ // object ({anims:[], origin:[], ok:false}) — a held objectId alone
701
+ // cannot tell that apart from a page that genuinely has zero animations
702
+ // to freeze. Read the script's own `ok` flag back before trusting the
703
+ // origin capture, or (per the ordering rule this function documents)
704
+ // ever touching the browser-wide override below.
705
+ let originCaptureOk = false;
706
+ try {
707
+ originCaptureOk = (await callOnHeld<boolean>(client, containerObjectId, FREEZE_ORIGIN_OK_SCRIPT)) === true;
708
+ } catch {
709
+ originCaptureOk = false;
710
+ }
711
+ if (!originCaptureOk) {
712
+ // Nothing trustworthy to restore from — release the now-useless held
713
+ // object and report "no origin handle," exactly like the two failure
714
+ // paths above, WITHOUT ever applying the browser-wide override.
715
+ try {
716
+ await client.send('Runtime.releaseObject', { objectId: containerObjectId });
717
+ } catch {
718
+ // Best-effort — CDP-session-scoped remote-object memory only.
719
+ }
720
+ return undefined;
721
+ }
722
+
723
+ let rateOverrideApplied = true;
724
+ try {
725
+ await client.send('Animation.setPlaybackRate', { playbackRate: 0 });
726
+ } catch {
727
+ // I-6: the browser-wide override itself failed to apply — record that
728
+ // fact explicitly (some Chrome/target combos may reject it) rather
729
+ // than swallowing it as merely best-effort. The per-animation pauses
730
+ // captured in the SAME evaluate above are still the authoritative
731
+ // freeze for whatever existed at freeze time.
732
+ rateOverrideApplied = false;
733
+ }
734
+
735
+ // #72: read back the per-animation pause-failure tally FREEZE_ANIMATIONS_SCRIPT
736
+ // recorded — originCaptureOk above only proves the enumeration walk didn't
737
+ // throw, never that every individual .pause() call inside it succeeded. A
738
+ // failed read-back of the tally itself is treated pessimistically as
739
+ // incomplete (never silently assumed clean), same posture as every other
740
+ // I-5/I-6 read in this module.
741
+ let freezeIncomplete = false;
742
+ let unfrozenCount = 0;
743
+ try {
744
+ const pauseStatus = await callOnHeld<{ total?: number; pauseFailureCount?: number }>(
745
+ client,
746
+ containerObjectId,
747
+ FREEZE_PAUSE_STATUS_SCRIPT,
748
+ );
749
+ unfrozenCount = typeof pauseStatus?.pauseFailureCount === 'number' ? pauseStatus.pauseFailureCount : 0;
750
+ freezeIncomplete = unfrozenCount > 0;
751
+ } catch {
752
+ freezeIncomplete = true;
753
+ }
754
+
755
+ return { containerObjectId, rateOverrideApplied, freezeIncomplete, unfrozenCount };
756
+ }
757
+
758
+ /**
759
+ * Restores every animation {@link freezeAnimationsBeforeCapture} paused
760
+ * back to its captured pre-pause `playState`, and releases the held
761
+ * handle — the ONLY place `handle.containerObjectId` is released. Never
762
+ * throws: any failure (the per-animation restore call, an individual
763
+ * `.play()` inside it, or the browser-wide playback-rate reset) is caught
764
+ * and reported as `restored: false` rather than left to abort the
765
+ * caller's snapshot flow. This is the ONLY sanctioned way to undo {@link
766
+ * freezeAnimationsBeforeCapture} — callers must call it exactly once per
767
+ * handle, exception-safely (e.g. from a `finally`), so a
768
+ * forced-but-uncaptured page is never left frozen past this snapshot.
769
+ *
770
+ * I-6 honesty (fixed): `restored` is `true` ONLY when EVERY restorative
771
+ * step actually succeeded — the per-animation `.play()` calls (via {@link
772
+ * RESTORE_ANIMATIONS_SCRIPT}'s now-honest `ok` return) AND the
773
+ * browser-wide `Animation.setPlaybackRate(1)` reset. Previously a per-
774
+ * animation `.play()` failure was swallowed inside the page script (always
775
+ * returning `true`) and a reset failure was caught but never reflected in
776
+ * the result — both let `{restored:true}` reach the caller (and
777
+ * `meta.json`) while the page was still partially or fully forced. A held-
778
+ * object release failure is NOT page-observable (CDP-session-scoped memory
779
+ * only) so it does not gate `restored`.
780
+ */
781
+ export async function restoreAnimationsAfterCapture(
782
+ client: CDPClient,
783
+ handle: AnimationFreezeHandle,
784
+ ): Promise<{ restored: boolean }> {
785
+ let playRestored = false;
786
+ try {
787
+ playRestored = (await callOnHeld<boolean>(client, handle.containerObjectId, RESTORE_ANIMATIONS_SCRIPT)) === true;
788
+ } catch {
789
+ playRestored = false;
790
+ } finally {
791
+ try {
792
+ await client.send('Runtime.releaseObject', { objectId: handle.containerObjectId });
793
+ } catch {
794
+ // Best-effort cleanup only — CDP-session-scoped remote-object memory,
795
+ // never page-observable state, so a release failure never gates
796
+ // `restored` below.
797
+ }
798
+ }
799
+ let rateReset = false;
800
+ try {
801
+ await client.send('Animation.setPlaybackRate', { playbackRate: 1 });
802
+ rateReset = true;
803
+ } catch {
804
+ // The browser-wide override reset itself failed — every animation may
805
+ // still be forced to playbackRate 0, a real page-observable
806
+ // consequence, so this MUST gate `restored` honestly rather than being
807
+ // swallowed as merely best-effort (I-6).
808
+ rateReset = false;
809
+ }
810
+ return { restored: playRestored && rateReset };
811
+ }
812
+
813
+ /** Fixed, factual reasons {@link collectAnimationEvidence} could not determine the page's animations (never a raw exception message, which is unbounded/page-influenced). Present only when {@link AnimationEvidenceResult.available} is `false`. */
814
+ export type AnimationEvidenceUnavailableReason = 'evaluate-failed' | 'get-animations-threw';
815
+
816
+ /**
817
+ * {@link collectAnimationEvidence}'s actual return shape — `AnimationEvidence`
818
+ * (the wire/artifact type from `types.ts`) plus the I-5 availability pair.
819
+ * Declared here rather than added to `types.ts` (outside this remediation's
820
+ * owned file set): structurally compatible everywhere a plain
821
+ * `AnimationEvidence` is expected (e.g. {@link groupChurnEvidence}'s
822
+ * parameter, or the raw `writeJsonPrivate` call in `snapshot.ts` that
823
+ * serializes this verbatim into `animation.json` on the evidence-only
824
+ * branch), so the extra fields flow straight into the artifact too.
825
+ */
826
+ export interface AnimationEvidenceResult extends AnimationEvidence {
827
+ /** `false` when `document.getAnimations()` itself threw, or the CDP evaluate round trip failed/returned no usable value — an empty `animations` array is then "could not enumerate", not "genuinely no animations" (I-5). Always `true` on a normal run, including one where the page really has zero animations. */
828
+ readonly available: boolean;
829
+ /** Present only when `available` is `false`. */
830
+ readonly unavailableReason?: AnimationEvidenceUnavailableReason;
831
+ }
832
+
833
+ /**
834
+ * Inventories `document.getAnimations()` — used both for `animation.json`
835
+ * evidence and churn-region grouping. Distinguishes "the walk threw / the
836
+ * evaluate round trip failed" (`available:false` + reason, `animations:
837
+ * []` unavoidably empty because there was nothing to report) from "the walk
838
+ * succeeded and the page genuinely has zero animations" (`available:true`,
839
+ * `animations: []`) — both previously collapsed to the same empty array
840
+ * (I-5).
841
+ */
842
+ export async function collectAnimationEvidence(client: CDPClient): Promise<AnimationEvidenceResult> {
843
+ let records: AnimationEvidenceRecord[] = [];
844
+ let available = true;
845
+ let unavailableReason: AnimationEvidenceUnavailableReason | undefined;
846
+ try {
847
+ const value = await evaluate<{ animations?: AnimationEvidenceRecord[]; ok?: boolean } | undefined>(client, ANIMATION_INVENTORY_SCRIPT);
848
+ if (value && value.ok === true && Array.isArray(value.animations)) {
849
+ records = value.animations;
850
+ } else {
851
+ // The evaluate round-tripped but either returned no usable value, or
852
+ // the page-side `document.getAnimations()` walk itself threw (the
853
+ // script's own catch branch signals `ok:false`) — both are "could not
854
+ // determine the page's animations", never a genuinely-empty walk.
855
+ available = false;
856
+ unavailableReason = 'get-animations-threw';
857
+ }
858
+ } catch {
859
+ // The CDP evaluate round trip itself failed (a transport/session-level
860
+ // throw, before any script-level ok:false could ever be reported).
861
+ available = false;
862
+ unavailableReason = 'evaluate-failed';
863
+ }
864
+ // Route the page-controlled `selector`/`animationName` through the shared
865
+ // sanitizer before either lands in animation.json (written raw by the
866
+ // orchestrator) or in a churn region reason — a secret in an id/class or
867
+ // an animation-name must not reach the evidence artifact.
868
+ const animations = records.map((a) => ({
869
+ ...a,
870
+ selector: a.selector === null ? null : sanitizeString(a.selector),
871
+ animationName: a.animationName === null ? null : sanitizeString(a.animationName),
872
+ }));
873
+ const infiniteCount = animations.filter((a) => a.infinite).length;
874
+ return {
875
+ animations,
876
+ infiniteCount,
877
+ available,
878
+ ...(unavailableReason ? { unavailableReason } : {}),
879
+ };
880
+ }
881
+
882
+ // ============================================================================
883
+ // groupChurnEvidence — pure, no CDP
884
+ // ============================================================================
885
+
886
+ interface MutationBucket {
887
+ readonly selector?: string;
888
+ count: number;
889
+ first?: number;
890
+ last?: number;
891
+ }
892
+
893
+ /**
894
+ * `groupChurnEvidence`'s actual `animationEvidence` parameter shape — a
895
+ * bare {@link AnimationEvidence} (e.g. a hand-built test fixture that
896
+ * doesn't care about availability) works exactly as before; the I-5
897
+ * `available`/`unavailableReason` pair {@link collectAnimationEvidence}
898
+ * actually returns (as {@link AnimationEvidenceResult}) is OPTIONAL here
899
+ * but honored whenever present — see the `animationEvidenceUnavailable`
900
+ * fact below (#10).
901
+ */
902
+ type AnimationEvidenceInput = AnimationEvidence & Partial<Pick<AnimationEvidenceResult, 'available' | 'unavailableReason'>>;
903
+
904
+ /**
905
+ * `groupChurnEvidence`'s actual `report` shape — `ChurnReport` (the
906
+ * wire/artifact type from `types.ts`) plus the I-5 honesty markers this
907
+ * remediation adds. Declared here rather than added to `types.ts`
908
+ * (outside this remediation's owned file set), mirroring
909
+ * {@link AnimationEvidenceResult}'s same rationale: structurally
910
+ * compatible everywhere a plain `ChurnReport` is expected, and
911
+ * `snapshot.ts` writes this object verbatim into `churn.json`, so the
912
+ * extra fields flow straight into the artifact.
913
+ */
914
+ export interface ChurnReportRecord extends ChurnReport {
915
+ /** `true` when the churn-observer teardown read ({@link ChurnEvidenceRaw.teardownUnavailable}) was malformed — `totalMutations`/`resizeCount`/`regions` above are then the empty/zero DEFAULT from a failed read, not a genuine observation of a quiet page (#6). */
916
+ readonly mutationsUnavailable?: boolean;
917
+ /** Present only when `mutationsUnavailable` is `true`. */
918
+ readonly mutationsUnavailableReason?: ChurnTeardownUnavailableReason;
919
+ /** `true` when `raw.mutationsObserved` was never supplied — the 200-record cap's true drop count is unknown, so `mutationsTruncated` being absent must NOT be read as "confirmed nothing was dropped" (#9). */
920
+ readonly mutationsTruncationUnknown?: boolean;
921
+ /** `true` when the `animationEvidence` this report was grouped from itself had `available:false` — animation-based regions above can then only ever be "none found because the read failed," not "none found because nothing is running" (#10). */
922
+ readonly animationEvidenceUnavailable?: boolean;
923
+ /** Present only when `animationEvidenceUnavailable` is `true`. */
924
+ readonly animationEvidenceUnavailableReason?: AnimationEvidenceUnavailableReason;
925
+ /** `true` when `raw.resizeObserverUnavailable` was set — the ResizeObserver setup itself failed at installation time, so `resizeCount` above is the empty DEFAULT, never a genuine observation of zero resizes (I-5, mirrors `mutationsUnavailable`'s teardown-read counterpart, but on observer INSTALLATION instead). */
926
+ readonly resizeObserverUnavailable?: boolean;
927
+ /** Present only when `resizeObserverUnavailable` is `true`. */
928
+ readonly resizeObserverUnavailableReason?: ChurnResizeObserverUnavailableReason;
929
+ }
930
+
931
+ /**
932
+ * Groups raw mutation + animation evidence into `churn.json`'s `regions`
933
+ * and the matching `UnstableRegion[]` (1:1 by id). Dual-sourced by design:
934
+ * one region per distinct mutation `selector` (DOM-mutation-based), THEN
935
+ * one region per `infinite && playState === 'running'` animation
936
+ * (animation-based) — do not simplify this to only one source.
937
+ */
938
+ export function groupChurnEvidence(
939
+ raw: ChurnEvidenceRaw,
940
+ animationEvidence: AnimationEvidenceInput,
941
+ elapsedMs: number,
942
+ settleTimeoutMs: number,
943
+ ): { report: ChurnReportRecord; unstableRegions: UnstableRegion[] } {
944
+ const buckets = new Map<string, MutationBucket & { backendNodeId?: number }>();
945
+ for (const mutation of raw.mutations) {
946
+ const backendNodeId = typeof mutation.backendNodeId === 'number' ? mutation.backendNodeId : undefined;
947
+ const key = backendNodeId === undefined ? `selector:${mutation.selector ?? '(unknown)'}` : `backend:${backendNodeId}`;
948
+ let bucket = buckets.get(key);
949
+ if (!bucket) {
950
+ bucket = { selector: mutation.selector === null ? undefined : sanitizeString(mutation.selector), count: 0, ...(backendNodeId === undefined ? {} : { backendNodeId }) };
951
+ buckets.set(key, bucket);
952
+ }
953
+ bucket.count += 1;
954
+ bucket.first = bucket.first === undefined ? mutation.t : Math.min(bucket.first, mutation.t);
955
+ bucket.last = bucket.last === undefined ? mutation.t : Math.max(bucket.last, mutation.t);
956
+ }
957
+
958
+ const regions: ChurnRegionRecord[] = [];
959
+ const unstableRegions: UnstableRegion[] = [];
960
+ let regionIndex = 0;
961
+
962
+ for (const bucket of buckets.values()) {
963
+ regionIndex += 1;
964
+ const id = `region-${regionIndex}`;
965
+ const reason = `${bucket.count} DOM mutation${bucket.count === 1 ? '' : 's'} observed over ${elapsedMs}ms`;
966
+ regions.push({
967
+ id,
968
+ ...(bucket.selector ? { selector: bucket.selector } : {}),
969
+ reason,
970
+ mutationCount: bucket.count,
971
+ ...(bucket.first !== undefined ? { firstMutationAtMs: bucket.first } : {}),
972
+ ...(bucket.last !== undefined ? { lastMutationAtMs: bucket.last } : {}),
973
+ });
974
+ unstableRegions.push({
975
+ id,
976
+ ...(bucket.selector ? { selector: bucket.selector } : {}),
977
+ ...(bucket.backendNodeId === undefined ? {} : { elementIds: [String(bucket.backendNodeId)] }),
978
+ reason,
979
+ });
980
+ }
981
+
982
+ for (const anim of animationEvidence.animations) {
983
+ if (!anim.infinite || anim.playState !== 'running') continue;
984
+ regionIndex += 1;
985
+ const id = `region-${regionIndex}`;
986
+ const name = anim.animationName ? sanitizeString(anim.animationName) : 'unnamed animation';
987
+ const selector = anim.selector ? sanitizeString(anim.selector) : undefined;
988
+ const durationPart = anim.durationMs !== null ? ` (duration ${anim.durationMs}ms)` : '';
989
+ const reason = `infinite animation "${name}"${durationPart} still running`;
990
+ regions.push({
991
+ id,
992
+ ...(selector ? { selector } : {}),
993
+ reason,
994
+ mutationCount: 0,
995
+ });
996
+ unstableRegions.push({
997
+ id,
998
+ ...(selector ? { selector } : {}),
999
+ reason,
1000
+ });
1001
+ }
1002
+
1003
+ // I-5 (#9): the churn observer's bootstrap script caps kept mutation
1004
+ // records at 200 but counts every one it actually saw
1005
+ // (`mutationsObserved`) — when that total exceeds what was kept,
1006
+ // `regions`/`totalMutations` are working from a capped set unless this
1007
+ // fact says so. `mutationsObserved` being ABSENT is a DIFFERENT fact
1008
+ // from "confirmed nothing was dropped": defaulting it to `raw.mutations
1009
+ // .length` would silently claim the latter every time it's merely
1010
+ // unknown, so it now stays honestly distinguishable via
1011
+ // `mutationsTruncationUnknown`.
1012
+ const mutationsObserved = raw.mutationsObserved;
1013
+ const mutationsTruncationUnknown = mutationsObserved === undefined;
1014
+ const mutationsTruncated =
1015
+ mutationsObserved !== undefined && mutationsObserved > raw.mutations.length ? mutationsObserved - raw.mutations.length : undefined;
1016
+
1017
+ // #10: an `animationEvidence` read that itself failed must not read as
1018
+ // "grouped successfully, found no running infinite animations" —
1019
+ // `animationEvidence.animations` is already honestly empty on that
1020
+ // failure ({@link collectAnimationEvidence}), but that emptiness alone
1021
+ // is indistinguishable from a genuine zero-animation page once it only
1022
+ // feeds into the (also legitimately empty) animation-based regions above.
1023
+ const animationEvidenceUnavailable = animationEvidence.available === false;
1024
+
1025
+ const report: ChurnReportRecord = {
1026
+ settled: false,
1027
+ settleTimeoutMs,
1028
+ elapsedMs,
1029
+ totalMutations: raw.mutations.length,
1030
+ resizeCount: raw.resizeCount,
1031
+ ...(mutationsTruncated !== undefined ? { mutationsTruncated } : {}),
1032
+ ...(mutationsTruncationUnknown ? { mutationsTruncationUnknown: true } : {}),
1033
+ ...(raw.teardownUnavailable
1034
+ ? { mutationsUnavailable: true, ...(raw.teardownUnavailableReason ? { mutationsUnavailableReason: raw.teardownUnavailableReason } : {}) }
1035
+ : {}),
1036
+ ...(raw.resizeObserverUnavailable
1037
+ ? {
1038
+ resizeObserverUnavailable: true,
1039
+ ...(raw.resizeObserverUnavailableReason ? { resizeObserverUnavailableReason: raw.resizeObserverUnavailableReason } : {}),
1040
+ }
1041
+ : {}),
1042
+ ...(animationEvidenceUnavailable
1043
+ ? {
1044
+ animationEvidenceUnavailable: true,
1045
+ ...(animationEvidence.unavailableReason ? { animationEvidenceUnavailableReason: animationEvidence.unavailableReason } : {}),
1046
+ }
1047
+ : {}),
1048
+ regions,
1049
+ };
1050
+ return { report, unstableRegions };
1051
+ }