@crouton-kit/capture 1.3.8 → 1.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- 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
|
+
}
|