@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,835 @@
1
+ /**
2
+ * `hittest.json` collector — point-sampled hit-test results (what element
3
+ * actually receives a click at a given coordinate): a 9-point lattice
4
+ * (center + 4 corners + 4 edge-midpoints) per walked element, plus a
5
+ * coarse whole-viewport grid independent of any element.
6
+ *
7
+ * The ENTIRE walk + sampling happens inside ONE `Runtime.evaluate` script
8
+ * (no per-point CDP round trip — `document.elementsFromPoint` runs natively
9
+ * in the same evaluate call that also does the tree walk). Same-origin
10
+ * iframe retargeting (a point whose topmost hit is an `<iframe>` gets
11
+ * re-queried inside that iframe's own document) and shadow-DOM detection
12
+ * both happen in-browser too — Chrome's `elementsFromPoint` already
13
+ * flattens open shadow DOM automatically. Point coordinates are computed
14
+ * via local `getBoundingClientRect()`/`elementsFromPoint` math (the one
15
+ * exception the design allows JS geometry for — see `geometry.ts`'s module
16
+ * doc), converted to top-viewport space via a JS-accumulated per-frame
17
+ * offset (each nested same-origin iframe's own `getBoundingClientRect()`
18
+ * within its parent, summed on the way down).
19
+ *
20
+ * Only `backendNodeId` resolution needs a Node-side round trip afterward.
21
+ * EVERY element-bearing stack member this collector emits (each point's
22
+ * full `stack[]`, not just `stack[0]`/`topReceiver`, plus each PRIMARY
23
+ * sampled element) gets a bridge index and is resolved to a `backendNodeId`
24
+ * where resolvable, so no emitted element record is selector-only. The
25
+ * page-side walk dedupes by DOM identity (a `WeakMap` keyed on the live
26
+ * element, scoped to one `Runtime.evaluate` invocation) so the same element
27
+ * hit at many sample points/stack depths is bridged exactly once, and a
28
+ * hard cap (`MAX_BRIDGE_ELEMENTS`) bounds the total unique element count so
29
+ * a pathologically deep/wide walk can't blow up CDP round-trip cost — past
30
+ * the cap, further NEW elements are simply left unbridged. Unbridged is
31
+ * never silent: every element-bearing record's `backendNodeId` is always
32
+ * present as `number | null` (never an omitted key), and a record whose
33
+ * identity did not resolve — whether because the bridge cap turned it away
34
+ * or because `DOM.describeNode` itself failed — carries `backendNodeId:
35
+ * null` alongside `identityUnresolved: true`, so a downstream join can
36
+ * never mistake it for a resolved node. The root-level `bridgeTruncated`
37
+ * count says how many distinct elements the bridge cap turned away in
38
+ * total; `elementsTruncated`/`samplesTruncated` report the same for the
39
+ * candidate-element cap (`MAX_ELEMENTS`) and the whole-viewport lattice cap
40
+ * (`LATTICE_MAX_POINTS`) respectively — all three are exact counts (the
41
+ * walk keeps counting past each cap, never a guess), but `MAX_ELEMENTS`
42
+ * bounds only the retained-candidate set and its 9-point lattice sampling:
43
+ * every walked element still pays `getComputedStyle()`/
44
+ * `getBoundingClientRect()`, because that read is what DEFINES eligibility
45
+ * (`display !== none`, non-zero size) in the first place — the cap cannot
46
+ * skip it without losing the exact count.
47
+ *
48
+ * The walk never assigns anything to `window` or any other page-observable
49
+ * location: its return value is a plain in-memory `{ facts, elements }`
50
+ * object (`facts` the JSON-safe per-point/per-element data, `elements` the
51
+ * live dedup-bridged element handles), read back purely through CDP's own
52
+ * remote-object identity — the exact same CDP-only identity bridge
53
+ * `geometry.ts` uses and exports (`ownPropertyObjectIds`/`readHeldValue`/
54
+ * `resolveIndexedObjectIds`/`describeBackendNodeId`), reused here rather
55
+ * than duplicated. A page can predefine a setter for any global name it can
56
+ * guess; it can observe nothing here, because nothing is ever set on it.
57
+ * For the bounded set of PRIMARY elements specifically, their `rect` is
58
+ * additionally upgraded from JS-local math to a real top-viewport CDP quad
59
+ * (`DOM.getContentQuads`/`getBoxModel`, same as `geometry.ts`) — per the
60
+ * design, ordinary (non-primary) stack members keep their JS-computed rect
61
+ * since they are supplementary identification, not the primary geometry
62
+ * source, and upgrading every one of them to a CDP quad would be needless
63
+ * CDP round-trip cost; only their identity (`backendNodeId`) is bridged,
64
+ * not their rect. So `backendNodeId` (the cross-artifact join key) is
65
+ * present on every element sample and every stack member (at every depth)
66
+ * where the underlying element resolved within the bridge cap.
67
+ *
68
+ * Two factual scope markers guard against silent omission: a point's
69
+ * `opaqueFrame` is `true` when the topmost element hit there is an
70
+ * `<iframe>` whose `contentDocument` is null (cross-origin/opaque —
71
+ * otherwise indistinguishable from an empty same-origin frame), and the
72
+ * artifact-root `bridgeCleanupFailed` is `true` when the try/finally
73
+ * release of a held CDP object threw — unlike the old page-observable
74
+ * global this replaces, a release failure can never imply a contaminated
75
+ * baseline: there is no page-observable state left to leak, only
76
+ * CDP-session-scoped remote-object memory freed when the tab closes. A
77
+ * member/point's `frameId` (`'iframe-'+idx`) is a run-scoped ARTIFACT-LOCAL
78
+ * id — it deliberately resembles a CDP Page frameId but is not one;
79
+ * nothing downstream may join on it (join on `backendNodeId`).
80
+ */
81
+
82
+ import type { CDPClient } from '../../client.js';
83
+ import { getContentQuadBox, axisAlignedRectFromQuad, type Quad, type Rect } from '../../coordinates.js';
84
+ import type { Collector } from '../types.js';
85
+ import { SELECTOR_HELPER_JS, ownPropertyObjectIds, readHeldValue, resolveIndexedObjectIds, describeBackendNodeId } from './geometry.js';
86
+ import { sanitizeString } from '../redaction.js';
87
+
88
+ /** Defensive cap on how many elements this collector will build a 9-point lattice for. */
89
+ const MAX_ELEMENTS = 500;
90
+ /** Defensive cap on the total number of UNIQUE (deduped-by-identity) elements this collector will bridge to a `backendNodeId` across the whole walk (every stack member at every depth, plus every primary element). Past this cap, newly-seen elements are simply left unbridged rather than uncapping the CDP round-trip cost. */
91
+ const MAX_BRIDGE_ELEMENTS = 3000;
92
+ /** Coarse whole-viewport lattice spacing, in CSS px. */
93
+ const LATTICE_STEP = 80;
94
+ /** Defensive cap on the whole-viewport lattice's total point count. */
95
+ const LATTICE_MAX_POINTS = 200;
96
+
97
+ // ============================================================================
98
+ // The page-side walk + sample. Runs once via `Runtime.evaluate({returnByValue: false})`
99
+ // -- the return value is held as a remote object (see the module doc's
100
+ // CDP-only identity bridge), never read back by value directly.
101
+ // ============================================================================
102
+
103
+ function buildHittestScript(maxElements: number, latticeStep: number, latticeMaxPoints: number, maxBridgeElements: number): string {
104
+ return `(function () {
105
+ var MAX_ELEMENTS = ${maxElements};
106
+ var LATTICE_STEP = ${latticeStep};
107
+ var LATTICE_MAX_POINTS = ${latticeMaxPoints};
108
+ var MAX_BRIDGE_ELEMENTS = ${maxBridgeElements};
109
+ var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEMPLATE: 1, HEAD: 1, META: 1, LINK: 1, TITLE: 1, BASE: 1, NOSCRIPT: 1 };
110
+ var bridgeEls = [];
111
+ // Dedupe table for repeat elements seen across multiple sample points
112
+ // within THIS SINGLE walk. Deliberately a WeakMap scoped to this one
113
+ // (function () { ... })() invocation (a brand-new closure every
114
+ // Runtime.evaluate call) rather than an expando written onto the
115
+ // element itself -- an expando would persist on the live DOM after this
116
+ // script returns, so a SECOND collectHittest() against the same page
117
+ // would see a stale index left by the first run and skip re-pushing the
118
+ // element into the new bridgeEls array. Nothing is ever written to any
119
+ // page element; the map (and its keys) are garbage-collected with this
120
+ // closure once Runtime.evaluate returns.
121
+ var bridgeMap = new WeakMap();
122
+ // Exact count of DISTINCT elements the MAX_BRIDGE_ELEMENTS cap turned
123
+ // away (never a guess): a rejected element is recorded in bridgeMap as
124
+ // undefined too, so re-encountering it at another sample point/stack
125
+ // depth resolves the SAME 'unbridged' answer without recounting it.
126
+ var bridgeOverflowCount = 0;
127
+
128
+ function bridgeIndexOf(el) {
129
+ if (!el) return undefined;
130
+ if (bridgeMap.has(el)) return bridgeMap.get(el);
131
+ if (bridgeEls.length >= MAX_BRIDGE_ELEMENTS) {
132
+ bridgeMap.set(el, undefined);
133
+ bridgeOverflowCount += 1;
134
+ return undefined;
135
+ }
136
+ var idx = bridgeEls.length;
137
+ bridgeEls.push(el);
138
+ bridgeMap.set(el, idx);
139
+ return idx;
140
+ }
141
+
142
+ // Builds a same-origin iframe's artifact-local frameId from its bridge
143
+ // index -- returns undefined (never a bogus 'iframe-undefined' string)
144
+ // when the bridge cap already turned the iframe element away, so a
145
+ // cap-exhausted iframe context is honestly withheld rather than
146
+ // collapsed onto a fake stable-looking id shared with every other
147
+ // capped iframe.
148
+ function iframeFrameId(bridgeIdx) {
149
+ return bridgeIdx === undefined ? undefined : 'iframe-' + bridgeIdx;
150
+ }
151
+
152
+ function directTextOf(el) {
153
+ var out = '';
154
+ for (var i = 0; i < el.childNodes.length; i++) {
155
+ var n = el.childNodes[i];
156
+ if (n.nodeType === 3 && n.textContent) out += n.textContent;
157
+ }
158
+ out = out.replace(/\\s+/g, ' ').trim();
159
+ return out || undefined;
160
+ }
161
+
162
+ function isInShadow(el) {
163
+ try {
164
+ var root = el.getRootNode ? el.getRootNode() : null;
165
+ return !!(root && root.host);
166
+ } catch (e) { return false; }
167
+ }
168
+
169
+ function isClipped(el, rect) {
170
+ try {
171
+ var node = el.parentElement;
172
+ var hops = 0;
173
+ var docEl = el.ownerDocument.documentElement;
174
+ while (node && node.nodeType === 1 && node !== docEl && hops < 50) {
175
+ var s = getComputedStyle(node);
176
+ if (/hidden|auto|scroll|clip/.test(s.overflowX) || /hidden|auto|scroll|clip/.test(s.overflowY)) {
177
+ var ar = node.getBoundingClientRect();
178
+ if (rect.left < ar.left - 0.5 || rect.right > ar.right + 0.5 || rect.top < ar.top - 0.5 || rect.bottom > ar.bottom + 0.5) {
179
+ return true;
180
+ }
181
+ }
182
+ node = node.parentElement;
183
+ hops += 1;
184
+ }
185
+ return false;
186
+ } catch (e) { return false; }
187
+ }
188
+
189
+ function makeStackMember(el, frameOffset) {
190
+ var r = el.getBoundingClientRect();
191
+ var style = getComputedStyle(el);
192
+ var disabled = false;
193
+ try { disabled = !!(el.matches && el.matches(':disabled')); } catch (e) {}
194
+ var ariaDisabled = !!(el.getAttribute && el.getAttribute('aria-disabled') === 'true');
195
+ var inert = false;
196
+ try { inert = !!(el.closest && el.closest('[inert]')); } catch (e) {}
197
+ var opacity = parseFloat(style.opacity);
198
+ if (isNaN(opacity)) opacity = 1;
199
+ var member = {
200
+ // EVERY emitted stack member is bridged (deduped by DOM identity,
201
+ // bounded by MAX_BRIDGE_ELEMENTS) so backendNodeId resolution
202
+ // below covers deeper stack entries too, not just the top receiver.
203
+ bridgeIdx: bridgeIndexOf(el),
204
+ selector: __selectorOf(el),
205
+ tag: el.tagName ? el.tagName.toLowerCase() : '',
206
+ rect: { x: r.x + frameOffset.dx, y: r.y + frameOffset.dy, width: r.width, height: r.height },
207
+ zIndex: style.zIndex || 'auto',
208
+ pointerEvents: style.pointerEvents,
209
+ cursor: style.cursor,
210
+ opacity: opacity,
211
+ disabled: disabled,
212
+ ariaDisabled: ariaDisabled,
213
+ inert: inert,
214
+ clipped: isClipped(el, r),
215
+ inShadowDom: isInShadow(el),
216
+ inIframe: frameOffset.frameId !== 'frame-0',
217
+ frameId: frameOffset.frameId,
218
+ };
219
+ return { member: member, el: el };
220
+ }
221
+
222
+ function sampleStackAt(doc, localX, localY, frameOffset, depth) {
223
+ var raw = [];
224
+ // H5: a THROW is a genuine hit-test read failure; a nullish return
225
+ // (elementsFromPoint should never do this per spec, but a
226
+ // hostile/broken page can still patch the method) is the same fact --
227
+ // either way stackUnavailable distinguishes "the read failed" from a
228
+ // real empty stack (topReceiver genuinely nothing at this point),
229
+ // which the bare '|| []' coercion made indistinguishable before.
230
+ var stackUnavailable = false;
231
+ try {
232
+ var elementsFromPointResult = doc.elementsFromPoint(localX, localY);
233
+ if (elementsFromPointResult) {
234
+ raw = elementsFromPointResult;
235
+ } else {
236
+ stackUnavailable = true;
237
+ }
238
+ } catch (e) {
239
+ raw = [];
240
+ stackUnavailable = true;
241
+ }
242
+ var built = [];
243
+ for (var i = 0; i < raw.length; i++) built.push(makeStackMember(raw[i], frameOffset));
244
+ var members = built.map(function (b) { return b.member; });
245
+
246
+ var retargetedShadow = false;
247
+ for (var j = 0; j < members.length; j++) { if (members[j].inShadowDom) { retargetedShadow = true; break; } }
248
+
249
+ var opaqueFrame = false;
250
+ var topEl = raw.length ? raw[0] : null;
251
+ if (topEl && topEl.tagName === 'IFRAME' && depth < 5) {
252
+ var innerDoc = null;
253
+ try { innerDoc = topEl.contentDocument; } catch (e) { innerDoc = null; }
254
+ if (innerDoc) {
255
+ var ir = topEl.getBoundingClientRect();
256
+ var childFrameOffset = {
257
+ dx: frameOffset.dx + ir.left,
258
+ dy: frameOffset.dy + ir.top,
259
+ // Artifact-local, run-scoped id -- 'iframe-' + this run's bridge
260
+ // index for the iframe element (undefined, never a bogus
261
+ // 'iframe-undefined', when the bridge cap already turned this
262
+ // iframe away). It deliberately LOOKS like a CDP Page frameId but
263
+ // is NOT one; nothing downstream may join on it.
264
+ frameId: iframeFrameId(bridgeIndexOf(topEl)),
265
+ };
266
+ var inner = sampleStackAt(innerDoc, localX - ir.left, localY - ir.top, childFrameOffset, depth + 1);
267
+ return {
268
+ members: members.concat(inner.members),
269
+ topReceiverMember: inner.topReceiverMember,
270
+ topReceiverEl: inner.topReceiverEl,
271
+ retargetedIframe: true,
272
+ retargetedShadow: retargetedShadow || inner.retargetedShadow,
273
+ opaqueFrame: inner.opaqueFrame,
274
+ // stackUnavailable is always false on THIS level here -- reaching
275
+ // this branch required a real topEl, which only exists when the
276
+ // outer elementsFromPoint read succeeded -- so the retargeted
277
+ // point's honesty is entirely the inner (retargeted) read's.
278
+ stackUnavailable: inner.stackUnavailable,
279
+ };
280
+ }
281
+ // contentDocument was null on a topmost IFRAME hit -> the frame is
282
+ // cross-origin/opaque, indistinguishable in the stack from an empty
283
+ // same-origin frame without this explicit fact.
284
+ opaqueFrame = true;
285
+ }
286
+
287
+ // topReceiverMember is members[0] (or null for an empty stack) --
288
+ // already bridged above in makeStackMember(), same as every other
289
+ // stack member, so no separate bridging step is needed here.
290
+ var topReceiverMember = members.length ? members[0] : null;
291
+ return {
292
+ members: members,
293
+ topReceiverMember: topReceiverMember,
294
+ topReceiverEl: topEl,
295
+ retargetedIframe: false,
296
+ retargetedShadow: retargetedShadow,
297
+ opaqueFrame: opaqueFrame,
298
+ stackUnavailable: stackUnavailable,
299
+ };
300
+ }
301
+
302
+ function buildPointResult(doc, localX, localY, frameOffset, depth) {
303
+ var res = sampleStackAt(doc, localX, localY, frameOffset, depth);
304
+ return {
305
+ x: localX + frameOffset.dx,
306
+ y: localY + frameOffset.dy,
307
+ stack: res.members,
308
+ topReceiver: res.topReceiverMember,
309
+ topReceiverEl: res.topReceiverEl,
310
+ retargetedThroughIframe: res.retargetedIframe,
311
+ retargetedThroughShadow: res.retargetedShadow,
312
+ opaqueFrame: res.opaqueFrame,
313
+ stackUnavailable: res.stackUnavailable,
314
+ };
315
+ }
316
+
317
+ function stripPoint(pr) {
318
+ return {
319
+ x: pr.x,
320
+ y: pr.y,
321
+ stack: pr.stack,
322
+ topReceiver: pr.topReceiver,
323
+ retargetedThroughIframe: pr.retargetedThroughIframe,
324
+ retargetedThroughShadow: pr.retargetedThroughShadow,
325
+ opaqueFrame: pr.opaqueFrame,
326
+ stackUnavailable: pr.stackUnavailable,
327
+ };
328
+ }
329
+
330
+ function pointLabelsFor(rect) {
331
+ var cx = rect.x + rect.width / 2;
332
+ var cy = rect.y + rect.height / 2;
333
+ return [
334
+ { label: 'center', x: cx, y: cy },
335
+ { label: 'top-left', x: rect.x, y: rect.y },
336
+ { label: 'top-right', x: rect.x + rect.width, y: rect.y },
337
+ { label: 'bottom-left', x: rect.x, y: rect.y + rect.height },
338
+ { label: 'bottom-right', x: rect.x + rect.width, y: rect.y + rect.height },
339
+ { label: 'top-mid', x: cx, y: rect.y },
340
+ { label: 'bottom-mid', x: cx, y: rect.y + rect.height },
341
+ { label: 'left-mid', x: rect.x, y: cy },
342
+ { label: 'right-mid', x: rect.x + rect.width, y: cy },
343
+ ];
344
+ }
345
+
346
+ var candidates = [];
347
+ // Exact count of real candidate elements the MAX_ELEMENTS cap chose not
348
+ // to keep (0 when every eligible element fit). MAX_ELEMENTS bounds the
349
+ // EXPENSIVE per-candidate work: the 9-point lattice sampling below (only
350
+ // retained candidates entries get sampled) and the retained-candidate
351
+ // set itself. It does NOT and cannot bound the single getComputedStyle()
352
+ // + getBoundingClientRect() read every walked element gets, because that
353
+ // read is what DEFINES an eligible candidate (display!==none, non-zero
354
+ // size) in the first place -- an exact eligible-candidate count requires
355
+ // making it. So this count is real (the walk keeps checking eligibility
356
+ // past the cap), never a guess.
357
+ var candidatesOverflowCount = 0;
358
+ // H7: exact count of same-origin <iframe> elements whose contentDocument
359
+ // read THREW during the candidate walk (a genuine failure, distinct from
360
+ // an ordinary cross-origin/childless iframe which returns null without
361
+ // throwing) -- an aggregate rather than a per-record marker, since the
362
+ // failing iframe element itself may never become a retained candidate
363
+ // (zero-size, or turned away by MAX_ELEMENTS) and so may have no record
364
+ // of its own to attach a marker to.
365
+ var candidateIframeReadFailures = 0;
366
+ function walkCandidates(el, frameOffset, ownerDoc) {
367
+ if (!el || el.nodeType !== 1) return;
368
+ var tag = el.tagName;
369
+ if (SKIP_TAGS[tag]) return;
370
+ var style = getComputedStyle(el);
371
+ if (style.display === 'none') return;
372
+ var rect = el.getBoundingClientRect();
373
+ if (!(rect.width === 0 && rect.height === 0)) {
374
+ if (candidates.length < MAX_ELEMENTS) {
375
+ candidates.push({ el: el, frameOffset: frameOffset, rect: rect, ownerDoc: ownerDoc });
376
+ } else {
377
+ candidatesOverflowCount += 1;
378
+ }
379
+ }
380
+
381
+ if (el.shadowRoot) {
382
+ var shadowKids = el.shadowRoot.children;
383
+ for (var si = 0; si < shadowKids.length; si++) walkCandidates(shadowKids[si], frameOffset, ownerDoc);
384
+ }
385
+
386
+ if (tag === 'IFRAME') {
387
+ var innerDoc = null;
388
+ var candidateContentDocumentThrew = false;
389
+ try { innerDoc = el.contentDocument; } catch (e) { innerDoc = null; candidateContentDocumentThrew = true; }
390
+ if (candidateContentDocumentThrew) candidateIframeReadFailures += 1;
391
+ if (innerDoc && innerDoc.body) {
392
+ var ir = el.getBoundingClientRect();
393
+ var childOffset = { dx: frameOffset.dx + ir.left, dy: frameOffset.dy + ir.top, frameId: iframeFrameId(bridgeIndexOf(el)) };
394
+ var innerKids = innerDoc.body.children;
395
+ for (var ii = 0; ii < innerKids.length; ii++) walkCandidates(innerKids[ii], childOffset, innerDoc);
396
+ }
397
+ return;
398
+ }
399
+
400
+ var children = el.children;
401
+ for (var c = 0; c < children.length; c++) walkCandidates(children[c], frameOffset, ownerDoc);
402
+ }
403
+
404
+ var topKids = document.body ? document.body.children : [];
405
+ for (var bi = 0; bi < topKids.length; bi++) {
406
+ walkCandidates(topKids[bi], { dx: 0, dy: 0, frameId: 'frame-0' }, document);
407
+ }
408
+
409
+ var results = [];
410
+ for (var ci = 0; ci < candidates.length; ci++) {
411
+ var cand = candidates[ci];
412
+ var pts = pointLabelsFor(cand.rect);
413
+ var pointEntries = [];
414
+ var selfHitCount = 0;
415
+ for (var pi = 0; pi < pts.length; pi++) {
416
+ var p = pts[pi];
417
+ var raw2 = buildPointResult(cand.ownerDoc, p.x, p.y, cand.frameOffset, 0);
418
+ if (raw2.topReceiverEl === cand.el) selfHitCount += 1;
419
+ pointEntries.push({ label: p.label, result: stripPoint(raw2) });
420
+ }
421
+ results.push({
422
+ markIdx: ci,
423
+ bridgeIdx: bridgeIndexOf(cand.el),
424
+ selector: __selectorOf(cand.el),
425
+ text: directTextOf(cand.el),
426
+ points: pointEntries,
427
+ selfHitCount: selfHitCount,
428
+ selfHitTotal: pts.length,
429
+ });
430
+ }
431
+
432
+ var vw = window.innerWidth;
433
+ var vh = window.innerHeight;
434
+ var samplePoints = [];
435
+ // Exact count of real lattice points the LATTICE_MAX_POINTS cap dropped
436
+ // -- the double loop always finishes (no early break), it just stops
437
+ // pushing once the cap is hit, so this is a real count, never a guess.
438
+ var latticeOverflowCount = 0;
439
+ for (var y = LATTICE_STEP / 2; y < vh; y += LATTICE_STEP) {
440
+ for (var x = LATTICE_STEP / 2; x < vw; x += LATTICE_STEP) {
441
+ if (samplePoints.length < LATTICE_MAX_POINTS) {
442
+ samplePoints.push({ x: x, y: y });
443
+ } else {
444
+ latticeOverflowCount += 1;
445
+ }
446
+ }
447
+ }
448
+ var samples = [];
449
+ for (var spi = 0; spi < samplePoints.length; spi++) {
450
+ var sp = samplePoints[spi];
451
+ var raw3 = buildPointResult(document, sp.x, sp.y, { dx: 0, dy: 0, frameId: 'frame-0' }, 0);
452
+ samples.push(stripPoint(raw3));
453
+ }
454
+
455
+ return {
456
+ facts: {
457
+ bridgeCount: bridgeEls.length,
458
+ elements: results,
459
+ samples: samples,
460
+ elementsTruncated: candidatesOverflowCount,
461
+ samplesTruncated: latticeOverflowCount,
462
+ bridgeTruncated: bridgeOverflowCount,
463
+ candidateIframesUnavailable: candidateIframeReadFailures,
464
+ },
465
+ elements: bridgeEls,
466
+ };
467
+ })()`;
468
+ }
469
+
470
+ // ============================================================================
471
+ // Raw (pre-bridge-resolution) shapes returned by the page-side script.
472
+ // ============================================================================
473
+
474
+ interface RawStackMember {
475
+ readonly bridgeIdx?: number;
476
+ readonly selector?: string;
477
+ readonly tag: string;
478
+ readonly rect: Rect;
479
+ readonly zIndex: string;
480
+ readonly pointerEvents: string;
481
+ readonly cursor: string;
482
+ readonly opacity: number;
483
+ readonly disabled: boolean;
484
+ readonly ariaDisabled: boolean;
485
+ readonly inert: boolean;
486
+ readonly clipped: boolean;
487
+ readonly inShadowDom: boolean;
488
+ readonly inIframe: boolean;
489
+ readonly frameId?: string;
490
+ }
491
+
492
+ interface RawPointResult {
493
+ readonly x: number;
494
+ readonly y: number;
495
+ readonly stack: RawStackMember[];
496
+ readonly topReceiver: RawStackMember | null;
497
+ readonly retargetedThroughIframe: boolean;
498
+ readonly retargetedThroughShadow: boolean;
499
+ readonly opaqueFrame: boolean;
500
+ /** H5: `true` when `document.elementsFromPoint()` threw or returned a nullish result at this point — `stack`/`topReceiver` are then a forced empty read, not a proven "nothing here" observation. */
501
+ readonly stackUnavailable: boolean;
502
+ }
503
+
504
+ interface RawElementSample {
505
+ readonly markIdx: number;
506
+ readonly bridgeIdx?: number;
507
+ readonly selector?: string;
508
+ readonly text?: string;
509
+ readonly points: Array<{ label: string; result: RawPointResult }>;
510
+ readonly selfHitCount: number;
511
+ readonly selfHitTotal: number;
512
+ }
513
+
514
+ interface RawHittestResult {
515
+ readonly bridgeCount: number;
516
+ readonly elements: RawElementSample[];
517
+ readonly samples: RawPointResult[];
518
+ /** Exact count of real candidate elements the {@link MAX_ELEMENTS} cap dropped (0 when everything fit). */
519
+ readonly elementsTruncated: number;
520
+ /** Exact count of real whole-viewport lattice points the {@link LATTICE_MAX_POINTS} cap dropped (0 when everything fit). */
521
+ readonly samplesTruncated: number;
522
+ /** Exact count of distinct elements the {@link MAX_BRIDGE_ELEMENTS} cap turned away from identity resolution (0 when everything fit). */
523
+ readonly bridgeTruncated: number;
524
+ /** H7: exact count of same-origin `<iframe>` elements whose `contentDocument` read THREW during the candidate walk (0 when everything fit or every iframe was genuinely cross-origin/childless, which throws nothing). */
525
+ readonly candidateIframesUnavailable: number;
526
+ }
527
+
528
+ // ============================================================================
529
+ // Public record shapes
530
+ // ============================================================================
531
+
532
+ export interface HitTestStackMember {
533
+ /** `null` (never an omitted key) when this record's identity did not resolve — see {@link identityUnresolved}. */
534
+ readonly backendNodeId: number | null;
535
+ /** `true` when {@link backendNodeId} is `null` because identity resolution was capped or failed — never omit this alongside a `null` backendNodeId, so a downstream join can never mistake an unresolved record for a resolved one. Absent (not `false`) when identity resolved. */
536
+ readonly identityUnresolved?: boolean;
537
+ readonly selector?: string;
538
+ readonly tag: string;
539
+ readonly rect: Rect;
540
+ /** `true` when {@link rect} is the JS-local `getBoundingClientRect()` approximation because the CDP rect upgrade (`DOM.getContentQuads`/`DOM.getBoxModel`, attempted only for PRIMARY elements — see the module doc) rejected. A rejected upgrade covers both a genuine no-layout-box element and an actual protocol/read failure (the two are indistinguishable from this shared `coordinates.ts` helper alone — see {@link BridgeInfo}), so this marks EITHER, but a rejection must never be silently absorbed into the same unmarked shape a real CDP-derived rect produces. Absent when `rect` is the real CDP-derived quad-union rect, or when this member is not a primary element (the upgrade is never attempted for those, by design, not a failure). */
541
+ readonly rectCdpUpgradeFailed?: true;
542
+ readonly zIndex: string;
543
+ readonly pointerEvents: string;
544
+ readonly cursor: string;
545
+ readonly opacity: number;
546
+ readonly disabled: boolean;
547
+ readonly ariaDisabled: boolean;
548
+ readonly inert: boolean;
549
+ readonly clipped: boolean;
550
+ readonly inShadowDom: boolean;
551
+ readonly inIframe: boolean;
552
+ readonly frameId?: string;
553
+ }
554
+
555
+ export interface HitTestPointResult {
556
+ readonly x: number;
557
+ readonly y: number;
558
+ readonly stack: HitTestStackMember[];
559
+ readonly topReceiver: HitTestStackMember | null;
560
+ readonly retargetedThroughIframe: boolean;
561
+ readonly retargetedThroughShadow: boolean;
562
+ /** True when the topmost element hit at this point is an `<iframe>` whose `contentDocument` is null — a cross-origin/opaque frame. A factual scope marker so an opaque frame is not silently indistinguishable from an empty same-origin one. */
563
+ readonly opaqueFrame: boolean;
564
+ /** `true` when `document.elementsFromPoint()` itself threw or returned a nullish result at this point (H5) — `stack: []`/`topReceiver: null` is then a forced-empty read, not a proven "nothing receives a hit here" observation. Always present (never omitted), so a genuinely empty stack is distinguishable from a failed read. */
565
+ readonly stackUnavailable: boolean;
566
+ }
567
+
568
+ export interface HitTestElementSample {
569
+ readonly id: string;
570
+ /** `null` (never an omitted key) when this record's identity did not resolve — see {@link identityUnresolved}. */
571
+ readonly backendNodeId: number | null;
572
+ /** `true` when {@link backendNodeId} is `null` because identity resolution was capped or failed. Absent (not `false`) when identity resolved. */
573
+ readonly identityUnresolved?: boolean;
574
+ readonly selector?: string;
575
+ readonly text?: string;
576
+ readonly points: Array<{ label: string; result: HitTestPointResult }>;
577
+ readonly selfHitCount: number;
578
+ readonly selfHitTotal: number;
579
+ }
580
+
581
+ /** Fixed, factual reason the walk evaluate/bridge could not be read (never a raw exception message, which is unbounded/page-influenced) — present only when {@link HittestJson.available} is `false`. `walk-facts-unavailable` covers BOTH a missing `facts` objectId on the held container and a `readHeldValue()` that resolves without throwing but returns `undefined` — either way the required `facts` read did not happen, so it collapses to the one reason rather than two indistinguishable-in-practice ones. */
582
+ export type HittestUnavailableReason = 'walk-evaluate-threw' | 'walk-evaluate-returned-no-object' | 'walk-facts-unavailable';
583
+
584
+ export interface HittestJson {
585
+ readonly elements: HitTestElementSample[];
586
+ readonly samples: HitTestPointResult[];
587
+ /** Exact count of real candidate elements the {@link MAX_ELEMENTS} cap dropped — always present (0 when everything fit), per I-5. */
588
+ readonly elementsTruncated: number;
589
+ /** Exact count of real whole-viewport lattice points the {@link LATTICE_MAX_POINTS} cap dropped — always present (0 when everything fit), per I-5. */
590
+ readonly samplesTruncated: number;
591
+ /** Exact count of distinct elements the {@link MAX_BRIDGE_ELEMENTS} cap turned away from identity resolution — always present (0 when everything fit), per I-5. Every affected record still carries `backendNodeId: null` + `identityUnresolved: true` (see {@link HitTestStackMember}), so this is an aggregate on top of, never a substitute for, the per-record honesty marker. */
592
+ readonly bridgeTruncated: number;
593
+ /** Exact count of same-origin `<iframe>` elements whose `contentDocument` read THREW during the candidate walk (H7) — always present (0 when everything fit or every iframe was genuinely cross-origin/childless), per I-5. That iframe's own subtree was never walked for candidates, but (unlike G6 in geometry.ts) the failing iframe element may have no retained candidate record of its own to mark, so this is an aggregate rather than a per-record honesty field. */
594
+ readonly candidateIframesUnavailable: number;
595
+ /** `false` when the walk evaluate/bridge itself failed — `elements: []`/`samples: []` with every truncation count at `0` is then "could not collect", not "genuinely empty page" (I-4/I-5). Always `true` on a normal run, including one where the page really has no elements/samples. */
596
+ readonly available: boolean;
597
+ /** Present only when `available` is `false`. */
598
+ readonly unavailableReason?: HittestUnavailableReason;
599
+ /** 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. */
600
+ readonly bridgeCleanupFailed?: boolean;
601
+ }
602
+
603
+ // ============================================================================
604
+ // Bridge resolution — EVERY element-bearing record gets a bridge index and
605
+ // is resolved to a backendNodeId here: every stack member at every depth
606
+ // (`makeStackMember`), each point's topReceiver (already one of those stack
607
+ // members), and every primary sampled element. Only primary elements
608
+ // additionally get their `rect` upgraded from JS-local math to a real
609
+ // top-viewport CDP quad (see module doc) — ordinary stack members keep
610
+ // their JS-computed rect but are bridged for identity exactly like primary
611
+ // elements are.
612
+ // ============================================================================
613
+
614
+ interface BridgeInfo {
615
+ readonly backendNodeId?: number;
616
+ readonly rect?: Rect;
617
+ /** `true` when this bridge entry is a PRIMARY element (see {@link resolveBridge}'s `primaryIdxSet` param) whose CDP rect upgrade (`DOM.getContentQuads`/`DOM.getBoxModel` via `coordinates.ts`'s `getContentQuadBox`) rejected — {@link rect} is absent, so `patchMember` falls back to the JS-local `member.rect`, but that fallback must be marked (see {@link HitTestStackMember.rectCdpUpgradeFailed}) so a protocol/read failure can never be indistinguishable from a rect that is deliberately JS-local (e.g. an ordinary, non-primary stack member — see the module doc). Absent when the upgrade succeeded or was never attempted (non-primary element). */
618
+ readonly rectCdpUpgradeFailed?: true;
619
+ }
620
+
621
+ function unionRect(quads: Quad[]): Rect {
622
+ let minX = Infinity;
623
+ let minY = Infinity;
624
+ let maxX = -Infinity;
625
+ let maxY = -Infinity;
626
+ for (const quad of quads) {
627
+ const r = axisAlignedRectFromQuad(quad);
628
+ minX = Math.min(minX, r.x);
629
+ minY = Math.min(minY, r.y);
630
+ maxX = Math.max(maxX, r.x + r.width);
631
+ maxY = Math.max(maxY, r.y + r.height);
632
+ }
633
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
634
+ }
635
+
636
+ /** Resolves each already-bridged `objectId` (sourced from the held `elements` array — see {@link collectHittest}, never a page-observable global) to its `backendNodeId`, plus (for PRIMARY elements only) an upgraded top-viewport CDP quad rect. */
637
+ async function resolveBridge(
638
+ client: CDPClient,
639
+ objectIds: ReadonlyArray<string | undefined>,
640
+ primaryIdxSet: ReadonlySet<number>,
641
+ ): Promise<Map<number, BridgeInfo>> {
642
+ const map = new Map<number, BridgeInfo>();
643
+ await Promise.all(
644
+ objectIds.map(async (objectId, idx) => {
645
+ if (!objectId) return;
646
+ const backendNodeId = await describeBackendNodeId(client, objectId);
647
+ let rect: Rect | undefined;
648
+ let rectCdpUpgradeFailed: true | undefined;
649
+ if (primaryIdxSet.has(idx)) {
650
+ try {
651
+ const box = await getContentQuadBox(client, { objectId });
652
+ if (box.quads.length > 0) rect = unionRect(box.quads);
653
+ } catch {
654
+ // The CDP rect upgrade rejected -- getContentQuadBox bundles
655
+ // DOM.getContentQuads + DOM.getBoxModel behind one reject (see
656
+ // coordinates.ts's own doc comment), so this covers a genuine
657
+ // no-layout-box element (display:none, zero-size) AND an actual
658
+ // protocol/read failure alike -- coordinates.ts is a shared
659
+ // helper used by geometry.ts/pixels.ts too, so this call site
660
+ // cannot split those apart without changing that shared contract
661
+ // (out of scope here). Either way `rect` stays the JS-local
662
+ // `member.rect` fallback below, but that fallback must never be
663
+ // indistinguishable from a genuinely CDP-upgraded rect, so mark
664
+ // it explicitly (I-4/I-5) rather than silently keeping it mute.
665
+ rectCdpUpgradeFailed = true;
666
+ }
667
+ }
668
+ map.set(idx, { backendNodeId, rect, ...(rectCdpUpgradeFailed ? { rectCdpUpgradeFailed } : {}) });
669
+ }),
670
+ );
671
+ return map;
672
+ }
673
+
674
+ /** Builds the honest `{ backendNodeId, identityUnresolved }` pair shared by every element-bearing hittest record — one authoritative shape, reused for stack members, topReceivers, and primary elements, so "identity did not resolve" is expressed identically everywhere it can happen (per I-3/I-5). */
675
+ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: number | null; identityUnresolved?: true } {
676
+ return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
677
+ }
678
+
679
+ function patchMember(member: RawStackMember, resolved: Map<number, BridgeInfo>): HitTestStackMember {
680
+ const info = member.bridgeIdx !== undefined ? resolved.get(member.bridgeIdx) : undefined;
681
+ return {
682
+ ...resolvedIdentity(info?.backendNodeId),
683
+ selector: member.selector !== undefined ? sanitizeString(member.selector, { max: 300 }) : undefined,
684
+ tag: sanitizeString(member.tag, { max: 64 }),
685
+ rect: info?.rect ?? member.rect,
686
+ ...(info?.rectCdpUpgradeFailed ? { rectCdpUpgradeFailed: true as const } : {}),
687
+ zIndex: sanitizeString(member.zIndex, { max: 20 }),
688
+ pointerEvents: sanitizeString(member.pointerEvents, { max: 100 }),
689
+ cursor: sanitizeString(member.cursor, { max: 300 }),
690
+ opacity: member.opacity,
691
+ disabled: member.disabled,
692
+ ariaDisabled: member.ariaDisabled,
693
+ inert: member.inert,
694
+ clipped: member.clipped,
695
+ inShadowDom: member.inShadowDom,
696
+ inIframe: member.inIframe,
697
+ frameId: member.frameId,
698
+ };
699
+ }
700
+
701
+ function patchPoint(pr: RawPointResult, resolved: Map<number, BridgeInfo>): HitTestPointResult {
702
+ return {
703
+ x: pr.x,
704
+ y: pr.y,
705
+ stack: pr.stack.map((m) => patchMember(m, resolved)),
706
+ topReceiver: pr.topReceiver ? patchMember(pr.topReceiver, resolved) : null,
707
+ retargetedThroughIframe: pr.retargetedThroughIframe,
708
+ retargetedThroughShadow: pr.retargetedThroughShadow,
709
+ opaqueFrame: pr.opaqueFrame,
710
+ stackUnavailable: pr.stackUnavailable,
711
+ };
712
+ }
713
+
714
+ // ============================================================================
715
+ // Collector
716
+ // ============================================================================
717
+
718
+ export const collectHittest: Collector = async (ctx) => {
719
+ let raw: RawHittestResult = {
720
+ bridgeCount: 0,
721
+ elements: [],
722
+ samples: [],
723
+ elementsTruncated: 0,
724
+ samplesTruncated: 0,
725
+ bridgeTruncated: 0,
726
+ candidateIframesUnavailable: 0,
727
+ };
728
+ let objectIds: Array<string | undefined> = [];
729
+ let bridgeCleanupFailed = false;
730
+ // I-5/I-4: distinguishes "the walk evaluate/bridge failed" from "the page
731
+ // really has zero candidates/samples" -- both would otherwise collapse to
732
+ // the same empty elements/samples with every truncation count at 0,
733
+ // falsely claiming nothing was dropped when in fact nothing was ever read.
734
+ let available = true;
735
+ let unavailableReason: HittestUnavailableReason | undefined;
736
+ const heldObjectIds: string[] = [];
737
+ try {
738
+ const walkEval = (await ctx.client.send('Runtime.evaluate', {
739
+ expression: SELECTOR_HELPER_JS + buildHittestScript(MAX_ELEMENTS, LATTICE_STEP, LATTICE_MAX_POINTS, MAX_BRIDGE_ELEMENTS),
740
+ returnByValue: false,
741
+ })) as { result?: { objectId?: string } };
742
+ const resultObjectId = walkEval.result?.objectId;
743
+
744
+ if (resultObjectId) {
745
+ heldObjectIds.push(resultObjectId);
746
+ const containerIds = await ownPropertyObjectIds(ctx.client, resultObjectId);
747
+ const factsObjectId = containerIds.get('facts');
748
+ const elementsObjectId = containerIds.get('elements');
749
+
750
+ // I-4: a missing `facts` objectId (the container came back without
751
+ // it) and a `readHeldValue()` that resolves to `undefined` without
752
+ // throwing are BOTH "the required facts read did not happen" -- both
753
+ // must fall to available:false, never quietly fall through to `raw`'s
754
+ // initialized-empty default (which would look identical to a
755
+ // genuinely empty page).
756
+ let factsValue: RawHittestResult | undefined;
757
+ if (factsObjectId) {
758
+ heldObjectIds.push(factsObjectId);
759
+ factsValue = await readHeldValue<RawHittestResult>(ctx.client, factsObjectId);
760
+ }
761
+
762
+ if (factsValue === undefined) {
763
+ available = false;
764
+ unavailableReason = 'walk-facts-unavailable';
765
+ } else {
766
+ raw = factsValue;
767
+ if (elementsObjectId && raw.bridgeCount > 0) {
768
+ heldObjectIds.push(elementsObjectId);
769
+ objectIds = await resolveIndexedObjectIds(ctx.client, elementsObjectId, raw.bridgeCount);
770
+ }
771
+ }
772
+ } else {
773
+ available = false;
774
+ unavailableReason = 'walk-evaluate-returned-no-object';
775
+ }
776
+ } catch {
777
+ raw = {
778
+ bridgeCount: 0,
779
+ elements: [],
780
+ samples: [],
781
+ elementsTruncated: 0,
782
+ samplesTruncated: 0,
783
+ bridgeTruncated: 0,
784
+ candidateIframesUnavailable: 0,
785
+ };
786
+ available = false;
787
+ unavailableReason = 'walk-evaluate-threw';
788
+ } finally {
789
+ // Runs UNCONDITIONALLY: every held container/facts/elements objectId must
790
+ // be released even when a step above throws, so a later capture on the
791
+ // same tab never collides with anything this run held.
792
+ for (const id of heldObjectIds) {
793
+ try {
794
+ await ctx.client.send('Runtime.releaseObject', { objectId: id });
795
+ } catch {
796
+ // Cleanup failing shouldn't fail the whole capture — but it IS a fact
797
+ // downstream should see, recorded below as bridgeCleanupFailed.
798
+ bridgeCleanupFailed = true;
799
+ }
800
+ }
801
+ }
802
+
803
+ const primaryIdxSet = new Set<number>();
804
+ for (const e of raw.elements) {
805
+ if (e.bridgeIdx !== undefined) primaryIdxSet.add(e.bridgeIdx);
806
+ }
807
+
808
+ const resolved = await resolveBridge(ctx.client, objectIds, primaryIdxSet);
809
+
810
+ const elements: HitTestElementSample[] = raw.elements.map((e) => {
811
+ const primaryInfo = e.bridgeIdx !== undefined ? resolved.get(e.bridgeIdx) : undefined;
812
+ return {
813
+ id: `hit-${e.markIdx}`,
814
+ ...resolvedIdentity(primaryInfo?.backendNodeId),
815
+ selector: e.selector !== undefined ? sanitizeString(e.selector, { max: 300 }) : undefined,
816
+ text: e.text !== undefined ? sanitizeString(e.text, { max: 200 }) : undefined,
817
+ points: e.points.map((p) => ({ label: p.label, result: patchPoint(p.result, resolved) })),
818
+ selfHitCount: e.selfHitCount,
819
+ selfHitTotal: e.selfHitTotal,
820
+ };
821
+ });
822
+ const samples: HitTestPointResult[] = raw.samples.map((s) => patchPoint(s, resolved));
823
+
824
+ ctx.write.json('hittest.json', {
825
+ elements,
826
+ samples,
827
+ elementsTruncated: raw.elementsTruncated,
828
+ samplesTruncated: raw.samplesTruncated,
829
+ bridgeTruncated: raw.bridgeTruncated,
830
+ candidateIframesUnavailable: raw.candidateIframesUnavailable,
831
+ available,
832
+ ...(unavailableReason ? { unavailableReason } : {}),
833
+ ...(bridgeCleanupFailed ? { bridgeCleanupFailed: true } : {}),
834
+ } satisfies HittestJson);
835
+ };