@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.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,385 @@
1
+ /**
2
+ * `animation.json` collector — the settled-capture animation inventory:
3
+ * `document.getAnimations({subtree:true})` target, duration, iteration
4
+ * count, run state, infinite-loop flag, frozen status, and (the
5
+ * cross-artifact join key) each target's `backendNodeId`.
6
+ *
7
+ * Identity (D3): to attach `backendNodeId` to an animation's target this
8
+ * collector runs its OWN inventory `Runtime.evaluate` that enumerates
9
+ * `getAnimations()` AND, in that SAME script, collects each element
10
+ * `a.effect.target` into a page-side array, recording the target's index
11
+ * on the record as `targetIdx`. Resolving the targets in a SECOND
12
+ * independent `getAnimations()` call would risk an enumeration-order
13
+ * mismatch (a finished animation dropping out, or reordering between the
14
+ * two calls), so enumeration and the identity collection MUST share one
15
+ * evaluate. The walk never assigns anything to `window` or any other
16
+ * page-observable location: its return value is a plain in-memory
17
+ * `{ facts, elements }` object, read back purely through CDP's own
18
+ * remote-object identity — the exact same CDP-only identity bridge
19
+ * geometry.ts uses and exports (`ownPropertyObjectIds`/`readHeldValue`/
20
+ * `resolveIndexedObjectIds`/`describeBackendNodeId`), reused here rather
21
+ * than duplicated. A page can predefine a setter for any global name it can
22
+ * guess; it can observe nothing here, because nothing is ever set on it.
23
+ * Every held container/facts/elements `objectId` is released via
24
+ * `Runtime.releaseObject` in `finally` (unconditionally) — a release
25
+ * failure is recorded as the factual `bridgeCleanupFailed`, but can never
26
+ * imply a contaminated baseline: there is no page-observable state left to
27
+ * leak, only CDP-session-scoped remote-object memory freed when the tab
28
+ * closes.
29
+ *
30
+ * The inventory script's `facts` array has the same bare record shape as
31
+ * settle.ts's `collectAnimationEvidence` (`selector, animationName,
32
+ * durationMs, iterationCount, infinite, playState`) plus the `targetIdx`
33
+ * bridge index; this collector adds `id`, `frozen`, `backendNodeId`, and a
34
+ * `coverage` scope fact on top. By the time this collector runs,
35
+ * `snapshot.ts` has already applied `--freeze-animations` (if requested)
36
+ * via settle.ts's CDP freeze helpers — this collector only reads the
37
+ * resulting inventory, never freezes.
38
+ *
39
+ * `frozen` honesty (I-4): `--freeze-animations` pauses EVERY animation
40
+ * that existed at freeze time via settle.ts's own, separate
41
+ * `getAnimations()` walk, and CDP's `Animation.setPlaybackRate` override
42
+ * is deliberately invisible to JS (confirmed: it never touches the
43
+ * WAAPI-visible `Animation.playbackRate`). By the time THIS collector
44
+ * re-enumerates independently, an animation the freeze forced from
45
+ * `'running'` to `'paused'` is indistinguishable, by playState alone, from
46
+ * one the page had already paused (or created already-paused) before or
47
+ * without the freeze ever touching it — WAAPI exposes no pause-history.
48
+ * `frozen` is therefore honestly scoped as "paused while freeze was
49
+ * requested" (co-occurrence), never as proof this specific animation was
50
+ * forced — see the field doc below. `report.freezeRequested` restates
51
+ * `ctx.freezeAnimations` so a reader of `animation.json` alone (without
52
+ * `meta.json`) can tell "freeze wasn't requested" apart from "freeze was
53
+ * requested but nothing ended up paused".
54
+ *
55
+ * Scope (D5): `getAnimations({subtree:true})` covers the top document's own
56
+ * shadow subtrees but NOT nested iframe documents (each has its own
57
+ * `getAnimations`). `coverage.iframesNotWalked` counts the top-document
58
+ * iframes whose animations are therefore absent — read as one
59
+ * order-independent count (a bare `querySelectorAll('iframe').length`, no
60
+ * enumeration coupling) — so downstream cannot read the omission as "no
61
+ * animation".
62
+ */
63
+
64
+ import type { Collector } from '../types.js';
65
+ import { ownPropertyObjectIds, readHeldValue, resolveIndexedObjectIds, describeBackendNodeId } from './geometry.js';
66
+ import { sanitizeString } from '../redaction.js';
67
+
68
+ export interface AnimationRecord {
69
+ readonly id: string;
70
+ /**
71
+ * The cross-artifact join key — the target element's `backendNodeId`.
72
+ * OMITTED (not `null`) when this animation genuinely has no element
73
+ * target (e.g. a bare `Animation` with a null effect) — there is nothing
74
+ * to identify. Present as `null` (never silently omitted) alongside
75
+ * `identityUnresolved: true` when the animation DOES have an element
76
+ * target but CDP could not resolve its identity (`describeBackendNodeId`
77
+ * failed or the bridge object was never held) — see {@link
78
+ * identityUnresolved}. Per I-3/I-5, "no target" and "target present but
79
+ * unresolved" must never collapse to the same shape.
80
+ */
81
+ readonly backendNodeId?: number | null;
82
+ /** `true` when this animation has an element target but {@link backendNodeId} is `null` because identity resolution failed or was never attempted — never omit this alongside a `null` backendNodeId. Absent (not `false`) when identity resolved or the animation has no element target at all. */
83
+ readonly identityUnresolved?: boolean;
84
+ readonly selector: string | null;
85
+ readonly animationName: string | null;
86
+ readonly durationMs: number | null;
87
+ readonly iterationCount: number | 'infinite' | null;
88
+ readonly infinite: boolean;
89
+ readonly playState: string;
90
+ /**
91
+ * True when `--freeze-animations` was requested for this capture AND
92
+ * this animation's `playState` is `'paused'` at read time. This is a
93
+ * CO-OCCURRENCE fact, not proof of causation: an animation the page
94
+ * itself had already paused (or created already-paused) before or
95
+ * independent of the freeze is indistinguishable, from this collector's
96
+ * own independent post-freeze `getAnimations()` re-enumeration, from one
97
+ * the freeze mechanism forced from `'running'` to `'paused'` — WAAPI
98
+ * exposes no per-animation pause history, and CDP's
99
+ * `Animation.setPlaybackRate` override never touches the JS-visible
100
+ * `Animation.playbackRate`. Read `frozen` as "paused while freeze was
101
+ * requested", never as a verified per-animation tool-intervention claim.
102
+ */
103
+ readonly frozen: boolean;
104
+ }
105
+
106
+ /** Explicit factual scope for `animation.json`: `getAnimations({subtree:true})` walks the top document and its shadow subtrees, never nested iframe documents. */
107
+ export interface AnimationCoverage {
108
+ readonly scope: 'top-document';
109
+ /** Count of top-document `<iframe>` elements whose own animations are NOT enumerated (each iframe document has its own `getAnimations`). Meaningless (always `0`) when `available` below is `false` — read `available` first. */
110
+ readonly iframesNotWalked: number;
111
+ /** `false` when the iframe-count evaluate itself failed (the page-side count threw, or the CDP round trip threw/returned no usable value) — `iframesNotWalked:0` is then "could not count", not "zero iframes" (I-4/I-5). Always `true` on a normal run, including a page with genuinely zero iframes. */
112
+ readonly available: boolean;
113
+ }
114
+
115
+ /** The fixed, factual reasons `animation.json`'s inventory could not be read — never a raw exception message, which is unbounded/page-influenced. */
116
+ export type AnimationUnavailableReason =
117
+ | 'inventory-evaluate-threw'
118
+ | 'inventory-evaluate-returned-no-object'
119
+ | 'inventory-facts-unavailable'
120
+ | 'inventory-meta-unavailable'
121
+ | 'get-animations-threw';
122
+
123
+ export interface AnimationReport {
124
+ /** `false` when the inventory evaluate/bridge itself failed, OR the page-side `document.getAnimations()` walk itself threw — an empty `animations` list 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. */
125
+ readonly available: boolean;
126
+ /** Present only when `available` is `false`. */
127
+ readonly unavailableReason?: AnimationUnavailableReason;
128
+ /** Restates `ctx.freezeAnimations` at the artifact level — lets a reader of `animation.json` alone (without `meta.json`) tell "freeze wasn't requested" apart from "freeze was requested but nothing ended up paused" (`frozenCount:0` is otherwise ambiguous between the two). */
129
+ readonly freezeRequested: boolean;
130
+ readonly animations: readonly AnimationRecord[];
131
+ readonly infiniteCount: number;
132
+ readonly frozenCount: number;
133
+ readonly coverage: AnimationCoverage;
134
+ /** True when the try/finally release of a held CDP bridge object threw — a factual marker that CDP-session-scoped remote-object memory may not have been freed early, never a diagnosis and never page-observable state. Absent when release succeeded. */
135
+ readonly bridgeCleanupFailed?: boolean;
136
+ }
137
+
138
+ /** Page-side raw record — the settle.ts base shape plus a `targetIdx` into the held (never page-observable) elements array (absent/`-1` when the animation has no element target). */
139
+ interface RawAnimationRecord {
140
+ readonly selector: string | null;
141
+ readonly animationName: string | null;
142
+ readonly durationMs: number | null;
143
+ readonly iterationCount: number | 'infinite' | null;
144
+ readonly infinite: boolean;
145
+ readonly playState: string;
146
+ readonly targetIdx?: number;
147
+ }
148
+
149
+ /**
150
+ * Enumerates `getAnimations({subtree:true})` and, in the SAME pass, collects
151
+ * each element target into a page-side array at the index recorded as
152
+ * `targetIdx` — so a target's `backendNodeId` is resolved against the
153
+ * exact element the record describes, with no second-call ordering skew.
154
+ * Returns `{ facts, elements, meta }`: `facts` is the bare record ARRAY
155
+ * (identical to settle.ts's inventory shape, plus `targetIdx`); `elements`
156
+ * is the live target array, in `targetIdx` order; `meta.ok` is `false`
157
+ * when `document.getAnimations()` (or reading its timing) itself threw —
158
+ * distinguishing that failure from a genuinely-empty successful walk is
159
+ * the entire point (I-4/I-5): a thrown enumeration discards any partial
160
+ * `facts`/`elements` accumulated so far (never returns a partial result as
161
+ * if it were the complete truth). None of `facts`/`elements`/`meta` is
162
+ * ever assigned to `window` or any other page-observable location — the
163
+ * return value is a plain in-memory object, read back only through its own
164
+ * held CDP `objectId` (see {@link collectAnimation}). `describeTarget`
165
+ * matches settle.ts verbatim so the `selector` base field is identical
166
+ * across both `animation.json` branches.
167
+ */
168
+ const ANIMATION_INVENTORY_SCRIPT = `/* __captureAnimationInventory */
169
+ (function() {
170
+ function describeTarget(node) {
171
+ try {
172
+ if (!node || node.nodeType !== 1) return null;
173
+ var tag = node.tagName ? node.tagName.toLowerCase() : null;
174
+ var cls = (node.className && typeof node.className === 'string')
175
+ ? ('.' + node.className.trim().split(/\\s+/).join('.'))
176
+ : '';
177
+ return tag ? (tag + cls) : null;
178
+ } catch (e) { return null; }
179
+ }
180
+ var out = [];
181
+ var targets = [];
182
+ try {
183
+ var anims = document.getAnimations({ subtree: true });
184
+ for (var i = 0; i < anims.length; i++) {
185
+ var a = anims[i];
186
+ var timing = (a.effect && a.effect.getComputedTiming) ? a.effect.getComputedTiming() : null;
187
+ var iterations = timing ? timing.iterations : null;
188
+ var infinite = iterations === Infinity;
189
+ var target = (a.effect && a.effect.target) ? a.effect.target : null;
190
+ var targetIdx = -1;
191
+ if (target && target.nodeType === 1) {
192
+ targetIdx = targets.length;
193
+ targets.push(target);
194
+ }
195
+ out.push({
196
+ selector: target ? describeTarget(target) : null,
197
+ animationName: a.animationName || a.id || null,
198
+ durationMs: timing ? timing.duration : null,
199
+ iterationCount: infinite ? 'infinite' : (typeof iterations === 'number' ? iterations : null),
200
+ infinite: infinite,
201
+ playState: a.playState,
202
+ targetIdx: targetIdx,
203
+ });
204
+ }
205
+ } catch (e) {
206
+ return { facts: [], elements: [], meta: { ok: false } };
207
+ }
208
+ return { facts: out, elements: targets, meta: { ok: true } };
209
+ })();`;
210
+
211
+ /**
212
+ * Order-independent count of top-document iframes whose animations are not
213
+ * enumerated. Its own evaluate — a bare count, no coupling to the inventory
214
+ * enumeration order. Returns `{ count, ok }` rather than a bare number so a
215
+ * page-side `querySelectorAll` throw is distinguishable from a genuine
216
+ * zero-iframe page (I-4/I-5) — see {@link AnimationCoverage.available}.
217
+ */
218
+ const IFRAME_COUNT_SCRIPT = `(function () { try { return { count: document.querySelectorAll('iframe').length, ok: true }; } catch (e) { return { count: 0, ok: false }; } })()`;
219
+
220
+ export const collectAnimation: Collector = async (ctx) => {
221
+ let raw: RawAnimationRecord[] = [];
222
+ let bridgeCleanupFailed = false;
223
+ // I-5: distinguishes "the evaluate/bridge failed" from "the page really
224
+ // has no animations" — both otherwise collapse to an empty `animations`
225
+ // list, indistinguishable to a reader.
226
+ let available = true;
227
+ let unavailableReason: AnimationReport['unavailableReason'];
228
+ const heldObjectIds: string[] = [];
229
+ // Preallocated below once `raw` (and so `targetCount`) is known — filled
230
+ // in the SAME try block as the walk, before the held ids are released.
231
+ let backendNodeIds: Array<number | undefined> = [];
232
+ try {
233
+ const walkEval = (await ctx.client.send('Runtime.evaluate', {
234
+ expression: ANIMATION_INVENTORY_SCRIPT,
235
+ returnByValue: false,
236
+ })) as { result?: { objectId?: string } };
237
+ const resultObjectId = walkEval.result?.objectId;
238
+
239
+ if (resultObjectId) {
240
+ heldObjectIds.push(resultObjectId);
241
+ const containerIds = await ownPropertyObjectIds(ctx.client, resultObjectId);
242
+ const factsObjectId = containerIds.get('facts');
243
+ const elementsObjectId = containerIds.get('elements');
244
+ const metaObjectId = containerIds.get('meta');
245
+
246
+ // I-4: a missing objectId for a required held property, OR a
247
+ // readHeldValue() that resolves (without throwing) to `undefined`, is
248
+ // the SAME fact as a thrown read -- the property was never actually
249
+ // read. Read both before deciding success/failure so neither can
250
+ // silently fall through to the initialized empty/default artifact
251
+ // (mirrors geometry.ts's facts/meta pattern).
252
+ let factsValue: RawAnimationRecord[] | undefined;
253
+ if (factsObjectId) {
254
+ heldObjectIds.push(factsObjectId);
255
+ factsValue = await readHeldValue<RawAnimationRecord[]>(ctx.client, factsObjectId);
256
+ }
257
+
258
+ let metaValue: { ok?: boolean } | undefined;
259
+ if (metaObjectId) {
260
+ heldObjectIds.push(metaObjectId);
261
+ metaValue = await readHeldValue<{ ok?: boolean }>(ctx.client, metaObjectId);
262
+ }
263
+
264
+ if (factsValue === undefined) {
265
+ available = false;
266
+ unavailableReason = 'inventory-facts-unavailable';
267
+ } else if (metaObjectId && metaValue === undefined) {
268
+ // `meta` rides the SAME returned object literal as `facts` -- when
269
+ // the container has a `meta` OWN PROPERTY (a real production
270
+ // inventory return always does) but reading it failed, that is a
271
+ // broken bridge read, not "the page has zero animations". A
272
+ // container with NO `meta` property at all is treated as
273
+ // undetermined-but-fine below rather than unavailable, so this
274
+ // stays compatible with any caller driving the pre-existing
275
+ // `{ facts, elements }` shape without the newer `ok` signal.
276
+ available = false;
277
+ unavailableReason = 'inventory-meta-unavailable';
278
+ } else if (metaValue?.ok === false) {
279
+ // The page-side `document.getAnimations()` walk itself threw --
280
+ // `facts`/`elements` were reset to empty by the script's own catch
281
+ // branch, so there is nothing partial to trust here either.
282
+ available = false;
283
+ unavailableReason = 'get-animations-threw';
284
+ } else {
285
+ raw = factsValue;
286
+ const targetCount = raw.reduce((n, a) => (typeof a.targetIdx === 'number' && a.targetIdx >= 0 ? n + 1 : n), 0);
287
+ backendNodeIds = new Array<number | undefined>(targetCount).fill(undefined);
288
+
289
+ if (elementsObjectId && targetCount > 0) {
290
+ heldObjectIds.push(elementsObjectId);
291
+ const objectIds = await resolveIndexedObjectIds(ctx.client, elementsObjectId, targetCount);
292
+ // Each resolved per-target element objectId is its OWN held remote
293
+ // reference, distinct from the container/facts/elements array
294
+ // objectIds above -- it must be released too, or every animated
295
+ // element this run touched leaks CDP-session-scoped memory.
296
+ for (const objectId of objectIds) {
297
+ if (objectId) heldObjectIds.push(objectId);
298
+ }
299
+ await Promise.all(
300
+ objectIds.map(async (objectId, idx) => {
301
+ if (!objectId) return;
302
+ backendNodeIds[idx] = await describeBackendNodeId(ctx.client, objectId);
303
+ }),
304
+ );
305
+ }
306
+ }
307
+ } else {
308
+ available = false;
309
+ unavailableReason = 'inventory-evaluate-returned-no-object';
310
+ }
311
+ } catch {
312
+ raw = [];
313
+ available = false;
314
+ unavailableReason = 'inventory-evaluate-threw';
315
+ } finally {
316
+ // Runs UNCONDITIONALLY: every held container/facts/elements/per-target
317
+ // objectId must be released even when a step above throws, so a later
318
+ // capture on the same tab never collides with anything this run held.
319
+ for (const id of heldObjectIds) {
320
+ try {
321
+ await ctx.client.send('Runtime.releaseObject', { objectId: id });
322
+ } catch {
323
+ bridgeCleanupFailed = true;
324
+ }
325
+ }
326
+ }
327
+
328
+ let iframesNotWalked = 0;
329
+ let iframeCoverageAvailable = true;
330
+ try {
331
+ const frameResult = (await ctx.client.send('Runtime.evaluate', {
332
+ expression: IFRAME_COUNT_SCRIPT,
333
+ returnByValue: true,
334
+ })) as { result?: { value?: { count?: number; ok?: boolean } } };
335
+ const value = frameResult.result?.value;
336
+ if (value && value.ok === true && typeof value.count === 'number') {
337
+ iframesNotWalked = value.count;
338
+ } else {
339
+ // The evaluate itself succeeded but the page-side count threw (or the
340
+ // round trip returned no usable value) -- 0 here would otherwise be
341
+ // indistinguishable from a genuine zero-iframe page.
342
+ iframeCoverageAvailable = false;
343
+ }
344
+ } catch {
345
+ iframeCoverageAvailable = false;
346
+ }
347
+
348
+ const animations: AnimationRecord[] = raw.map((a, i) => {
349
+ const hasTarget = typeof a.targetIdx === 'number' && a.targetIdx >= 0;
350
+ // I-3/I-5: "no element target" (omit the field — nothing to identify)
351
+ // and "target present but identity resolution failed" (backendNodeId:
352
+ // null + identityUnresolved:true) must never collapse to the same
353
+ // undefined-field shape — mirrors hittest.ts's `resolvedIdentity`.
354
+ const resolvedBackendNodeId = hasTarget ? backendNodeIds[a.targetIdx as number] : undefined;
355
+ const identity: Pick<AnimationRecord, 'backendNodeId' | 'identityUnresolved'> = !hasTarget
356
+ ? {}
357
+ : resolvedBackendNodeId === undefined
358
+ ? { backendNodeId: null, identityUnresolved: true }
359
+ : { backendNodeId: resolvedBackendNodeId };
360
+ return {
361
+ id: `anim-${i + 1}`,
362
+ ...identity,
363
+ selector: a.selector !== null ? sanitizeString(a.selector, { max: 300 }) : null,
364
+ animationName: a.animationName !== null ? sanitizeString(a.animationName, { max: 200 }) : null,
365
+ durationMs: a.durationMs,
366
+ iterationCount: a.iterationCount,
367
+ infinite: a.infinite,
368
+ playState: a.playState,
369
+ frozen: ctx.freezeAnimations && a.playState === 'paused',
370
+ };
371
+ });
372
+
373
+ const report: AnimationReport = {
374
+ available,
375
+ ...(unavailableReason ? { unavailableReason } : {}),
376
+ freezeRequested: ctx.freezeAnimations,
377
+ animations,
378
+ infiniteCount: animations.filter((a) => a.infinite).length,
379
+ frozenCount: animations.filter((a) => a.frozen).length,
380
+ coverage: { scope: 'top-document', iframesNotWalked, available: iframeCoverageAvailable },
381
+ ...(bridgeCleanupFailed ? { bridgeCleanupFailed: true } : {}),
382
+ };
383
+
384
+ ctx.write.json('animation.json', report);
385
+ };
@@ -0,0 +1,278 @@
1
+ /**
2
+ * `ax.json` collector — the full accessibility tree (`Accessibility.
3
+ * getFullAXTree()`) flattened into a per-node index: role, name,
4
+ * description, ignored/ignoredReasons, a curated allowlist of ARIA
5
+ * states, and the AX parent/child structure. `backendNodeId` (from
6
+ * `backendDOMNodeId`, free on every AX node) is the cross-file join key
7
+ * shared with `styles.json`/`media.json` per the U04 contract — no
8
+ * coordination with those files is needed.
9
+ *
10
+ * "geometry ids ... when possible": U07's `geometry.json` is a
11
+ * concurrently-written sibling collector (`Promise.all` in `snapshot.ts`,
12
+ * no cross-collector read ordering) that this file must not depend on or
13
+ * read at capture time. Read here as "attach a `rect` fact directly per AX
14
+ * node via a best-effort `DOM.getBoxModel` lookup" rather than a join
15
+ * against `geometry.json` — a deliberate, bounded reading documented in
16
+ * the U08 build-plan report, not a defect.
17
+ */
18
+
19
+ import { axisAlignedRectFromQuad, type Quad, type Rect } from '../../coordinates.js';
20
+ import { capArray, sanitizeString } from '../redaction.js';
21
+ import type { Collector } from '../types.js';
22
+
23
+ /** How many non-ignored AX nodes (with a `backendDOMNodeId`) get a follow-up `DOM.getBoxModel` rect lookup. */
24
+ const AX_MAX_RECT_LOOKUPS = 300;
25
+ /** Hard cap on AX nodes written to `ax.json` — the AX tree is page-controlled and otherwise unbounded; a generous bound keeps the artifact size sane. Nodes beyond this are dropped (in document order) and counted in `truncated`. */
26
+ const AX_MAX_NODES = 5000;
27
+
28
+ /** Curated allowlist of AX properties surfaced as `states` — the common interactive/structural states, not the full CDP property list. */
29
+ const STATE_ALLOWLIST = [
30
+ 'checked',
31
+ 'expanded',
32
+ 'disabled',
33
+ 'invalid',
34
+ 'pressed',
35
+ 'selected',
36
+ 'required',
37
+ 'readonly',
38
+ 'multiselectable',
39
+ 'busy',
40
+ 'hidden',
41
+ 'modal',
42
+ ] as const;
43
+
44
+ // ============================================================================
45
+ // Raw CDP shapes (modeled locally — no CDP protocol package is imported)
46
+ // ============================================================================
47
+
48
+ interface AXPropertyValue {
49
+ type: string;
50
+ value?: unknown;
51
+ }
52
+
53
+ interface AXProperty {
54
+ name: string;
55
+ value: AXPropertyValue;
56
+ }
57
+
58
+ interface AXNode {
59
+ nodeId: string;
60
+ ignored: boolean;
61
+ ignoredReasons?: AXProperty[];
62
+ role?: AXPropertyValue;
63
+ name?: AXPropertyValue;
64
+ description?: AXPropertyValue;
65
+ properties?: AXProperty[];
66
+ childIds?: string[];
67
+ parentId?: string;
68
+ backendDOMNodeId?: number;
69
+ }
70
+
71
+ // ============================================================================
72
+ // Output shape
73
+ // ============================================================================
74
+
75
+ export interface AxNodeRecord {
76
+ id: string;
77
+ axId: string;
78
+ role: unknown;
79
+ axName?: string;
80
+ description?: string;
81
+ ignored: boolean;
82
+ ignoredReasons: string[];
83
+ backendNodeId?: number;
84
+ parentAxId?: string;
85
+ childAxIds: string[];
86
+ states: Record<string, unknown>;
87
+ /** Present only when the per-node `DOM.getBoxModel` lookup was attempted (node is eligible: not ignored, has a `backendDOMNodeId`, and wasn't skipped by {@link AxReport.rectLookupsTruncated}) AND succeeded with a real content quad. Absent for an ineligible/skipped node (never attempted) and for an attempted-but-failed node (see {@link rectUnavailable}) -- both of those also omit `rect`, but only the latter carries a failure marker. */
88
+ rect?: Rect;
89
+ /**
90
+ * `true` when this node's `DOM.getBoxModel` rect lookup was ATTEMPTED and
91
+ * FAILED (threw, or returned no/malformed `model.content`) -- distinct
92
+ * from a node whose lookup was never attempted at all (ignored, no
93
+ * `backendDOMNodeId`, or skipped by {@link AxReport.rectLookupsTruncated}),
94
+ * which also omits `rect` but carries no failure marker (I-5: a genuine
95
+ * "not attempted"/scope omission must not look identical to a genuine
96
+ * read failure). See {@link AxRectUnavailableReason} for which failure.
97
+ * Absent (not `false`) when the lookup wasn't attempted or succeeded.
98
+ */
99
+ rectUnavailable?: true;
100
+ /** Present only when {@link rectUnavailable} is `true`. */
101
+ rectUnavailableReason?: AxRectUnavailableReason;
102
+ }
103
+
104
+ /**
105
+ * Fixed, factual reason a single AX node's per-node `DOM.getBoxModel` rect
106
+ * lookup did not complete -- never a raw exception message. Present only on
107
+ * {@link AxNodeRecord.rectUnavailable}.
108
+ *
109
+ * - `box-model-read-threw`: `DOM.getBoxModel` itself threw (protocol error,
110
+ * detached/invalid backend node reference, ...) -- a genuine read
111
+ * failure, NOT proof the node has no box. Empirically, a genuinely
112
+ * non-rendered element (e.g. `display:none`) is excluded from the AX tree
113
+ * entirely (or arrives `ignored`) and so is never in the eligible set this
114
+ * function attempts at all; a REAL zero-size rendered box (e.g.
115
+ * `width:0;height:0;overflow:hidden`) resolves `DOM.getBoxModel`
116
+ * successfully with a degenerate (zero-area) content quad rather than
117
+ * throwing. A throw on an eligible node is therefore a read failure to
118
+ * report, not a silent "no box" to infer.
119
+ * - `box-model-no-content`: `DOM.getBoxModel` resolved without throwing but
120
+ * the response carried no `model.content` field, `content` was not an
121
+ * array of exactly 8 entries, or one of those 8 entries was not a finite
122
+ * number (e.g. `NaN`/non-numeric) -- any malformed response, distinct
123
+ * from a clean 8-finite-number quad (which is honestly represented as a
124
+ * real, if possibly degenerate/zero-area, `rect`). A partially-numeric or
125
+ * `NaN`-bearing quad is never silently coerced through
126
+ * `axisAlignedRectFromQuad` -- that would emit a `NaN` rect that
127
+ * serializes as `null` fields with no failure marker at all.
128
+ */
129
+ export type AxRectUnavailableReason = 'box-model-read-threw' | 'box-model-no-content';
130
+
131
+ /** Fixed, factual reason `Accessibility.getFullAXTree` could not be read (never a raw exception message, which is unbounded/page-influenced) — present only when {@link AxReport.available} is `false`. */
132
+ export type AxUnavailableReason = 'axtree-unavailable' | 'axtree-returned-no-nodes';
133
+
134
+ export interface AxReport {
135
+ nodes: AxNodeRecord[];
136
+ /** Present only when the tree exceeded {@link AX_MAX_NODES} — the count of AX nodes dropped past the cap. */
137
+ truncated?: number;
138
+ /** Present only when the eligible (non-ignored, with a `backendDOMNodeId`) node set exceeded {@link AX_MAX_RECT_LOOKUPS} — the count of nodes whose `rect` lookup was skipped by the cap, so their `rect` is absent by cap rather than by measurement (e.g. non-rendered). */
139
+ rectLookupsTruncated?: number;
140
+ /** Explicit scope fact (D5): `Accessibility.getFullAXTree` is top-document only, so omission of iframe AX nodes is a stated scope boundary, never a negative fact. */
141
+ coverage: { scope: 'top-document' };
142
+ /** `false` when `Accessibility.getFullAXTree` itself failed (threw, or returned no `nodes` field) — `nodes: []` is then "could not collect", not "genuinely empty tree" (I-5). Always `true` on a normal run, including one where the page genuinely has no AX nodes. */
143
+ available: boolean;
144
+ /** Present only when `available` is `false`. */
145
+ unavailableReason?: AxUnavailableReason;
146
+ }
147
+
148
+ // ============================================================================
149
+ // Helpers
150
+ // ============================================================================
151
+
152
+ /** Caps an optional page-controlled AX string, or returns `undefined` when absent. */
153
+ function sanitizeOptional(value: string | null | undefined): string | undefined {
154
+ if (value === null || value === undefined) return undefined;
155
+ return sanitizeString(value);
156
+ }
157
+
158
+ function deriveStates(properties: AXProperty[] | undefined): Record<string, unknown> {
159
+ const states: Record<string, unknown> = {};
160
+ if (!properties) return states;
161
+ for (const prop of properties) {
162
+ if ((STATE_ALLOWLIST as readonly string[]).includes(prop.name)) {
163
+ states[prop.name] = prop.value?.value;
164
+ }
165
+ }
166
+ return states;
167
+ }
168
+
169
+ // ============================================================================
170
+ // Collector
171
+ // ============================================================================
172
+
173
+ export const collectAx: Collector = async (ctx) => {
174
+ const { client } = ctx;
175
+
176
+ // I-5: a missing `nodes` field (the read failed) and a genuinely empty tree both look like `[]`
177
+ // unless the failure is distinguished explicitly — `available:false` is the honest signal a
178
+ // downstream reader needs to tell "could not collect" from "the page really has zero AX nodes".
179
+ let allAxNodes: AXNode[];
180
+ let available = true;
181
+ let unavailableReason: AxUnavailableReason | undefined;
182
+ try {
183
+ const treeResponse = (await client.send('Accessibility.getFullAXTree')) as { nodes?: AXNode[] };
184
+ if (treeResponse.nodes === undefined) {
185
+ allAxNodes = [];
186
+ available = false;
187
+ unavailableReason = 'axtree-returned-no-nodes';
188
+ } else {
189
+ allAxNodes = treeResponse.nodes;
190
+ }
191
+ } catch {
192
+ allAxNodes = [];
193
+ available = false;
194
+ unavailableReason = 'axtree-unavailable';
195
+ }
196
+ const axNodes = allAxNodes.slice(0, AX_MAX_NODES);
197
+ const truncated = allAxNodes.length > AX_MAX_NODES ? allAxNodes.length - AX_MAX_NODES : undefined;
198
+
199
+ const {
200
+ rects: rectByNodeId,
201
+ failures: rectFailureByNodeId,
202
+ truncated: rectLookupsTruncated,
203
+ } = await resolveRects(client, axNodes);
204
+
205
+ const nodes: AxNodeRecord[] = axNodes.map((node, index) => {
206
+ const nameValue = node.name?.value;
207
+ const descriptionValue = node.description?.value;
208
+ const rectFailureReason = node.backendDOMNodeId !== undefined ? rectFailureByNodeId.get(node.backendDOMNodeId) : undefined;
209
+ return {
210
+ id: `ax-${index}`,
211
+ axId: node.nodeId,
212
+ role: node.role?.value ?? null,
213
+ axName: sanitizeOptional(nameValue === undefined || nameValue === null ? undefined : String(nameValue)),
214
+ description: sanitizeOptional(descriptionValue === undefined || descriptionValue === null ? undefined : String(descriptionValue)),
215
+ ignored: node.ignored,
216
+ ignoredReasons: (node.ignoredReasons ?? []).map((r) => r.name),
217
+ backendNodeId: node.backendDOMNodeId,
218
+ parentAxId: node.parentId,
219
+ childAxIds: node.childIds ?? [],
220
+ states: deriveStates(node.properties),
221
+ rect: node.backendDOMNodeId !== undefined ? rectByNodeId.get(node.backendDOMNodeId) : undefined,
222
+ ...(rectFailureReason !== undefined ? { rectUnavailable: true as const, rectUnavailableReason: rectFailureReason } : {}),
223
+ };
224
+ });
225
+
226
+ ctx.write.json('ax.json', {
227
+ nodes,
228
+ truncated,
229
+ rectLookupsTruncated: rectLookupsTruncated > 0 ? rectLookupsTruncated : undefined,
230
+ coverage: { scope: 'top-document' },
231
+ available,
232
+ ...(unavailableReason ? { unavailableReason } : {}),
233
+ } satisfies AxReport);
234
+ };
235
+
236
+ /**
237
+ * Best-effort `rect` lookup for up to {@link AX_MAX_RECT_LOOKUPS} non-ignored
238
+ * AX nodes with a `backendDOMNodeId`, in tree order. A genuine per-node read
239
+ * FAILURE (the `DOM.getBoxModel` call throws, or resolves with no/malformed
240
+ * `model.content`) is reported back in `failures` (I-5 — see
241
+ * {@link AxRectUnavailableReason}), never silently folded into "no rect";
242
+ * only a node this function never attempts at all (filtered out of
243
+ * `eligible`, or skipped by the cap) legitimately omits both `rect` and a
244
+ * failure marker. Nodes skipped by the cap are counted in the returned
245
+ * `truncated` (a cap that can drop real data emits a fact, via the shared
246
+ * {@link capArray} authority).
247
+ */
248
+ async function resolveRects(
249
+ client: Parameters<Collector>[0]['client'],
250
+ axNodes: readonly AXNode[],
251
+ ): Promise<{ rects: Map<number, Rect>; failures: Map<number, AxRectUnavailableReason>; truncated: number }> {
252
+ const eligible = axNodes.filter((node) => !node.ignored && node.backendDOMNodeId !== undefined);
253
+ const { items: capped, truncated } = capArray(eligible, AX_MAX_RECT_LOOKUPS);
254
+
255
+ const rects = new Map<number, Rect>();
256
+ const failures = new Map<number, AxRectUnavailableReason>();
257
+ for (const node of capped) {
258
+ const backendNodeId = node.backendDOMNodeId as number;
259
+ try {
260
+ const boxResponse = (await client.send('DOM.getBoxModel', { backendNodeId })) as {
261
+ model?: { content: number[] };
262
+ };
263
+ const content = boxResponse.model?.content;
264
+ if (!Array.isArray(content) || content.length !== 8 || !content.every((n) => typeof n === 'number' && Number.isFinite(n))) {
265
+ failures.set(backendNodeId, 'box-model-no-content');
266
+ continue;
267
+ }
268
+ rects.set(backendNodeId, axisAlignedRectFromQuad(content as Quad));
269
+ } catch {
270
+ // A genuine read failure (protocol error, detached/invalid node) — NOT proof of "no box"; see
271
+ // AxRectUnavailableReason's `box-model-read-threw` doc for why a real non-rendered element never
272
+ // reaches this catch. Mark it explicitly rather than silently omitting the rect.
273
+ failures.set(backendNodeId, 'box-model-read-threw');
274
+ }
275
+ }
276
+
277
+ return { rects, failures, truncated };
278
+ }