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