@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,1038 @@
1
+ /**
2
+ * `geometry.json` collector — element rects, quads, box-model detail,
3
+ * layout provenance (flex/grid/clipping/stacking-context), and (on an
4
+ * unsettled `--capture-unsettled` capture) the unstable-region markers.
5
+ *
6
+ * Architecture: ONE `Runtime.evaluate` walks the whole reachable tree
7
+ * (document body, open shadow roots, and same-origin iframe
8
+ * `contentDocument`s) and returns page-side FACTS (selector, computed-style
9
+ * provenance, frame/shadow context) keyed by `idx` — never geometry. The
10
+ * walk never assigns anything to `window` or any other page-observable
11
+ * location: its return value is a plain in-memory `{ facts, elements }`
12
+ * object, read back purely through CDP's own remote-object identity —
13
+ * `Runtime.evaluate({returnByValue: false})` hands back an `objectId` for
14
+ * that held object with zero page visibility into it, `Runtime.getProperties`
15
+ * resolves `facts`'/`elements`'s own `objectId`s ({@link ownPropertyObjectIds}),
16
+ * `Runtime.callFunctionOn({returnByValue: true})` reads `facts` out by value
17
+ * ({@link readHeldValue}), and a second `Runtime.getProperties` on the held
18
+ * `elements` objectId resolves each matched element's own `objectId` in one
19
+ * round trip ({@link resolveIndexedObjectIds}) — no N separate evaluates,
20
+ * and no page-observable global for a page to define a setter against. From
21
+ * there, `DOM.describeNode` (backendNodeId) is fetched per element, and
22
+ * `DOM.getContentQuads`/`DOM.getBoxModel` are read through this file's own
23
+ * {@link readContentQuads}/{@link readBoxModel} — deliberately NOT
24
+ * `../../coordinates.js`'s combined `getContentQuadBox`, because Chrome
25
+ * throws `DOM.getBoxModel` for EVERY node with no layout box (the identical
26
+ * throw a genuinely invalid/detached node reference produces), so a
27
+ * combined one-catch read would make a real `display:none`/no-box element
28
+ * indistinguishable from an actual failed read. `readContentQuads` is
29
+ * called first and alone; only a THROW from it — never a successful empty
30
+ * result — counts as a per-element geometry read failure (I-4/I-5,
31
+ * {@link GeometryElementRecord.geometryUnavailable}). `readBoxModel` is
32
+ * only attempted once real (non-empty) quads prove a box exists, so its own
33
+ * throw can only mean the detail read failed, not that the box never
34
+ * existed. Every held container/facts/elements
35
+ * `objectId` is released via `Runtime.releaseObject` in `finally`
36
+ * (unconditionally), so a later capture on the same tab never collides with
37
+ * anything this run held — a release failure is recorded as the factual
38
+ * `bridgeCleanupFailed`, but can never imply a contaminated baseline: there
39
+ * is no page-observable state left to leak, only CDP-session-scoped
40
+ * remote-object memory freed when the tab closes.
41
+ *
42
+ * Coordinate space: every reported `rect`/`quads`/`boxModel` comes from
43
+ * real CDP `DOM.getContentQuads`/`DOM.getBoxModel` calls — never from JS
44
+ * `getBoundingClientRect()` math. Empirically confirmed (see U07's final
45
+ * report) that CDP already returns these quads in TOP-VIEWPORT space for
46
+ * same-process (non-OOPIF) nested iframes over one un-sessioned CDP
47
+ * connection — including correct vertical margin-collapse behavior — so no
48
+ * `composeFrameTransform`/`toTopViewportQuad` stitching is needed here.
49
+ * `getBoundingClientRect()` is used ONLY for same-document-local math
50
+ * (visibility/zero-size flags, the clipping-ancestor ratio calc) — never
51
+ * reported as final geometry.
52
+ *
53
+ * U07 note: this file keeps spreading `ctx.unstableRegions` into the
54
+ * written object's `unstableRegions` field exactly as the original stub
55
+ * did; it is NOT recomputed here. The orchestrator (`snapshot.ts`) already
56
+ * computed and passed it in via `ctx` — this is what makes
57
+ * "`--capture-unsettled` writes geometry.json with unstable-region ids"
58
+ * true regardless of which unit last touched this file.
59
+ */
60
+
61
+ import type { CDPClient } from '../../client.js';
62
+ import { axisAlignedRectFromQuad, type Quad, type Rect } from '../../coordinates.js';
63
+ import { capArray, sanitizeString } from '../redaction.js';
64
+ import type { Collector, ElementRecord } from '../types.js';
65
+
66
+ /** Defensive cap on how many elements one geometry walk will record for a single snapshot. */
67
+ const MAX_ELEMENTS = 1200;
68
+
69
+ // ============================================================================
70
+ // Shared JS helpers — inlined (by string concatenation) into BOTH this
71
+ // file's walk script and hittest.ts's walk/sample script. Exported so
72
+ // hittest.ts imports rather than duplicates. NONE of these cap in-page:
73
+ // every page-controlled string they build is returned at full length and
74
+ // is capped node-side through redaction.ts `sanitizeString` (the single
75
+ // cap authority).
76
+ // ============================================================================
77
+
78
+ export const SELECTOR_HELPER_JS = `
79
+ function __selectorOf(el) {
80
+ try {
81
+ if (!el || el.nodeType !== 1) return undefined;
82
+ if (el.id) return '#' + el.id;
83
+ var parts = [];
84
+ var node = el;
85
+ var depth = 0;
86
+ while (node && node.nodeType === 1 && depth < 6) {
87
+ var part = node.tagName.toLowerCase();
88
+ if (node.id) {
89
+ part += '#' + node.id;
90
+ parts.unshift(part);
91
+ break;
92
+ }
93
+ if (node.classList && node.classList.length) {
94
+ part += '.' + Array.prototype.slice.call(node.classList).slice(0, 3).join('.');
95
+ }
96
+ var parent = node.parentElement;
97
+ if (parent) {
98
+ var siblings = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
99
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
100
+ }
101
+ parts.unshift(part);
102
+ node = parent;
103
+ depth += 1;
104
+ }
105
+ return parts.join(' > ');
106
+ } catch (e) { return undefined; }
107
+ }
108
+ function __domPathOf(el) {
109
+ try {
110
+ var parts = [];
111
+ var node = el;
112
+ var hops = 0;
113
+ while (node && node.nodeType === 1 && hops < 200) {
114
+ var idx = 0;
115
+ var sib = node;
116
+ while ((sib = sib.previousElementSibling)) idx += 1;
117
+ parts.unshift(node.tagName.toLowerCase() + '[' + idx + ']');
118
+ var parentEl = node.parentElement;
119
+ if (!parentEl) {
120
+ var root = node.getRootNode ? node.getRootNode() : null;
121
+ parentEl = root && root.host ? root.host : null;
122
+ }
123
+ node = parentEl;
124
+ hops += 1;
125
+ }
126
+ return parts.join('/') || '';
127
+ } catch (e) { return ''; }
128
+ }
129
+ `;
130
+
131
+ // ============================================================================
132
+ // The page-side walk. Runs once via `Runtime.evaluate({returnByValue: false})`
133
+ // -- the return value is held as a remote object (see the module doc's
134
+ // CDP-only identity bridge), never read back by value directly.
135
+ // ============================================================================
136
+
137
+ function buildGeometryWalkScript(maxElements: number): string {
138
+ return `(function () {
139
+ var MAX_ELEMENTS = ${maxElements};
140
+ var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEMPLATE: 1, HEAD: 1, META: 1, LINK: 1, TITLE: 1, BASE: 1, NOSCRIPT: 1 };
141
+ var facts = [];
142
+ var elements = [];
143
+ var frameCounter = 0;
144
+ // Exact count of elements the MAX_ELEMENTS cap chose not to record --
145
+ // real elements the walk visited (recursed into) but skipped the
146
+ // expensive per-element fact-building for, never a page-observable value.
147
+ var elementsOverflowCount = 0;
148
+
149
+ function stackingReasons(style) {
150
+ var reasons = [];
151
+ if (style.position !== 'static' && style.zIndex !== 'auto') reasons.push('position+z-index');
152
+ var op = parseFloat(style.opacity);
153
+ if (!isNaN(op) && op < 1) reasons.push('opacity<1');
154
+ if (style.transform && style.transform !== 'none') reasons.push('transform');
155
+ if (style.filter && style.filter !== 'none') reasons.push('filter');
156
+ if (style.perspective && style.perspective !== 'none') reasons.push('perspective');
157
+ if (style.mixBlendMode && style.mixBlendMode !== 'normal') reasons.push('mix-blend-mode');
158
+ if (style.isolation === 'isolate') reasons.push('isolate');
159
+ if (style.willChange && /transform|opacity|filter/.test(style.willChange)) reasons.push('will-change');
160
+ if (style.contain && /layout|paint|strict|content/.test(style.contain)) reasons.push('contain');
161
+ if (style.position === 'fixed') reasons.push('fixed');
162
+ if (style.position === 'sticky') reasons.push('sticky');
163
+ return reasons;
164
+ }
165
+
166
+ function clippingInfo(el, rect) {
167
+ try {
168
+ if (rect.width === 0 && rect.height === 0) return null;
169
+ var node = el.parentElement;
170
+ var hops = 0;
171
+ var docEl = el.ownerDocument.documentElement;
172
+ while (node && node.nodeType === 1 && node !== docEl && hops < 50) {
173
+ var s = getComputedStyle(node);
174
+ var clipX = /hidden|auto|scroll|clip/.test(s.overflowX);
175
+ var clipY = /hidden|auto|scroll|clip/.test(s.overflowY);
176
+ if (clipX || clipY) {
177
+ var ar = node.getBoundingClientRect();
178
+ var ix1 = Math.max(rect.left, ar.left);
179
+ var iy1 = Math.max(rect.top, ar.top);
180
+ var ix2 = Math.min(rect.right, ar.right);
181
+ var iy2 = Math.min(rect.bottom, ar.bottom);
182
+ var iw = Math.max(0, ix2 - ix1);
183
+ var ih = Math.max(0, iy2 - iy1);
184
+ var selfArea = rect.width * rect.height;
185
+ var fraction = selfArea > 0 ? (iw * ih) / selfArea : 0;
186
+ if (fraction < 0.999) {
187
+ return { clippedBy: __selectorOf(node), clippedFraction: Math.round(fraction * 1000) / 1000 };
188
+ }
189
+ }
190
+ node = node.parentElement;
191
+ hops += 1;
192
+ }
193
+ return null;
194
+ } catch (e) { return null; }
195
+ }
196
+
197
+ function flexFactsFor(el, parentStyle) {
198
+ try {
199
+ var style = getComputedStyle(el);
200
+ return {
201
+ grow: parseFloat(style.flexGrow) || 0,
202
+ shrink: parseFloat(style.flexShrink) || 0,
203
+ basis: style.flexBasis,
204
+ alignSelf: style.alignSelf,
205
+ order: parseInt(style.order, 10) || 0,
206
+ container: {
207
+ direction: parentStyle.flexDirection,
208
+ wrap: parentStyle.flexWrap,
209
+ justifyContent: parentStyle.justifyContent,
210
+ alignItems: parentStyle.alignItems,
211
+ gap: parentStyle.gap,
212
+ },
213
+ };
214
+ } catch (e) { return null; }
215
+ }
216
+
217
+ function gridFactsFor(el, parentStyle) {
218
+ try {
219
+ var style = getComputedStyle(el);
220
+ return {
221
+ columnStart: style.gridColumnStart,
222
+ columnEnd: style.gridColumnEnd,
223
+ rowStart: style.gridRowStart,
224
+ rowEnd: style.gridRowEnd,
225
+ container: {
226
+ templateColumns: parentStyle.gridTemplateColumns.split(' ').filter(Boolean),
227
+ templateRows: parentStyle.gridTemplateRows.split(' ').filter(Boolean),
228
+ columnGap: parentStyle.columnGap,
229
+ rowGap: parentStyle.rowGap,
230
+ },
231
+ };
232
+ } catch (e) { return null; }
233
+ }
234
+
235
+ function directText(el) {
236
+ var out = '';
237
+ for (var i = 0; i < el.childNodes.length; i++) {
238
+ var n = el.childNodes[i];
239
+ if (n.nodeType === 3 && n.textContent) out += n.textContent;
240
+ }
241
+ out = out.replace(/\\s+/g, ' ').trim();
242
+ return out || undefined;
243
+ }
244
+
245
+ function walk(el, frameId, isTop, ancestorFrameIds, shadowCtx) {
246
+ if (!el || el.nodeType !== 1) return;
247
+ var tag = el.tagName;
248
+ if (SKIP_TAGS[tag]) return;
249
+
250
+ // The MAX_ELEMENTS cap bounds only the EXPENSIVE per-element work below
251
+ // (getComputedStyle/rect/clip-ancestor-walk facts) -- recursion into
252
+ // shadow roots, same-origin iframes, and children always continues
253
+ // past the cap so elementsTruncated below is an EXACT count of the
254
+ // real elements this walk chose not to record, not a guess.
255
+ // Captures the fact object THIS call pushed (if any) so the IFRAME
256
+ // branch below can attach an honest failure marker directly to it --
257
+ // stays null when this element's own record was skipped by the
258
+ // MAX_ELEMENTS cap (recursion still continues past the cap, so a
259
+ // capped IFRAME's contentDocument read failure has no record to mark,
260
+ // same limitation geometryUnavailable already accepts for capped
261
+ // elements).
262
+ var pushedFact = null;
263
+ if (facts.length >= MAX_ELEMENTS) {
264
+ elementsOverflowCount += 1;
265
+ } else {
266
+ var style = getComputedStyle(el);
267
+ var rect = el.getBoundingClientRect();
268
+ var displayNone = style.display === 'none';
269
+ var visibilityHidden = style.visibility === 'hidden';
270
+ var opacity = parseFloat(style.opacity);
271
+ if (isNaN(opacity)) opacity = 1;
272
+ var zeroSize = rect.width === 0 && rect.height === 0;
273
+ var visible = !displayNone && !visibilityHidden && opacity > 0 && !zeroSize;
274
+
275
+ var parent = el.parentElement;
276
+ var parentStyle = parent ? getComputedStyle(parent) : null;
277
+ var isFlexChild = parentStyle && (parentStyle.display === 'flex' || parentStyle.display === 'inline-flex');
278
+ var isGridChild = parentStyle && (parentStyle.display === 'grid' || parentStyle.display === 'inline-grid');
279
+
280
+ var idx = facts.length;
281
+ elements.push(el);
282
+ var reasons = stackingReasons(style);
283
+ var factObj = {
284
+ idx: idx,
285
+ tag: tag.toLowerCase(),
286
+ selector: __selectorOf(el),
287
+ domPath: __domPathOf(el),
288
+ text: directText(el),
289
+ frame: { frameId: frameId, isTopFrame: isTop, ancestorFrameIds: ancestorFrameIds.slice() },
290
+ shadow: shadowCtx,
291
+ zIndex: style.zIndex || 'auto',
292
+ stackingContext: { creates: reasons.length > 0, reasons: reasons },
293
+ visibility: { visible: visible, opacity: opacity, displayNone: displayNone, visibilityHidden: visibilityHidden, zeroSize: zeroSize },
294
+ clipping: clippingInfo(el, rect),
295
+ layout: {
296
+ boxSizing: style.boxSizing,
297
+ position: style.position,
298
+ display: style.display,
299
+ overflowX: style.overflowX,
300
+ overflowY: style.overflowY,
301
+ scrollWidth: el.scrollWidth,
302
+ scrollHeight: el.scrollHeight,
303
+ clientWidth: el.clientWidth,
304
+ clientHeight: el.clientHeight,
305
+ contributesOverflowX: el.scrollWidth > el.clientWidth,
306
+ contributesOverflowY: el.scrollHeight > el.clientHeight,
307
+ minWidth: style.minWidth,
308
+ maxWidth: style.maxWidth,
309
+ minHeight: style.minHeight,
310
+ maxHeight: style.maxHeight,
311
+ aspectRatio: style.aspectRatio,
312
+ flex: isFlexChild ? flexFactsFor(el, parentStyle) : null,
313
+ grid: isGridChild ? gridFactsFor(el, parentStyle) : null,
314
+ },
315
+ };
316
+ facts.push(factObj);
317
+ pushedFact = factObj;
318
+ }
319
+
320
+ if (el.shadowRoot) {
321
+ var hostSel = __selectorOf(el);
322
+ var newDepth = (shadowCtx ? shadowCtx.chainDepth : 0) + 1;
323
+ var shadowKids = el.shadowRoot.children;
324
+ for (var si = 0; si < shadowKids.length; si++) {
325
+ walk(shadowKids[si], frameId, isTop, ancestorFrameIds, { inShadowDom: true, hostSelector: hostSel, chainDepth: newDepth });
326
+ }
327
+ }
328
+
329
+ if (tag === 'IFRAME') {
330
+ var innerDoc = null;
331
+ var contentDocumentReadThrew = false;
332
+ try { innerDoc = el.contentDocument; } catch (e) { innerDoc = null; contentDocumentReadThrew = true; }
333
+ // G6: a THROWN contentDocument read (a genuine failure, e.g. a
334
+ // hostile/broken page patching the accessor) is distinct from a
335
+ // clean null (the ordinary cross-origin-iframe case, which throws
336
+ // nothing and is not a read failure) -- only the throw gets an
337
+ // honest unavailable marker, attached directly to the iframe's own
338
+ // already-pushed fact record so a same-origin subtree that silently
339
+ // went unwalked is never indistinguishable from a genuinely childless
340
+ // or cross-origin iframe.
341
+ if (contentDocumentReadThrew && pushedFact) {
342
+ pushedFact.iframeContentUnavailable = true;
343
+ pushedFact.iframeContentUnavailableReason = 'content-document-read-threw';
344
+ }
345
+ if (innerDoc && innerDoc.body) {
346
+ frameCounter += 1;
347
+ var childFrameId = 'frame-' + frameCounter;
348
+ var childAncestors = ancestorFrameIds.concat([frameId]);
349
+ var innerKids = innerDoc.body.children;
350
+ for (var ii = 0; ii < innerKids.length; ii++) {
351
+ walk(innerKids[ii], childFrameId, false, childAncestors, null);
352
+ }
353
+ }
354
+ return;
355
+ }
356
+
357
+ var children = el.children;
358
+ for (var c = 0; c < children.length; c++) {
359
+ walk(children[c], frameId, isTop, ancestorFrameIds, shadowCtx);
360
+ }
361
+ }
362
+
363
+ var bodyKids = document.body ? document.body.children : [];
364
+ for (var bi = 0; bi < bodyKids.length; bi++) {
365
+ walk(bodyKids[bi], 'frame-0', true, [], null);
366
+ }
367
+
368
+ return { facts: facts, elements: elements, meta: { elementsTruncated: elementsOverflowCount } };
369
+ })()`;
370
+ }
371
+
372
+ // ============================================================================
373
+ // Bridge helpers — exported for hittest.ts to reuse. NONE of these ever
374
+ // assigns to `window` or any other page-observable location: they only
375
+ // ever read a held CDP `RemoteObject` back through its own `objectId`, so a
376
+ // page that predefines a setter for a guessed global name has nothing to
377
+ // observe.
378
+ // ============================================================================
379
+
380
+ /** Resolves each own-property `objectId` of a held CDP object (e.g. the walk's `{ facts, elements }` return-value container) in one `Runtime.getProperties` round trip — how `facts`/`elements` are found inside a held reference rather than a page-observable global. */
381
+ export async function ownPropertyObjectIds(client: CDPClient, objectId: string): Promise<Map<string, string>> {
382
+ const propsResult = (await client.send('Runtime.getProperties', {
383
+ objectId,
384
+ ownProperties: true,
385
+ })) as { result?: Array<{ name: string; value?: { objectId?: string } }> };
386
+ const out = new Map<string, string>();
387
+ for (const prop of propsResult.result ?? []) {
388
+ if (prop.value?.objectId) out.set(prop.name, prop.value.objectId);
389
+ }
390
+ return out;
391
+ }
392
+
393
+ /** Reads a held CDP object out by value via one `Runtime.callFunctionOn({returnByValue:true})` round trip on its OWN `objectId` — the only way a by-value JSON-safe blob (rects/strings/numbers, no DOM handles) leaves a held reference without ever touching a page-observable global. */
394
+ export async function readHeldValue<T>(client: CDPClient, objectId: string): Promise<T | undefined> {
395
+ const res = (await client.send('Runtime.callFunctionOn', {
396
+ objectId,
397
+ functionDeclaration: 'function() { return this; }',
398
+ returnByValue: true,
399
+ })) as { result?: { value?: T } };
400
+ return res.result?.value;
401
+ }
402
+
403
+ /**
404
+ * Resolves each numeric-index own property of a held live-array `objectId`
405
+ * to that element's own `objectId`, in exactly ONE `Runtime.getProperties`
406
+ * round trip. `count` bounds the returned array's length (indices outside
407
+ * `[0, count)` are ignored even if present). This is the primitive every
408
+ * collector's CDP-only identity bridge (geometry, hittest, animation)
409
+ * shares — each resolves the `elements` array of its held
410
+ * `{ facts, elements }` container through this, with no page-observable
411
+ * global anywhere.
412
+ */
413
+ export async function resolveIndexedObjectIds(
414
+ client: CDPClient,
415
+ arrayObjectId: string,
416
+ count: number,
417
+ ): Promise<Array<string | undefined>> {
418
+ const out = new Array<string | undefined>(count).fill(undefined);
419
+ if (count <= 0) return out;
420
+
421
+ const propsResult = (await client.send('Runtime.getProperties', {
422
+ objectId: arrayObjectId,
423
+ ownProperties: true,
424
+ })) as { result?: Array<{ name: string; value?: { objectId?: string } }> };
425
+
426
+ for (const prop of propsResult.result ?? []) {
427
+ if (!/^\d+$/.test(prop.name)) continue;
428
+ const idx = Number(prop.name);
429
+ if (idx < 0 || idx >= count) continue;
430
+ out[idx] = prop.value?.objectId;
431
+ }
432
+ return out;
433
+ }
434
+
435
+ /** Resolves one element's `backendNodeId` from its `objectId` via `DOM.describeNode`. Best-effort — returns `undefined` (never throws) if CDP can't describe the node. */
436
+ export async function describeBackendNodeId(client: CDPClient, objectId: string): Promise<number | undefined> {
437
+ try {
438
+ const res = (await client.send('DOM.describeNode', { objectId })) as { node?: { backendNodeId?: number } };
439
+ return res.node?.backendNodeId;
440
+ } catch {
441
+ return undefined;
442
+ }
443
+ }
444
+
445
+ /** Same 8-number validation as `../../coordinates.js`'s private `asQuad` — reimplemented locally because this file needs `DOM.getContentQuads`'s raw quads read independently of `DOM.getBoxModel` (see {@link readContentQuads}/{@link readBoxModel}'s doc comments for why). */
446
+ function quadFromPoints(points: number[]): Quad {
447
+ if (points.length !== 8) {
448
+ throw new Error(`Expected an 8-number quad (x1,y1,x2,y2,x3,y3,x4,y4), got ${points.length} numbers`);
449
+ }
450
+ return points as Quad;
451
+ }
452
+
453
+ /**
454
+ * Reads ONLY `DOM.getContentQuads` for one element — deliberately NOT
455
+ * bundled with `DOM.getBoxModel` (unlike `../../coordinates.js`'s
456
+ * `getContentQuadBox`). Empirically confirmed against real Chrome: a
457
+ * `display:none` (or otherwise boxless) element makes
458
+ * `DOM.getContentQuads` resolve normally with a real, honest empty array —
459
+ * never throw — while `DOM.getBoxModel` throws "Could not compute box
460
+ * model" for that SAME element. So this call's own throw can only mean the
461
+ * quad read itself failed (invalid/detached node reference, a protocol
462
+ * error) — never that the element has no layout box, which always surfaces
463
+ * as a clean empty array here instead. Callers must let a throw here
464
+ * propagate as a genuine per-element geometry failure (I-4/I-5), not
465
+ * degrade it to the same shape a real empty result produces.
466
+ */
467
+ async function readContentQuads(client: CDPClient, objectId: string): Promise<Quad[]> {
468
+ const res = (await client.send('DOM.getContentQuads', { objectId })) as { quads: number[][] };
469
+ return res.quads.map(quadFromPoints);
470
+ }
471
+
472
+ /**
473
+ * Reads `DOM.getBoxModel` for one element. Callers must only invoke this
474
+ * once {@link readContentQuads} has already proven a real (non-empty) box —
475
+ * see {@link readContentQuads}'s doc comment for why calling this
476
+ * unconditionally would make a genuinely boxless element indistinguishable
477
+ * from an actual box-model read failure.
478
+ */
479
+ async function readBoxModel(client: CDPClient, objectId: string): Promise<GeometryBoxModel> {
480
+ const res = (await client.send('DOM.getBoxModel', { objectId })) as {
481
+ model: { content: number[]; padding: number[]; border: number[]; margin: number[]; width: number; height: number };
482
+ };
483
+ const model = res.model;
484
+ return {
485
+ content: quadFromPoints(model.content),
486
+ padding: quadFromPoints(model.padding),
487
+ border: quadFromPoints(model.border),
488
+ margin: quadFromPoints(model.margin),
489
+ width: model.width,
490
+ height: model.height,
491
+ };
492
+ }
493
+
494
+ /** Union AABB of every quad in `quads`, via `axisAlignedRectFromQuad`. Multi-fragment elements (inline runs wrapped across lines) can return more than one quad — this unions them rather than taking `quads[0]`. */
495
+ function unionRect(quads: Quad[]): Rect {
496
+ let minX = Infinity;
497
+ let minY = Infinity;
498
+ let maxX = -Infinity;
499
+ let maxY = -Infinity;
500
+ for (const quad of quads) {
501
+ const r = axisAlignedRectFromQuad(quad);
502
+ minX = Math.min(minX, r.x);
503
+ minY = Math.min(minY, r.y);
504
+ maxX = Math.max(maxX, r.x + r.width);
505
+ maxY = Math.max(maxY, r.y + r.height);
506
+ }
507
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
508
+ }
509
+
510
+ // ============================================================================
511
+ // Record shapes
512
+ // ============================================================================
513
+
514
+ interface RawFrameFact {
515
+ readonly frameId: string;
516
+ readonly isTopFrame: boolean;
517
+ readonly ancestorFrameIds: string[];
518
+ }
519
+
520
+ interface RawShadowFact {
521
+ readonly inShadowDom: boolean;
522
+ readonly hostSelector?: string;
523
+ readonly chainDepth: number;
524
+ }
525
+
526
+ interface RawStackingContextFact {
527
+ readonly creates: boolean;
528
+ readonly reasons: string[];
529
+ }
530
+
531
+ interface RawVisibilityFact {
532
+ readonly visible: boolean;
533
+ readonly opacity: number;
534
+ readonly displayNone: boolean;
535
+ readonly visibilityHidden: boolean;
536
+ readonly zeroSize: boolean;
537
+ }
538
+
539
+ interface RawClippingFact {
540
+ readonly clippedBy?: string;
541
+ readonly clippedFraction?: number;
542
+ }
543
+
544
+ interface RawFlexFact {
545
+ readonly grow: number;
546
+ readonly shrink: number;
547
+ readonly basis: string;
548
+ readonly alignSelf: string;
549
+ readonly order: number;
550
+ readonly container: {
551
+ readonly direction: string;
552
+ readonly wrap: string;
553
+ readonly justifyContent: string;
554
+ readonly alignItems: string;
555
+ readonly gap: string;
556
+ };
557
+ }
558
+
559
+ interface RawGridFact {
560
+ readonly columnStart: string;
561
+ readonly columnEnd: string;
562
+ readonly rowStart: string;
563
+ readonly rowEnd: string;
564
+ readonly container: {
565
+ readonly templateColumns: string[];
566
+ readonly templateRows: string[];
567
+ readonly columnGap: string;
568
+ readonly rowGap: string;
569
+ };
570
+ }
571
+
572
+ interface RawLayoutFact {
573
+ readonly boxSizing: string;
574
+ readonly position: string;
575
+ readonly display: string;
576
+ readonly overflowX: string;
577
+ readonly overflowY: string;
578
+ readonly scrollWidth: number;
579
+ readonly scrollHeight: number;
580
+ readonly clientWidth: number;
581
+ readonly clientHeight: number;
582
+ readonly contributesOverflowX: boolean;
583
+ readonly contributesOverflowY: boolean;
584
+ readonly minWidth: string;
585
+ readonly maxWidth: string;
586
+ readonly minHeight: string;
587
+ readonly maxHeight: string;
588
+ readonly aspectRatio: string;
589
+ readonly flex: RawFlexFact | null;
590
+ readonly grid: RawGridFact | null;
591
+ }
592
+
593
+ /** Post-cap `grid` fact ({@link capGridFact}'s output) — same shape as {@link RawGridFact} except its track arrays are bounded and carry an explicit dropped-track count per axis, per I-5. */
594
+ interface GridFact extends Omit<RawGridFact, 'container'> {
595
+ readonly container: RawGridFact['container'] & {
596
+ /** Count of real `grid-template-columns` tracks the {@link MAX_GRID_TRACKS} cap dropped (0 when nothing was dropped). */
597
+ readonly columnTracksTruncated: number;
598
+ /** Count of real `grid-template-rows` tracks the {@link MAX_GRID_TRACKS} cap dropped (0 when nothing was dropped). */
599
+ readonly rowTracksTruncated: number;
600
+ };
601
+ }
602
+
603
+ /** Post-cap `layout` fact ({@link capLayoutFact}'s output) — same shape as {@link RawLayoutFact} except `grid` is the post-cap {@link GridFact}. */
604
+ interface LayoutFact extends Omit<RawLayoutFact, 'grid'> {
605
+ readonly grid: GridFact | null;
606
+ }
607
+
608
+ interface RawGeometryFact {
609
+ readonly idx: number;
610
+ readonly tag: string;
611
+ readonly selector?: string;
612
+ readonly domPath: string;
613
+ readonly text?: string;
614
+ readonly frame: RawFrameFact;
615
+ readonly shadow: RawShadowFact | null;
616
+ readonly zIndex: string;
617
+ readonly stackingContext: RawStackingContextFact;
618
+ readonly visibility: RawVisibilityFact;
619
+ readonly clipping: RawClippingFact | null;
620
+ readonly layout: RawLayoutFact;
621
+ /** Present only on an `<iframe>` element's own fact when its `contentDocument` read threw (G6) -- see {@link GeometryElementRecord.iframeContentUnavailable}. */
622
+ readonly iframeContentUnavailable?: boolean;
623
+ readonly iframeContentUnavailableReason?: GeometryIframeUnavailableReason;
624
+ }
625
+
626
+ /** Fixed, factual reason an `<iframe>` element's same-origin subtree was not walked because reading its `contentDocument` itself failed -- never a raw exception message. A clean `null` (the ordinary cross-origin-iframe case) is NOT this: it throws nothing and is not a read failure, so it carries no marker. */
627
+ export type GeometryIframeUnavailableReason = 'content-document-read-threw';
628
+
629
+ export interface GeometryBoxModel {
630
+ readonly content: Quad;
631
+ readonly padding: Quad;
632
+ readonly border: Quad;
633
+ readonly margin: Quad;
634
+ readonly width: number;
635
+ readonly height: number;
636
+ }
637
+
638
+ /** `geometry.json` per-element record shape — mirrors {@link ElementRecord} but requires `backendNodeId: number | null` (never omitted) plus an honest `identityUnresolved` marker when identity resolution failed, the same shape `hittest.ts`/`text.ts` already use (I-3/I-5). */
639
+ export interface GeometryElementRecord extends Omit<ElementRecord, 'backendNodeId'> {
640
+ readonly id: string;
641
+ readonly tag: string;
642
+ readonly domPath: string;
643
+ readonly frame: RawFrameFact;
644
+ readonly shadow: RawShadowFact | null;
645
+ readonly rect: Rect;
646
+ readonly quads: Quad[];
647
+ readonly boxModel: GeometryBoxModel | null;
648
+ readonly zIndex: string;
649
+ readonly stackingContext: RawStackingContextFact;
650
+ readonly visibility: RawVisibilityFact;
651
+ readonly clipping: RawClippingFact | null;
652
+ readonly layout: LayoutFact;
653
+ /** `null` (never an omitted key) when this record's identity did not resolve — see {@link identityUnresolved}. */
654
+ readonly backendNodeId: number | null;
655
+ /** `true` when {@link backendNodeId} is `null` because identity resolution failed (no bridged `objectId`, or `DOM.describeNode` threw/returned nothing) — 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. */
656
+ readonly identityUnresolved?: true;
657
+ /**
658
+ * `true` when this record's per-element CDP geometry read did not fully
659
+ * succeed — see {@link geometryUnavailableReason} for which read failed.
660
+ * When `true`:
661
+ * - `quads-read-threw`/`no-element-object-id`: `DOM.getContentQuads`
662
+ * never ran (no bridged `objectId`) or threw (a genuine read failure,
663
+ * NOT proof of "no layout box" — a real no-box element resolves
664
+ * `DOM.getContentQuads` with a clean empty array instead of throwing).
665
+ * `rect` stays the placeholder zero rect, `quads` stays `[]`,
666
+ * `boxModel` stays `null`, and `visibility.zeroSize`/`visibility.visible`
667
+ * fall back to the JS-side (`getBoundingClientRect()`) facts alone —
668
+ * NEVER the CDP-quad-derived zero-size/invisible shape a genuine
669
+ * empty-quads result earns (I-4/I-5).
670
+ * - `box-model-read-threw`: `DOM.getContentQuads` succeeded with a real
671
+ * (non-empty) box, so `rect`/`quads`/`visibility.zeroSize`/`visibility.visible`
672
+ * ARE a proven observation and are computed normally — only `boxModel`
673
+ * (padding/border/margin detail) stays `null`, honestly marked as an
674
+ * unread detail rather than "this element genuinely has no box".
675
+ *
676
+ * Absent (not `false`) when the per-element geometry read fully succeeded.
677
+ */
678
+ readonly geometryUnavailable?: true;
679
+ /** Present only when {@link geometryUnavailable} is `true`. */
680
+ readonly geometryUnavailableReason?: GeometryElementUnavailableReason;
681
+ /** `true` on an `<iframe>` element's own record when its `contentDocument` read THREW (G6) — its same-origin subtree was never walked because the read itself failed, not because the frame is genuinely cross-origin/childless (that case throws nothing and carries no marker). Absent (not `false`) otherwise, including for a non-iframe element or an iframe whose subtree walked normally or was genuinely cross-origin/empty. */
682
+ readonly iframeContentUnavailable?: true;
683
+ /** Present only when {@link iframeContentUnavailable} is `true`. */
684
+ readonly iframeContentUnavailableReason?: GeometryIframeUnavailableReason;
685
+ }
686
+
687
+ /**
688
+ * Fixed, factual reason a single element's `DOM.getContentQuads`/
689
+ * `DOM.getBoxModel` read did not complete — never a raw exception message.
690
+ * Present only on {@link GeometryElementRecord.geometryUnavailable}.
691
+ *
692
+ * - `no-element-object-id`: this element's index never resolved to a
693
+ * bridged `objectId` at all (the `elements` bridge array had no entry for
694
+ * it), so the quad/box read was never even attempted.
695
+ * - `quads-read-threw`: a bridged `objectId` existed, but `DOM.getContentQuads`
696
+ * itself threw — a genuine failure (invalid/detached node reference, a
697
+ * protocol error), distinct from the clean empty array a real
698
+ * no-layout-box element (e.g. `display:none`) returns without throwing.
699
+ * - `box-model-read-threw`: `DOM.getContentQuads` succeeded with a real,
700
+ * non-empty box, but the follow-up `DOM.getBoxModel` detail read threw —
701
+ * `rect`/`quads`/visibility are still trustworthy; only `boxModel` is
702
+ * unavailable.
703
+ */
704
+ export type GeometryElementUnavailableReason = 'no-element-object-id' | 'quads-read-threw' | 'box-model-read-threw';
705
+
706
+ /** Builds the honest `{ backendNodeId, identityUnresolved }` pair every geometry element-bearing record carries — `null` (never an omitted key) when identity did not resolve, mirroring `hittest.ts`'s/`text.ts`'s `resolvedIdentity` (I-3/I-5). */
707
+ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: number | null; identityUnresolved?: true } {
708
+ return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
709
+ }
710
+
711
+ // ============================================================================
712
+ // String-sanitizing — `layout` (and its nested `flex`/`grid` facts) is a
713
+ // page-controlled bag of computed-CSS-value strings, exactly like
714
+ // selector/domPath/text above. Each string is routed through the single
715
+ // redaction.ts `sanitizeString` authority (capped at CSS_VALUE_MAX),
716
+ // never a private capper. `capTrackList` additionally
717
+ // bounds the grid template-track ARRAY itself (a pathological
718
+ // `grid-template-columns: repeat(100000, 1fr)` would otherwise emit an
719
+ // unbounded array of per-track strings) through the one authoritative
720
+ // array-capper (`capArray`, `redaction.ts`), reporting how many real tracks
721
+ // it dropped rather than silently slicing them away.
722
+ // ============================================================================
723
+
724
+ /** Cap for ordinary computed-CSS-value strings (position/display/min-max sizes/flex+grid provenance). */
725
+ const CSS_VALUE_MAX = 200;
726
+ /** Cap on how many grid template-track strings are kept per axis. */
727
+ const MAX_GRID_TRACKS = 64;
728
+
729
+ function capTrackList(values: readonly string[]): { items: string[]; truncated: number } {
730
+ const { items, truncated } = capArray(values, MAX_GRID_TRACKS);
731
+ return { items: items.map((v) => sanitizeString(v, { max: CSS_VALUE_MAX })), truncated };
732
+ }
733
+
734
+ function capFlexFact(flex: RawFlexFact): RawFlexFact {
735
+ return {
736
+ ...flex,
737
+ basis: sanitizeString(flex.basis, { max: CSS_VALUE_MAX }),
738
+ alignSelf: sanitizeString(flex.alignSelf, { max: CSS_VALUE_MAX }),
739
+ container: {
740
+ ...flex.container,
741
+ direction: sanitizeString(flex.container.direction, { max: CSS_VALUE_MAX }),
742
+ wrap: sanitizeString(flex.container.wrap, { max: CSS_VALUE_MAX }),
743
+ justifyContent: sanitizeString(flex.container.justifyContent, { max: CSS_VALUE_MAX }),
744
+ alignItems: sanitizeString(flex.container.alignItems, { max: CSS_VALUE_MAX }),
745
+ gap: sanitizeString(flex.container.gap, { max: CSS_VALUE_MAX }),
746
+ },
747
+ };
748
+ }
749
+
750
+ function capGridFact(grid: RawGridFact): GridFact {
751
+ const columns = capTrackList(grid.container.templateColumns);
752
+ const rows = capTrackList(grid.container.templateRows);
753
+ return {
754
+ ...grid,
755
+ columnStart: sanitizeString(grid.columnStart, { max: CSS_VALUE_MAX }),
756
+ columnEnd: sanitizeString(grid.columnEnd, { max: CSS_VALUE_MAX }),
757
+ rowStart: sanitizeString(grid.rowStart, { max: CSS_VALUE_MAX }),
758
+ rowEnd: sanitizeString(grid.rowEnd, { max: CSS_VALUE_MAX }),
759
+ container: {
760
+ ...grid.container,
761
+ templateColumns: columns.items,
762
+ templateRows: rows.items,
763
+ columnTracksTruncated: columns.truncated,
764
+ rowTracksTruncated: rows.truncated,
765
+ columnGap: sanitizeString(grid.container.columnGap, { max: CSS_VALUE_MAX }),
766
+ rowGap: sanitizeString(grid.container.rowGap, { max: CSS_VALUE_MAX }),
767
+ },
768
+ };
769
+ }
770
+
771
+ function capLayoutFact(layout: RawLayoutFact): LayoutFact {
772
+ return {
773
+ ...layout,
774
+ boxSizing: sanitizeString(layout.boxSizing, { max: CSS_VALUE_MAX }),
775
+ position: sanitizeString(layout.position, { max: CSS_VALUE_MAX }),
776
+ display: sanitizeString(layout.display, { max: CSS_VALUE_MAX }),
777
+ overflowX: sanitizeString(layout.overflowX, { max: CSS_VALUE_MAX }),
778
+ overflowY: sanitizeString(layout.overflowY, { max: CSS_VALUE_MAX }),
779
+ minWidth: sanitizeString(layout.minWidth, { max: CSS_VALUE_MAX }),
780
+ maxWidth: sanitizeString(layout.maxWidth, { max: CSS_VALUE_MAX }),
781
+ minHeight: sanitizeString(layout.minHeight, { max: CSS_VALUE_MAX }),
782
+ maxHeight: sanitizeString(layout.maxHeight, { max: CSS_VALUE_MAX }),
783
+ aspectRatio: sanitizeString(layout.aspectRatio, { max: CSS_VALUE_MAX }),
784
+ flex: layout.flex ? capFlexFact(layout.flex) : null,
785
+ grid: layout.grid ? capGridFact(layout.grid) : null,
786
+ };
787
+ }
788
+
789
+ // ============================================================================
790
+ // Collector
791
+ // ============================================================================
792
+
793
+ /**
794
+ * Fixed, factual reason the walk evaluate/bridge could not be read (never a
795
+ * raw exception message, which is unbounded/page-influenced) — present
796
+ * only when {@link GeometryJson.available} is `false`.
797
+ *
798
+ * - `walk-facts-unavailable`: the held container came back without a
799
+ * `facts` objectId, OR `readHeldValue()` resolved (without throwing) to
800
+ * `undefined` — either way the required `facts` read did not happen, so
801
+ * both collapse to the one reason rather than two indistinguishable
802
+ * ones.
803
+ * - `walk-meta-unavailable`: `facts` read successfully, but the held
804
+ * container's `meta` objectId was absent or its `readHeldValue()` also
805
+ * resolved to `undefined`. Since `meta` rides the SAME returned object
806
+ * literal as `facts` (`{ facts, elements, meta }`, one evaluate), `meta`
807
+ * going missing right after `facts` succeeded signals a broken bridge
808
+ * read, not "nothing was truncated" — so this collapses the whole
809
+ * collection to unavailable rather than silently stamping
810
+ * `elementsTruncated: 0`.
811
+ */
812
+ export type GeometryUnavailableReason =
813
+ | 'walk-evaluate-threw'
814
+ | 'walk-evaluate-returned-no-object'
815
+ | 'walk-facts-unavailable'
816
+ | 'walk-meta-unavailable';
817
+
818
+ export interface GeometryJson {
819
+ readonly elements: GeometryElementRecord[];
820
+ /** Exact count of real elements the {@link MAX_ELEMENTS} cap chose not to record — always present (0 when the whole reachable tree fit), per I-5. When {@link elementsTruncatedUnknown} is `true` this is a `0` placeholder, not a proven count — see that field. */
821
+ readonly elementsTruncated: number;
822
+ /** `true` when the held `meta` object read successfully (so `available` stays `true`) but its `elementsTruncated` field was missing or not a number — a malformed named field on an otherwise-vouched-for object (Layer 2, I-4/I-5). `elementsTruncated` is then `0` as a placeholder, never a proven observation of "nothing was truncated". Absent (not `false`) when `elementsTruncated` read as a real number. */
823
+ readonly elementsTruncatedUnknown?: true;
824
+ /** `false` when the walk evaluate/bridge itself failed — `elements: []` + `elementsTruncated: 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 zero elements. */
825
+ readonly available: boolean;
826
+ /** Present only when `available` is `false`. */
827
+ readonly unavailableReason?: GeometryUnavailableReason;
828
+ readonly unstableRegions?: string[];
829
+ /** True when the try/finally release of a held CDP bridge object threw — never a diagnosis, never page-observable state. Absent when release succeeded. */
830
+ readonly bridgeCleanupFailed?: boolean;
831
+ }
832
+
833
+ export const collectGeometry: Collector = async (ctx) => {
834
+ let raw: RawGeometryFact[] = [];
835
+ let objectIds: Array<string | undefined> = [];
836
+ let elementsTruncated = 0;
837
+ let elementsTruncatedUnknown = false;
838
+ let bridgeCleanupFailed = false;
839
+ // I-5/I-4: distinguishes "the walk evaluate/bridge failed" from "the page
840
+ // really has zero elements" -- both would otherwise collapse to the same
841
+ // empty `elements` array with elementsTruncated:0, falsely claiming
842
+ // nothing was dropped when in fact nothing was ever read.
843
+ let available = true;
844
+ let unavailableReason: GeometryUnavailableReason | undefined;
845
+ const heldObjectIds: string[] = [];
846
+ try {
847
+ const walkEval = (await ctx.client.send('Runtime.evaluate', {
848
+ expression: SELECTOR_HELPER_JS + buildGeometryWalkScript(MAX_ELEMENTS),
849
+ returnByValue: false,
850
+ })) as { result?: { objectId?: string } };
851
+ const resultObjectId = walkEval.result?.objectId;
852
+
853
+ if (resultObjectId) {
854
+ heldObjectIds.push(resultObjectId);
855
+ const containerIds = await ownPropertyObjectIds(ctx.client, resultObjectId);
856
+ const factsObjectId = containerIds.get('facts');
857
+ const elementsObjectId = containerIds.get('elements');
858
+ const metaObjectId = containerIds.get('meta');
859
+
860
+ // I-4: a missing objectId for a required held property, OR a
861
+ // readHeldValue() that resolves (without throwing) to `undefined`, is
862
+ // the SAME fact as a thrown read -- the property was never actually
863
+ // read. Read both before deciding success/failure so neither can
864
+ // silently fall through to the initialized empty/default artifact.
865
+ let factsValue: RawGeometryFact[] | undefined;
866
+ if (factsObjectId) {
867
+ heldObjectIds.push(factsObjectId);
868
+ factsValue = await readHeldValue<RawGeometryFact[]>(ctx.client, factsObjectId);
869
+ }
870
+
871
+ let metaValue: { elementsTruncated?: unknown } | undefined;
872
+ if (metaObjectId) {
873
+ heldObjectIds.push(metaObjectId);
874
+ metaValue = await readHeldValue<{ elementsTruncated?: unknown }>(ctx.client, metaObjectId);
875
+ }
876
+
877
+ if (factsValue === undefined) {
878
+ available = false;
879
+ unavailableReason = 'walk-facts-unavailable';
880
+ } else if (metaValue === undefined) {
881
+ // `meta` rides the same returned object literal as `facts` -- its
882
+ // absence right after a successful `facts` read signals a broken
883
+ // bridge read, not "the walk truncated nothing". Treat the whole
884
+ // collection as unavailable rather than stamping elementsTruncated:0.
885
+ available = false;
886
+ unavailableReason = 'walk-meta-unavailable';
887
+ } else {
888
+ raw = factsValue;
889
+ // G17 (Layer 2): `meta` itself vouched for (its objectId resolved and
890
+ // readHeldValue() returned a real object above), but its named
891
+ // `elementsTruncated` FIELD may still be malformed (missing, or not a
892
+ // number) -- `?? 0` would silently treat that as a proven "nothing
893
+ // truncated" observation. Only a genuine number is trusted; anything
894
+ // else marks elementsTruncatedUnknown rather than defaulting mute.
895
+ if (typeof metaValue.elementsTruncated === 'number') {
896
+ elementsTruncated = metaValue.elementsTruncated;
897
+ } else {
898
+ elementsTruncated = 0;
899
+ elementsTruncatedUnknown = true;
900
+ }
901
+
902
+ if (elementsObjectId && raw.length > 0) {
903
+ heldObjectIds.push(elementsObjectId);
904
+ objectIds = await resolveIndexedObjectIds(ctx.client, elementsObjectId, raw.length);
905
+ }
906
+ }
907
+ } else {
908
+ available = false;
909
+ unavailableReason = 'walk-evaluate-returned-no-object';
910
+ }
911
+ } catch {
912
+ raw = [];
913
+ available = false;
914
+ unavailableReason = 'walk-evaluate-threw';
915
+ } finally {
916
+ for (const id of heldObjectIds) {
917
+ try {
918
+ await ctx.client.send('Runtime.releaseObject', { objectId: id });
919
+ } catch {
920
+ bridgeCleanupFailed = true;
921
+ }
922
+ }
923
+ }
924
+
925
+ const elements: GeometryElementRecord[] = [];
926
+ if (raw.length > 0) {
927
+ const resolved = await Promise.all(
928
+ raw.map(async (fact): Promise<GeometryElementRecord> => {
929
+ const objectId = objectIds[fact.idx];
930
+ let backendNodeId: number | undefined;
931
+ let quads: Quad[] = [];
932
+ let boxModel: GeometryBoxModel | null = null;
933
+ // I-4/I-5: `quadsUnavailable` drives whether zeroSize/visible below
934
+ // fall back to the JS-side facts alone -- true only when the
935
+ // per-element DOM.getContentQuads read itself never proved
936
+ // anything (never ran, or threw). A DOM.getBoxModel-only failure
937
+ // (quadsUnavailable stays false) does NOT trigger that fallback,
938
+ // since real quads already proved rect/visibility honestly.
939
+ let quadsUnavailable = false;
940
+ let geometryUnavailableReason: GeometryElementUnavailableReason | undefined;
941
+
942
+ if (objectId) {
943
+ backendNodeId = await describeBackendNodeId(ctx.client, objectId);
944
+ try {
945
+ quads = await readContentQuads(ctx.client, objectId);
946
+ } catch {
947
+ // A thrown DOM.getContentQuads read is a genuine failure, NOT
948
+ // proof of "no layout box" -- Chrome resolves that case with a
949
+ // real, non-throwing empty array instead (see readContentQuads's
950
+ // doc comment). Surface it honestly rather than degrading to
951
+ // the same zero-size/invisible shape a real empty result earns.
952
+ quadsUnavailable = true;
953
+ geometryUnavailableReason = 'quads-read-threw';
954
+ }
955
+
956
+ if (!quadsUnavailable && quads.length > 0) {
957
+ // Only attempt DOM.getBoxModel once real quads proved a box
958
+ // exists -- Chrome throws "Could not compute box model" for
959
+ // EVERY boxless node (the identical throw an actual failed
960
+ // read produces), so calling it unconditionally would make a
961
+ // genuine no-layout-box element indistinguishable from a real
962
+ // box-model read failure.
963
+ try {
964
+ boxModel = await readBoxModel(ctx.client, objectId);
965
+ } catch {
966
+ geometryUnavailableReason = 'box-model-read-threw';
967
+ }
968
+ }
969
+ } else {
970
+ // No bridged objectId at all for this element -- the quad/box
971
+ // read was never even attempted, so it must not silently degrade
972
+ // into the same zero-size/invisible shape a genuine no-layout-box
973
+ // element gets.
974
+ quadsUnavailable = true;
975
+ geometryUnavailableReason = 'no-element-object-id';
976
+ }
977
+
978
+ const geometryUnavailable = geometryUnavailableReason !== undefined;
979
+ const rect = quads.length > 0 ? unionRect(quads) : { x: 0, y: 0, width: 0, height: 0 };
980
+ // Only a REAL (non-throwing) quads read can strengthen zeroSize/visible
981
+ // beyond the JS-side facts -- when the quads read itself never proved
982
+ // anything, fall back to fact.visibility (getBoundingClientRect())
983
+ // alone rather than a fabricated CDP-derived zero-size/invisible claim.
984
+ const zeroSize = quadsUnavailable ? fact.visibility.zeroSize : fact.visibility.zeroSize || quads.length === 0;
985
+ const visible = quadsUnavailable ? fact.visibility.visible : fact.visibility.visible && quads.length > 0;
986
+
987
+ return {
988
+ id: `el-${fact.idx}`,
989
+ tag: sanitizeString(fact.tag, { max: 64 }),
990
+ selector: fact.selector !== undefined ? sanitizeString(fact.selector, { max: 300 }) : undefined,
991
+ domPath: sanitizeString(fact.domPath, { max: 500 }),
992
+ ...resolvedIdentity(backendNodeId),
993
+ text: fact.text !== undefined ? sanitizeString(fact.text, { max: 200 }) : undefined,
994
+ frame: fact.frame,
995
+ shadow: fact.shadow
996
+ ? {
997
+ ...fact.shadow,
998
+ hostSelector:
999
+ fact.shadow.hostSelector !== undefined ? sanitizeString(fact.shadow.hostSelector, { max: 300 }) : undefined,
1000
+ }
1001
+ : null,
1002
+ rect,
1003
+ quads,
1004
+ boxModel,
1005
+ zIndex: sanitizeString(fact.zIndex, { max: 20 }),
1006
+ stackingContext: fact.stackingContext,
1007
+ visibility: { ...fact.visibility, visible, zeroSize },
1008
+ clipping: fact.clipping
1009
+ ? {
1010
+ clippedBy:
1011
+ fact.clipping.clippedBy !== undefined ? sanitizeString(fact.clipping.clippedBy, { max: 300 }) : undefined,
1012
+ clippedFraction: fact.clipping.clippedFraction,
1013
+ }
1014
+ : null,
1015
+ layout: capLayoutFact(fact.layout),
1016
+ ...(geometryUnavailable ? { geometryUnavailable: true as const, geometryUnavailableReason } : {}),
1017
+ ...(fact.iframeContentUnavailable
1018
+ ? { iframeContentUnavailable: true as const, iframeContentUnavailableReason: fact.iframeContentUnavailableReason }
1019
+ : {}),
1020
+ };
1021
+ }),
1022
+ );
1023
+ elements.push(...resolved);
1024
+ }
1025
+
1026
+ ctx.write.json('geometry.json', {
1027
+ elements,
1028
+ // Exact count of real elements the MAX_ELEMENTS cap chose not to record
1029
+ // (0 when the whole reachable tree fit) -- always present, never a
1030
+ // silently-omitted field, per I-5.
1031
+ elementsTruncated,
1032
+ ...(elementsTruncatedUnknown ? { elementsTruncatedUnknown: true } : {}),
1033
+ available,
1034
+ ...(unavailableReason ? { unavailableReason } : {}),
1035
+ ...(ctx.unstableRegions.length ? { unstableRegions: [...ctx.unstableRegions] } : {}),
1036
+ ...(bridgeCleanupFailed ? { bridgeCleanupFailed: true } : {}),
1037
+ } satisfies GeometryJson);
1038
+ };