@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,905 @@
1
+ /**
2
+ * `forms.json` collector — native and contenteditable form-control facts:
3
+ * value/placeholder line boxes, caret/selection rects, scroll offsets,
4
+ * visible-substring range, validity state/message, label association,
5
+ * autofill state, pseudo-state facts, and (best-effort) native-part
6
+ * dimensions. Page-provided strings and values are preserved up to their
7
+ * structural output caps.
8
+ *
9
+ * Measurement never touches a control's live `value`/`selectionStart`/
10
+ * `selectionEnd`, and never mutates the DOM: native single-line controls
11
+ * (every type except `textarea`) render their value on exactly one line,
12
+ * so value/placeholder line boxes and caret/selection rects are computed
13
+ * from `measureText` glyph-advance widths on a detached `<canvas>` (the
14
+ * same never-appended pattern `text.ts` uses for its baseline metric)
15
+ * combined with the control's own computed box model — the canvas is
16
+ * created but never appended to the document, so it stays detached and
17
+ * is never page-observable. Multiline
18
+ * `textarea` wrapped-line layout (word-wrap/overflow-wrap/CJK/tabs/
19
+ * white-space) is the browser's line-breaking engine, not reproducible
20
+ * from `measureText` widths alone; rather than reimplement it or
21
+ * silently approximate it, a `textarea`'s wrapped-layout facts (value/
22
+ * placeholder line boxes, caret, selection rects) are reported as
23
+ * factually unavailable (`textLayout: { available: false, reason }`)
24
+ * while every non-layout fact (scroll, dimensions, selection indices,
25
+ * validity, label, value/length, rect, pseudoState, autofill) is still
26
+ * emitted. `contenteditable` regions are real DOM, so they use
27
+ * the real `Selection`/`Range.getClientRects()` APIs directly.
28
+ *
29
+ * Control IDENTITY resolution (mapping each walked control to its stable
30
+ * CDP `backendNodeId`, the cross-artifact join key) also never mutates the
31
+ * DOM AND never assigns anything to `window` or any other page-observable
32
+ * location: the walk's return value is a plain in-memory `{ facts,
33
+ * elements }` object, read back purely through CDP's own remote-object
34
+ * identity — `Runtime.evaluate({returnByValue: false})` hands back an
35
+ * `objectId` for that object with zero page visibility into it,
36
+ * `Runtime.getProperties` resolves `facts`'/`elements`'s own `objectId`s,
37
+ * `Runtime.callFunctionOn({returnByValue: true})` reads `facts` out by
38
+ * value, and a second `Runtime.getProperties` on `elements` resolves each
39
+ * matched control's own `objectId` (the CDP identity bridge), which
40
+ * `describeBackendNodeId` (shared with `geometry.ts`/`hittest.ts`) then
41
+ * turns into a `backendNodeId`. A page can predefine a setter for any
42
+ * global name it can guess; it can observe nothing here, because nothing
43
+ * is ever set on it. No DOM mutation and no page-observable global either,
44
+ * so nothing in the emitted `screenshot.png`/`dom.html`/any other baseline
45
+ * collector running concurrently in the same `Promise.all` can ever
46
+ * observe this collector having run.
47
+ */
48
+
49
+ import type { CDPClient } from '../../client.js';
50
+ import type { Collector, ElementRecord } from '../types.js';
51
+ import { capArray, capString, sanitizeString } from '../redaction.js';
52
+ import { describeBackendNodeId } from './geometry.js';
53
+
54
+ /** Hard cap on the rect-array facts (`valueLineBoxes`/`placeholderLines`/`selectionRects`) emitted per control — page-shaped arrays get a factual truncation count for parity with geometry's track cap. */
55
+ const MAX_RECTS = 500;
56
+
57
+ /** Hard cap on controls emitted per snapshot — the single source of truth for the page-side walk's own `MAX_CONTROLS` (interpolated into `FORMS_WALK_EXPRESSION` below) and for the host-side `controlsTruncated` fact, so the two can never drift apart. */
58
+ const MAX_FORMS_CONTROLS = 300;
59
+
60
+ /** The label-association sources this collector recognizes; a page-shaped `source` outside this set normalizes to `'unknown'` (D8b — never spread the raw label object). */
61
+ const LABEL_SOURCE_ALLOWLIST = new Set(['for', 'wrapping', 'aria-label', 'aria-labelledby']);
62
+
63
+ /** Known HTML form-control `type`s, plus this collector's synthetic types (`select`, `textarea`, `contenteditable` — assigned on the page-side walk, not read off a `type` attribute). A page can put anything in a custom element's reported type; anything outside this set is not a real control-type fact and is normalized away. */
64
+ const KNOWN_CONTROL_TYPES = new Set([
65
+ 'text', 'password', 'email', 'tel', 'url', 'number', 'search',
66
+ 'date', 'datetime-local', 'month', 'week', 'time', 'color', 'range',
67
+ 'checkbox', 'radio', 'file', 'hidden', 'submit', 'reset', 'button', 'image',
68
+ 'select', 'textarea', 'contenteditable',
69
+ ]);
70
+
71
+ /** Normalizes a control's `type` to the known allowlist, else `'unknown'`. */
72
+ function normalizeControlType(type: string): string {
73
+ return KNOWN_CONTROL_TYPES.has(type) ? type : 'unknown';
74
+ }
75
+
76
+ interface RawRect {
77
+ readonly x: number;
78
+ readonly y: number;
79
+ readonly width: number;
80
+ readonly height: number;
81
+ }
82
+
83
+ interface RawValidity {
84
+ readonly valid: boolean;
85
+ readonly valueMissing: boolean;
86
+ readonly typeMismatch: boolean;
87
+ readonly patternMismatch: boolean;
88
+ readonly tooLong: boolean;
89
+ readonly tooShort: boolean;
90
+ readonly rangeUnderflow: boolean;
91
+ readonly rangeOverflow: boolean;
92
+ readonly stepMismatch: boolean;
93
+ readonly badInput: boolean;
94
+ readonly customError: boolean;
95
+ readonly message: string;
96
+ }
97
+
98
+ interface RawLabel {
99
+ readonly text: string;
100
+ readonly rect?: RawRect;
101
+ readonly source: 'for' | 'wrapping' | 'aria-label' | 'aria-labelledby';
102
+ }
103
+
104
+ interface RawControlRecord {
105
+ readonly markId: string;
106
+ readonly selector: string;
107
+ readonly tagName: string;
108
+ readonly type: string;
109
+ readonly value: string;
110
+ readonly valuePlaceholder: string | null;
111
+ readonly selectionStart: number | null;
112
+ readonly selectionEnd: number | null;
113
+ readonly scrollLeft: number;
114
+ readonly scrollTop: number;
115
+ readonly clientWidth: number;
116
+ readonly clientHeight: number;
117
+ readonly scrollWidth: number;
118
+ readonly scrollHeight: number;
119
+ readonly disabled: boolean;
120
+ readonly readOnly: boolean;
121
+ readonly required: boolean;
122
+ readonly pattern: string | null;
123
+ readonly checked: boolean | null;
124
+ readonly multiple: boolean;
125
+ readonly validity: RawValidity | null;
126
+ readonly label: RawLabel | null;
127
+ readonly autofilled: boolean;
128
+ readonly pseudoState: {
129
+ readonly focused: boolean;
130
+ readonly hovered: boolean;
131
+ readonly active: boolean;
132
+ readonly checked: boolean | null;
133
+ readonly disabled: boolean;
134
+ readonly readOnly: boolean;
135
+ readonly invalid: boolean;
136
+ readonly focusVisible: boolean;
137
+ };
138
+ readonly rect: RawRect;
139
+ readonly valueLines: RawRect[];
140
+ readonly placeholderLines: RawRect[];
141
+ readonly caretRect: RawRect | null;
142
+ readonly selectionRects: RawRect[];
143
+ readonly visibleRange: { start: number; end: number } | null;
144
+ /** Set only for `textarea` (multiline) controls: wrapped-line layout cannot be faithfully computed from `measureText` alone, so `valueLines`/`placeholderLines`/`caretRect`/`selectionRects`/`visibleRange` are left empty/null and this carries the factual reason. */
145
+ readonly textLayoutUnavailable: string | null;
146
+ /** `true` when the single-line rects above (`valueLines`/`placeholderLines`/`caretRect`/`selectionRects`) used the `1.2 * font-size` line-height heuristic because Chrome's computed `line-height` was the `normal` keyword rather than a used px value; `false` when a real computed line-height backed them; `null` when this control never computes single-line rects at all (textarea/select/contenteditable/checkable/password/file/range/color). */
147
+ readonly lineHeightApproximate: boolean | null;
148
+ readonly isContentEditable: boolean;
149
+ readonly autocomplete: string | null;
150
+ readonly name: string | null;
151
+ readonly id: string | null;
152
+ }
153
+
154
+ /**
155
+ * The page-side walk. Runs once via `Runtime.evaluate({returnByValue:
156
+ * false})` (see the identity note above). Enumerates
157
+ * `input`/`textarea`/`select`/`contenteditable` controls, reading (never
158
+ * writing) each control's value/selection, and measures rendered text
159
+ * geometry from `measureText` glyph-advance widths on a detached,
160
+ * never-appended `<canvas>` plus the control's own box model (native
161
+ * controls) or the real `Selection`/`Range` API (contenteditable).
162
+ */
163
+ const FORMS_WALK_EXPRESSION = `(() => {
164
+ const MAX_CONTROLS = ${MAX_FORMS_CONTROLS};
165
+ const results = [];
166
+ const elements = [];
167
+ let counter = 0;
168
+
169
+ function rectOf(r) { return { x: r.x, y: r.y, width: r.width, height: r.height }; }
170
+
171
+ function cssSelector(el) {
172
+ if (el.id) return '#' + el.id;
173
+ const parts = [];
174
+ let node = el;
175
+ let depth = 0;
176
+ while (node && node.nodeType === 1 && depth < 6) {
177
+ let part = node.tagName.toLowerCase();
178
+ if (node.classList && node.classList.length) {
179
+ part += '.' + Array.from(node.classList).slice(0, 3).join('.');
180
+ }
181
+ const parent = node.parentElement;
182
+ if (parent) {
183
+ const siblings = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
184
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
185
+ }
186
+ parts.unshift(part);
187
+ node = parent;
188
+ depth += 1;
189
+ }
190
+ return parts.join(' > ');
191
+ }
192
+
193
+ function isVisible(el) {
194
+ const style = getComputedStyle(el);
195
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
196
+ if (parseFloat(style.opacity) === 0) return false;
197
+ const rect = el.getBoundingClientRect();
198
+ return rect.width > 0 && rect.height > 0;
199
+ }
200
+
201
+ function labelFor(el) {
202
+ if (el.id) {
203
+ try {
204
+ const lbl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
205
+ if (lbl) return { text: lbl.textContent || '', rect: rectOf(lbl.getBoundingClientRect()), source: 'for' };
206
+ } catch (e) {}
207
+ }
208
+ const wrapping = el.closest('label');
209
+ if (wrapping) return { text: wrapping.textContent || '', rect: rectOf(wrapping.getBoundingClientRect()), source: 'wrapping' };
210
+ const ariaLabel = el.getAttribute('aria-label');
211
+ if (ariaLabel) return { text: ariaLabel, source: 'aria-label' };
212
+ const labelledBy = el.getAttribute('aria-labelledby');
213
+ if (labelledBy) {
214
+ const ids = labelledBy.split(/\\s+/).filter(Boolean);
215
+ const texts = ids.map((id) => { const n = document.getElementById(id); return n ? (n.textContent || '') : ''; }).filter((t) => t.length);
216
+ if (texts.length) return { text: texts.join(' '), source: 'aria-labelledby' };
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function validityOf(el) {
222
+ if (!('validity' in el) || !el.validity) return null;
223
+ const v = el.validity;
224
+ return {
225
+ valid: v.valid,
226
+ valueMissing: !!v.valueMissing,
227
+ typeMismatch: !!v.typeMismatch,
228
+ patternMismatch: !!v.patternMismatch,
229
+ tooLong: !!v.tooLong,
230
+ tooShort: !!v.tooShort,
231
+ rangeUnderflow: !!v.rangeUnderflow,
232
+ rangeOverflow: !!v.rangeOverflow,
233
+ stepMismatch: !!v.stepMismatch,
234
+ badInput: !!v.badInput,
235
+ customError: !!v.customError,
236
+ message: el.validationMessage || '',
237
+ };
238
+ }
239
+
240
+ function isAutofilled(el) {
241
+ try { if (el.matches(':autofill')) return true; } catch (e) {}
242
+ try { if (el.matches(':-webkit-autofill')) return true; } catch (e) {}
243
+ return false;
244
+ }
245
+
246
+ function selectionOf(el) {
247
+ try {
248
+ return { start: el.selectionStart, end: el.selectionEnd };
249
+ } catch (e) {
250
+ return { start: null, end: null };
251
+ }
252
+ }
253
+
254
+ // ---- detached-canvas measurement (native single-line text-like controls) ----
255
+ // A <canvas> is created but NEVER appended anywhere — the same
256
+ // never-appended pattern text.ts uses for its baseline metric — so no
257
+ // MutationObserver on document.body/anywhere else can ever record
258
+ // anything from this measurement. measureText(text).width is the
259
+ // browser's true glyph-advance width for a text run at a given font,
260
+ // which is faithful for a SINGLE-LINE control's rendered text (its value
261
+ // never wraps). Combined with the control's own computed box model, this
262
+ // reproduces value/placeholder line boxes, caret rects, selection rects,
263
+ // and the visible-substring range without ever touching the live DOM.
264
+ let __measureCanvas = null;
265
+ function measureWidth(text, font) {
266
+ if (!__measureCanvas) __measureCanvas = document.createElement('canvas');
267
+ const c = __measureCanvas.getContext('2d');
268
+ if (!c) return 0;
269
+ c.font = font;
270
+ return c.measureText(text).width;
271
+ }
272
+
273
+ function fontStringOf(cs) {
274
+ return cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
275
+ }
276
+
277
+ function contentBoxOf(el, cs) {
278
+ const rect = el.getBoundingClientRect();
279
+ const bl = parseFloat(cs.borderLeftWidth) || 0;
280
+ const bt = parseFloat(cs.borderTopWidth) || 0;
281
+ const br = parseFloat(cs.borderRightWidth) || 0;
282
+ const bb = parseFloat(cs.borderBottomWidth) || 0;
283
+ const pl = parseFloat(cs.paddingLeft) || 0;
284
+ const pt = parseFloat(cs.paddingTop) || 0;
285
+ const pr = parseFloat(cs.paddingRight) || 0;
286
+ const pb = parseFloat(cs.paddingBottom) || 0;
287
+ return {
288
+ left: rect.left + bl + pl,
289
+ top: rect.top + bt + pt,
290
+ width: Math.max(0, rect.width - bl - br - pl - pr),
291
+ height: Math.max(0, rect.height - bt - bb - pt - pb),
292
+ };
293
+ }
294
+
295
+ // lineHeightApproximate is honest per I-4: Chrome's computed line-height
296
+ // for a native control sometimes resolves to the literal keyword normal
297
+ // (not a used px value) rather than a specified length — the common case
298
+ // for a control with no authored line-height — in which case there is no
299
+ // real line-height metric to read, and 1.2 * font-size is a heuristic
300
+ // guess, not a measurement. The flag names exactly that condition so a
301
+ // guessed y/height is never indistinguishable from a metric-backed one.
302
+ function singleLineTop(content, cs) {
303
+ const parsedLineHeight = parseFloat(cs.lineHeight);
304
+ const lineHeightApproximate = !parsedLineHeight;
305
+ const lineHeight = parsedLineHeight || parseFloat(cs.fontSize) * 1.2;
306
+ return { top: content.top + Math.max(0, (content.height - lineHeight) / 2), lineHeight: lineHeight, lineHeightApproximate: lineHeightApproximate };
307
+ }
308
+
309
+ function valueLineRect(el, cs, font, text, scrollLeft) {
310
+ if (!text.length) return [];
311
+ const content = contentBoxOf(el, cs);
312
+ const line = singleLineTop(content, cs);
313
+ return [{ x: content.left - scrollLeft, y: line.top, width: measureWidth(text, font), height: line.lineHeight }];
314
+ }
315
+
316
+ function caretRectOf(el, cs, font, text, offset, scrollLeft) {
317
+ if (offset === null || offset === undefined) return null;
318
+ const content = contentBoxOf(el, cs);
319
+ const line = singleLineTop(content, cs);
320
+ const safeOffset = Math.max(0, Math.min(offset, text.length));
321
+ const x = content.left - scrollLeft + measureWidth(text.slice(0, safeOffset), font);
322
+ return { x: x, y: line.top, width: 1, height: line.lineHeight };
323
+ }
324
+
325
+ function selectionRectsOf(el, cs, font, text, start, end, scrollLeft) {
326
+ if (start === null || end === null || start === end) return [];
327
+ const content = contentBoxOf(el, cs);
328
+ const line = singleLineTop(content, cs);
329
+ const len = text.length;
330
+ const s = Math.max(0, Math.min(start, len));
331
+ const e = Math.max(0, Math.min(end, len));
332
+ const x1 = content.left - scrollLeft + measureWidth(text.slice(0, s), font);
333
+ const x2 = content.left - scrollLeft + measureWidth(text.slice(0, e), font);
334
+ return [{ x: Math.min(x1, x2), y: line.top, width: Math.abs(x2 - x1), height: line.lineHeight }];
335
+ }
336
+
337
+ function visibleRangeSingleLine(font, text, scrollLeft, clipWidth) {
338
+ if (!text.length) return { start: 0, end: 0 };
339
+ const len = text.length;
340
+ let lo = 0, hi = len;
341
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (measureWidth(text.slice(0, mid), font) >= scrollLeft) hi = mid; else lo = mid + 1; }
342
+ const start = lo;
343
+ lo = 0; hi = len;
344
+ while (lo < hi) { const mid = (lo + hi) >> 1; if (measureWidth(text.slice(0, mid), font) >= scrollLeft + clipWidth) hi = mid; else lo = mid + 1; }
345
+ const end = Math.max(start, lo);
346
+ return { start, end };
347
+ }
348
+
349
+ // ---- contenteditable: real Selection/Range ----
350
+ function computeOffsets(root, range) {
351
+ let start = -1, end = -1, count = 0;
352
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
353
+ let node;
354
+ while ((node = walker.nextNode())) {
355
+ if (node === range.startContainer) start = count + range.startOffset;
356
+ if (node === range.endContainer) end = count + range.endOffset;
357
+ count += node.textContent.length;
358
+ }
359
+ return { start: start === -1 ? null : start, end: end === -1 ? null : end };
360
+ }
361
+
362
+ function contentEditableFacts(el) {
363
+ const text = el.textContent || '';
364
+ const range = document.createRange();
365
+ range.selectNodeContents(el);
366
+ const lines = Array.from(range.getClientRects()).map(rectOf);
367
+ let selectionStart = null, selectionEnd = null, caretRect = null, selectionRects = [];
368
+ const sel = window.getSelection();
369
+ if (sel && sel.rangeCount > 0) {
370
+ const r0 = sel.getRangeAt(0);
371
+ if (el.contains(r0.commonAncestorContainer)) {
372
+ const offsets = computeOffsets(el, r0);
373
+ selectionStart = offsets.start;
374
+ selectionEnd = offsets.end;
375
+ const rects2 = Array.from(r0.getClientRects()).map(rectOf);
376
+ if (r0.collapsed) caretRect = rects2[0] || null;
377
+ else selectionRects = rects2;
378
+ }
379
+ }
380
+ return { text, lines, selectionStart, selectionEnd, caretRect, selectionRects };
381
+ }
382
+
383
+ const controls = document.querySelectorAll('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
384
+ // I-5: controlsTotal counts every visible candidate control this walk
385
+ // finds — including those past MAX_CONTROLS, cheaply (no measurement work
386
+ // for them) — so the host can report an honest total-vs-kept truncation
387
+ // fact instead of a silent MAX_CONTROLS drop.
388
+ let controlsTotal = 0;
389
+ for (const el of controls) {
390
+ if (!(el instanceof Element)) continue;
391
+ if (!isVisible(el)) continue;
392
+ controlsTotal += 1;
393
+ if (results.length >= MAX_CONTROLS) continue;
394
+
395
+ const tagName = el.tagName.toLowerCase();
396
+ const isCE = tagName !== 'input' && tagName !== 'textarea' && tagName !== 'select' && !!el.isContentEditable;
397
+ const cs = getComputedStyle(el);
398
+ const rect = rectOf(el.getBoundingClientRect());
399
+ const markId = 'form-' + (counter++);
400
+ elements.push(el);
401
+
402
+ if (tagName === 'select') {
403
+ const selected = el.options && el.selectedIndex >= 0 ? el.options[el.selectedIndex] : null;
404
+ results.push({
405
+ markId, selector: cssSelector(el), tagName, type: 'select',
406
+ value: selected ? (selected.textContent || '') : '', valuePlaceholder: null,
407
+ selectionStart: null, selectionEnd: null,
408
+ scrollLeft: el.scrollLeft || 0, scrollTop: el.scrollTop || 0,
409
+ clientWidth: el.clientWidth, clientHeight: el.clientHeight,
410
+ scrollWidth: el.scrollWidth, scrollHeight: el.scrollHeight,
411
+ disabled: !!el.disabled, readOnly: false, required: !!el.required, pattern: null,
412
+ checked: null, multiple: !!el.multiple,
413
+ validity: validityOf(el), label: labelFor(el), autofilled: false,
414
+ pseudoState: {
415
+ focused: document.activeElement === el, hovered: (function () { try { return el.matches(':hover'); } catch (e) { return false; } })(),
416
+ active: (function () { try { return el.matches(':active'); } catch (e) { return false; } })(),
417
+ checked: null, disabled: !!el.disabled, readOnly: false,
418
+ invalid: (function () { try { return el.matches(':invalid'); } catch (e) { return false; } })(),
419
+ focusVisible: (function () { try { return el.matches(':focus-visible'); } catch (e) { return false; } })(),
420
+ },
421
+ rect, valueLines: [], placeholderLines: [], caretRect: null, selectionRects: [], visibleRange: null,
422
+ textLayoutUnavailable: null, lineHeightApproximate: null,
423
+ isContentEditable: false, autocomplete: el.getAttribute('autocomplete'), name: el.getAttribute('name'), id: el.id || null,
424
+ });
425
+ continue;
426
+ }
427
+
428
+ if (isCE) {
429
+ const ce = contentEditableFacts(el);
430
+ results.push({
431
+ markId, selector: cssSelector(el), tagName, type: 'contenteditable',
432
+ value: ce.text, valuePlaceholder: el.getAttribute('data-placeholder') || null,
433
+ selectionStart: ce.selectionStart, selectionEnd: ce.selectionEnd,
434
+ scrollLeft: el.scrollLeft || 0, scrollTop: el.scrollTop || 0,
435
+ clientWidth: el.clientWidth, clientHeight: el.clientHeight,
436
+ scrollWidth: el.scrollWidth, scrollHeight: el.scrollHeight,
437
+ disabled: false, readOnly: false, required: false, pattern: null,
438
+ checked: null, multiple: false,
439
+ validity: null, label: labelFor(el), autofilled: false,
440
+ pseudoState: {
441
+ focused: document.activeElement === el, hovered: (function () { try { return el.matches(':hover'); } catch (e) { return false; } })(),
442
+ active: (function () { try { return el.matches(':active'); } catch (e) { return false; } })(),
443
+ checked: null, disabled: false, readOnly: false, invalid: false,
444
+ focusVisible: (function () { try { return el.matches(':focus-visible'); } catch (e) { return false; } })(),
445
+ },
446
+ rect, valueLines: ce.lines, placeholderLines: [], caretRect: ce.caretRect, selectionRects: ce.selectionRects,
447
+ visibleRange: null, textLayoutUnavailable: null, lineHeightApproximate: null,
448
+ isContentEditable: true, autocomplete: null, name: null, id: el.id || null,
449
+ });
450
+ continue;
451
+ }
452
+
453
+ const type = tagName === 'textarea' ? 'textarea' : (el.getAttribute('type') || 'text').toLowerCase();
454
+ const isCheckable = type === 'checkbox' || type === 'radio';
455
+ const multiline = tagName === 'textarea';
456
+ let value = '';
457
+ try { value = el.value != null ? String(el.value) : ''; } catch (e) {}
458
+ const placeholder = el.getAttribute ? el.getAttribute('placeholder') : null;
459
+ const sel = isCheckable ? { start: null, end: null } : selectionOf(el);
460
+
461
+ let valueLines = [], placeholderLines = [], caretRect = null, selectionRects = [], visibleRange = null, textLayoutUnavailable = null, lineHeightApproximate = null;
462
+ // type=password is excluded from canvas text-layout measurement because
463
+ // the control renders masking glyphs rather than the raw characters;
464
+ // measuring raw glyph widths would fabricate caret/line geometry. The
465
+ // value itself is still preserved in the emitted record.
466
+ if (!isCheckable && type !== 'password' && type !== 'file' && type !== 'range' && type !== 'color') {
467
+ if (multiline) {
468
+ // Wrapped-line layout for a <textarea> is the browser's
469
+ // line-breaking engine (word-wrap/overflow-wrap/CJK/tabs/
470
+ // white-space) — not reproducible from measureText widths alone.
471
+ // Report it as factually unavailable rather than approximate it.
472
+ textLayoutUnavailable = 'textarea-wrapping-requires-layout';
473
+ } else {
474
+ const font = fontStringOf(cs);
475
+ // Same content-box/line-height condition every single-line rect below
476
+ // is built from — computed once so the I-4 flag can never drift from
477
+ // the geometry it describes.
478
+ lineHeightApproximate = !parseFloat(cs.lineHeight);
479
+ valueLines = valueLineRect(el, cs, font, value, el.scrollLeft || 0);
480
+ if (!value.length && placeholder) placeholderLines = valueLineRect(el, cs, font, placeholder, el.scrollLeft || 0);
481
+ if (document.activeElement === el) {
482
+ caretRect = caretRectOf(el, cs, font, value, sel.start, el.scrollLeft || 0);
483
+ if (sel.start !== sel.end) selectionRects = selectionRectsOf(el, cs, font, value, sel.start, sel.end, el.scrollLeft || 0);
484
+ }
485
+ const padLeft = parseFloat(cs.paddingLeft) || 0;
486
+ const padRight = parseFloat(cs.paddingRight) || 0;
487
+ const clipWidth = Math.max(0, el.clientWidth - padLeft - padRight);
488
+ visibleRange = visibleRangeSingleLine(font, value, el.scrollLeft || 0, clipWidth);
489
+ }
490
+ }
491
+
492
+ results.push({
493
+ markId, selector: cssSelector(el), tagName, type,
494
+ value, valuePlaceholder: placeholder,
495
+ selectionStart: sel.start, selectionEnd: sel.end,
496
+ scrollLeft: el.scrollLeft || 0, scrollTop: el.scrollTop || 0,
497
+ clientWidth: el.clientWidth, clientHeight: el.clientHeight,
498
+ scrollWidth: el.scrollWidth, scrollHeight: el.scrollHeight,
499
+ disabled: !!el.disabled, readOnly: !!el.readOnly, required: !!el.required, pattern: el.getAttribute ? el.getAttribute('pattern') : null,
500
+ checked: isCheckable ? !!el.checked : null, multiple: !!el.multiple,
501
+ validity: validityOf(el), label: labelFor(el), autofilled: isAutofilled(el),
502
+ pseudoState: {
503
+ focused: document.activeElement === el, hovered: (function () { try { return el.matches(':hover'); } catch (e) { return false; } })(),
504
+ active: (function () { try { return el.matches(':active'); } catch (e) { return false; } })(),
505
+ checked: isCheckable ? !!el.checked : null, disabled: !!el.disabled, readOnly: !!el.readOnly,
506
+ invalid: (function () { try { return el.matches(':invalid'); } catch (e) { return false; } })(),
507
+ focusVisible: (function () { try { return el.matches(':focus-visible'); } catch (e) { return false; } })(),
508
+ },
509
+ rect, valueLines, placeholderLines, caretRect, selectionRects, visibleRange, textLayoutUnavailable, lineHeightApproximate,
510
+ isContentEditable: false, autocomplete: el.getAttribute ? el.getAttribute('autocomplete') : null,
511
+ name: el.getAttribute ? el.getAttribute('name') : null, id: el.id || null,
512
+ });
513
+ }
514
+
515
+ // Frame/shadow scope facts (D5): the walk is top-document, non-piercing,
516
+ // so controls inside iframes / shadow roots are absent from this artifact.
517
+ const iframesNotWalked = document.querySelectorAll('iframe').length;
518
+ let shadowRootsNotWalked = 0;
519
+ const allEls = document.querySelectorAll('*');
520
+ for (const e of allEls) { if (e.shadowRoot) shadowRootsNotWalked++; }
521
+ return {
522
+ facts: { records: results, iframesNotWalked: iframesNotWalked, shadowRootsNotWalked: shadowRootsNotWalked, controlsTotal: controlsTotal },
523
+ elements: elements,
524
+ };
525
+ })()`;
526
+
527
+ interface FormsWalkResult {
528
+ readonly records: RawControlRecord[];
529
+ readonly iframesNotWalked: number;
530
+ readonly shadowRootsNotWalked: number;
531
+ /** Total visible candidate controls the walk found, BEFORE the `MAX_CONTROLS` cap — always `>= records.length`; the gap is what `controlsTruncated` (I-5) reports rather than silently dropping. */
532
+ readonly controlsTotal: number;
533
+ }
534
+
535
+ /** 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 FormsJson.available} is `false`. `walk-evaluate-threw` covers `Runtime.evaluate`, the held-container `Runtime.getProperties`, or the held-`facts` `Runtime.callFunctionOn` rejecting outright. `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 read did not happen, so it collapses to one reason rather than two indistinguishable-in-practice ones (mirrors `hittest.ts`/`text.ts`). */
536
+ export type FormsUnavailableReason =
537
+ | 'walk-evaluate-threw'
538
+ | 'walk-evaluate-returned-no-object'
539
+ | 'walk-facts-unavailable'
540
+ /** F17 (I-4/I-5, Layer 2): the held facts object read back fine, but its `records` field was missing/malformed — distinct from `walk-facts-unavailable` (the facts read never happened at all). */
541
+ | 'walk-records-malformed';
542
+
543
+ /** `forms.json` per-control 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). */
544
+ interface FormsElementRecord extends Omit<ElementRecord, 'backendNodeId'> {
545
+ readonly backendNodeId: number | null;
546
+ /** `true` when {@link backendNodeId} is `null` because identity resolution failed (no bridged `objectId`, or `DOM.describeNode`/`describeBackendNodeId` 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. */
547
+ readonly identityUnresolved?: true;
548
+ }
549
+
550
+ /** Builds the honest `{ backendNodeId, identityUnresolved }` pair every control record carries — `null` (never an omitted key) when identity did not resolve, mirroring `hittest.ts`'s `resolvedIdentity` (I-3/I-5). */
551
+ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: number | null; identityUnresolved?: true } {
552
+ return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
553
+ }
554
+
555
+ /** `forms.json`'s on-disk shape. */
556
+ export interface FormsJson {
557
+ readonly controls: FormsElementRecord[];
558
+ readonly coverage: Record<string, unknown>;
559
+ /** `false` when the walk's held `facts` could not be read — `controls: []` with an all-zero `coverage` is then "could not collect", not "genuinely no form controls on the page" (I-4/I-5). Always `true` on a normal run, including one where the page really has no controls. */
560
+ readonly available: boolean;
561
+ /** Present only when `available` is `false`. */
562
+ readonly unavailableReason?: FormsUnavailableReason;
563
+ readonly bridgeCleanupFailed?: boolean;
564
+ }
565
+
566
+ // ============================================================================
567
+ // CDP-only identity bridge — reads the walk's held return value (a plain
568
+ // `{ facts, elements }` object CDP hands back as a `RemoteObject`) purely
569
+ // through `Runtime.getProperties`/`Runtime.callFunctionOn`/
570
+ // `Runtime.releaseObject`. NONE of these ever assigns to `window` or any
571
+ // other page-observable location, so a page that predefines a setter for
572
+ // a guessed global name (the exact reported attack) has nothing to
573
+ // observe: this collector never sets a property on anything the page can
574
+ // see.
575
+ // ============================================================================
576
+
577
+ /** Resolves each own-property `objectId` of a held CDP object in one `Runtime.getProperties` round trip — how `facts`/`elements` are found inside the walk's held result container. */
578
+ async function ownPropertyObjectIds(client: CDPClient, objectId: string): Promise<Map<string, string>> {
579
+ const propsResult = (await client.send('Runtime.getProperties', {
580
+ objectId,
581
+ ownProperties: true,
582
+ })) as { result?: Array<{ name: string; value?: { objectId?: string } }> };
583
+ const out = new Map<string, string>();
584
+ for (const prop of propsResult.result ?? []) {
585
+ if (prop.value?.objectId) out.set(prop.name, prop.value.objectId);
586
+ }
587
+ return out;
588
+ }
589
+
590
+ /** Reads a held CDP object out by value via one `Runtime.callFunctionOn({returnByValue:true})` round trip on its OWN `objectId` — the only way the by-value `facts` blob (JSON-safe: rects/strings/numbers, no DOM handles) leaves the held reference without ever touching a page-observable global. */
591
+ async function readHeldValue<T>(client: CDPClient, objectId: string): Promise<T | undefined> {
592
+ const res = (await client.send('Runtime.callFunctionOn', {
593
+ objectId,
594
+ functionDeclaration: 'function() { return this; }',
595
+ returnByValue: true,
596
+ })) as { result?: { value?: T } };
597
+ return res.result?.value;
598
+ }
599
+
600
+ /**
601
+ * Resolves each numeric-index own property of a held live-array `objectId`
602
+ * (the walk's `elements` handle array) to that control's own `objectId` —
603
+ * the CDP identity bridge every `backendNodeId` resolution in this file
604
+ * depends on, sourced from the walk's held return value rather than a
605
+ * page-observable global. `count` bounds the returned array (indices
606
+ * outside `[0, count)` are ignored even if present).
607
+ */
608
+ async function resolveIndexedObjectIds(
609
+ client: CDPClient,
610
+ arrayObjectId: string,
611
+ count: number,
612
+ ): Promise<Array<string | undefined>> {
613
+ const out = new Array<string | undefined>(count).fill(undefined);
614
+ if (count <= 0) return out;
615
+ const propsResult = (await client.send('Runtime.getProperties', {
616
+ objectId: arrayObjectId,
617
+ ownProperties: true,
618
+ })) as { result?: Array<{ name: string; value?: { objectId?: string } }> };
619
+ for (const prop of propsResult.result ?? []) {
620
+ if (!/^\d+$/.test(prop.name)) continue;
621
+ const idx = Number(prop.name);
622
+ if (idx < 0 || idx >= count) continue;
623
+ out[idx] = prop.value?.objectId;
624
+ }
625
+ return out;
626
+ }
627
+
628
+ export const collectForms: Collector = async (ctx) => {
629
+ // The walk's return value is a plain in-memory `{ facts, elements }`
630
+ // object — never a `window` global, never a live-DOM attribute — so
631
+ // nothing another baseline collector or the `screenshot.png`/`dom.html`
632
+ // capture running concurrently in the same `Promise.all` (see
633
+ // `snapshot.ts`) could ever observe. `Runtime.evaluate({returnByValue:
634
+ // false})` hands that object back as a CDP `RemoteObject` (an `objectId`
635
+ // with zero page visibility into it); every held `objectId` (the
636
+ // container, `facts`, `elements`) is released via `Runtime.releaseObject`
637
+ // in `finally`, unconditionally, so cleanup still runs even when the
638
+ // walk or the bridge rejects. A release failure is recorded as the
639
+ // factual `bridgeCleanupFailed` rather than swallowed; it can NEVER
640
+ // imply a contaminated baseline, because object-id bridge cleanup is
641
+ // entirely CDP-session-scoped remote-object memory (freed when the tab
642
+ // closes regardless) and the baseline walk itself mutates nothing
643
+ // page-observable. A thrown top-level evaluate/bridge read
644
+ // (`Runtime.evaluate`, the held-container `Runtime.getProperties`, or
645
+ // the held-`facts` `Runtime.callFunctionOn`) is caught below and turned
646
+ // into an honest `available: false` artifact rather than propagating —
647
+ // cleanup still runs unconditionally via `finally`. The per-control
648
+ // object-id bridge (identity resolution for each already-read record)
649
+ // is separately best-effort: its own failure still emits the record,
650
+ // with identity encoded explicitly as `backendNodeId: null` +
651
+ // `identityUnresolved: true` (never an omitted key — see
652
+ // `resolvedIdentity`).
653
+ let bridgeCleanupFailed = false;
654
+ let walkValue: FormsWalkResult | undefined;
655
+ let objectIds: Array<string | undefined> = [];
656
+ const heldObjectIds: string[] = [];
657
+ // I-4/I-5: distinguishes "the walk's held `facts` could not be read" from
658
+ // "the page really has zero form controls" — both would otherwise collapse
659
+ // to the same `controls: []` with an all-zero coverage, falsely claiming a
660
+ // genuinely-empty read when in fact the required facts read never
661
+ // happened. Mirrors `hittest.ts`'s `available`/`unavailableReason`.
662
+ let available = true;
663
+ let unavailableReason: FormsUnavailableReason | undefined;
664
+ try {
665
+ const walkEval = (await ctx.client.send('Runtime.evaluate', {
666
+ expression: FORMS_WALK_EXPRESSION,
667
+ returnByValue: false,
668
+ })) as { result?: { objectId?: string } };
669
+ const resultObjectId = walkEval.result?.objectId;
670
+
671
+ if (resultObjectId) {
672
+ heldObjectIds.push(resultObjectId);
673
+ const containerIds = await ownPropertyObjectIds(ctx.client, resultObjectId);
674
+ const factsObjectId = containerIds.get('facts');
675
+ const elementsObjectId = containerIds.get('elements');
676
+
677
+ if (factsObjectId) {
678
+ heldObjectIds.push(factsObjectId);
679
+ walkValue = await readHeldValue<FormsWalkResult>(ctx.client, factsObjectId);
680
+ }
681
+
682
+ // A missing `facts` objectId on the held container and a
683
+ // `readHeldValue()` that resolves to `undefined` without throwing are
684
+ // BOTH "the required facts read did not happen" — neither may fall
685
+ // through to the initialized-empty `raw`/`coverage` defaults below
686
+ // looking identical to a genuinely empty page.
687
+ if (walkValue === undefined) {
688
+ available = false;
689
+ unavailableReason = 'walk-facts-unavailable';
690
+ } else if (!Array.isArray(walkValue.records)) {
691
+ // F17 (I-4/I-5, Layer 2): the held facts object itself read back
692
+ // fine, but its `records` field is missing/malformed on an
693
+ // otherwise-present object — a broken facts contract, NOT "the page
694
+ // has zero form controls". `walkValue?.records ?? []` would
695
+ // silently coerce this into a fabricated empty-success result;
696
+ // treat the whole read as unavailable instead (reusing the same
697
+ // all-zero/empty shape as walk-facts-unavailable below).
698
+ available = false;
699
+ unavailableReason = 'walk-records-malformed';
700
+ walkValue = undefined;
701
+ }
702
+
703
+ const rawLength = walkValue?.records?.length ?? 0;
704
+ if (elementsObjectId && rawLength > 0) {
705
+ heldObjectIds.push(elementsObjectId);
706
+ try {
707
+ objectIds = await resolveIndexedObjectIds(ctx.client, elementsObjectId, rawLength);
708
+ } catch {
709
+ // Best-effort — records are still written, with identity encoded
710
+ // explicitly as backendNodeId: null + identityUnresolved: true
711
+ // (never an omitted key — see resolvedIdentity).
712
+ }
713
+ }
714
+ } else {
715
+ available = false;
716
+ unavailableReason = 'walk-evaluate-returned-no-object';
717
+ }
718
+ } catch {
719
+ // `Runtime.evaluate`, the held-container `Runtime.getProperties`
720
+ // (`ownPropertyObjectIds`), or the held-`facts` `Runtime.callFunctionOn`
721
+ // (`readHeldValue`) rejected outright — reset to the honest-empty state
722
+ // rather than letting the collector crash without ever writing an
723
+ // unavailable artifact (mirrors `hittest.ts`).
724
+ walkValue = undefined;
725
+ objectIds = [];
726
+ available = false;
727
+ unavailableReason = 'walk-evaluate-threw';
728
+ } finally {
729
+ for (const id of heldObjectIds) {
730
+ try {
731
+ await ctx.client.send('Runtime.releaseObject', { objectId: id });
732
+ } catch {
733
+ bridgeCleanupFailed = true;
734
+ }
735
+ }
736
+ }
737
+
738
+ const raw = walkValue?.records ?? [];
739
+
740
+ // F18 (I-4/I-5, Layer 2): each coverage field is validated independently
741
+ // of `records` — a malformed/non-number `controlsTotal`/`iframesNotWalked`/
742
+ // `shadowRootsNotWalked` on an otherwise-valid facts object must surface a
743
+ // malformed marker, not silently coerce into a genuine zero/uncapped fact
744
+ // via `?? 0`/`?? raw.length`. Only evaluated when `walkValue` itself is
745
+ // defined (records valid) — when the whole read failed, the top-level
746
+ // `available`/`unavailableReason` already covers it and these per-field
747
+ // markers would be redundant noise.
748
+ const controlsTotalValid = walkValue !== undefined && typeof walkValue.controlsTotal === 'number';
749
+ const controlsTotal = controlsTotalValid ? (walkValue as FormsWalkResult).controlsTotal : raw.length;
750
+ const iframesNotWalkedValid = walkValue !== undefined && typeof walkValue.iframesNotWalked === 'number';
751
+ const shadowRootsNotWalkedValid = walkValue !== undefined && typeof walkValue.shadowRootsNotWalked === 'number';
752
+
753
+ const coverage = {
754
+ scope: 'top-document' as const,
755
+ iframesNotWalked: iframesNotWalkedValid ? (walkValue as FormsWalkResult).iframesNotWalked : 0,
756
+ ...(walkValue !== undefined && !iframesNotWalkedValid ? { iframesNotWalkedUnavailable: true } : {}),
757
+ shadowRootsNotWalked: shadowRootsNotWalkedValid ? (walkValue as FormsWalkResult).shadowRootsNotWalked : 0,
758
+ ...(walkValue !== undefined && !shadowRootsNotWalkedValid ? { shadowRootsNotWalkedUnavailable: true } : {}),
759
+ ...(walkValue !== undefined && !controlsTotalValid ? { controlsTotalUnavailable: true } : {}),
760
+ // I-5: the top-level MAX_CONTROLS cap silently dropped excess controls
761
+ // before this fact existed. Same convention as the per-field rect-array
762
+ // truncation counts below — present only when the cap actually dropped
763
+ // something and controlsTotal itself is a valid measured number, so an
764
+ // uncapped snapshot's coverage stays exactly as before.
765
+ ...(controlsTotalValid && controlsTotal > raw.length ? { controlsTotal, controlsTruncated: controlsTotal - raw.length } : {}),
766
+ };
767
+
768
+ const controls: FormsElementRecord[] = [];
769
+ // Per-element object IDs (`objectIds`, resolved above) are a SEPARATE
770
+ // remote-object handle per control, distinct from the container/facts/
771
+ // elements-array handles the earlier `finally` already released — each
772
+ // is released here once the loop is done reading through it (unconditionally,
773
+ // even on a mid-loop throw), so no per-control CDP remote-object handle
774
+ // outlives this collector.
775
+ try {
776
+ for (let i = 0; i < raw.length; i += 1) {
777
+ const rec = raw[i];
778
+ const objectId = objectIds[i];
779
+ const backendNodeId = objectId ? await describeBackendNodeId(ctx.client, objectId) : undefined;
780
+ const cappedValue = capString(rec.value);
781
+
782
+ const selector = sanitizeString(rec.selector);
783
+ const placeholderSanitized = rec.valuePlaceholder !== null ? sanitizeString(rec.valuePlaceholder) : null;
784
+
785
+ // Explicit allowlisted validity/label objects (D8b): never spread the
786
+ // page-shaped record — only the known boolean flags + a sanitized
787
+ // message / an allowlisted source survive.
788
+ const validity = rec.validity
789
+ ? {
790
+ valid: !!rec.validity.valid,
791
+ valueMissing: !!rec.validity.valueMissing,
792
+ typeMismatch: !!rec.validity.typeMismatch,
793
+ patternMismatch: !!rec.validity.patternMismatch,
794
+ tooLong: !!rec.validity.tooLong,
795
+ tooShort: !!rec.validity.tooShort,
796
+ rangeUnderflow: !!rec.validity.rangeUnderflow,
797
+ rangeOverflow: !!rec.validity.rangeOverflow,
798
+ stepMismatch: !!rec.validity.stepMismatch,
799
+ badInput: !!rec.validity.badInput,
800
+ customError: !!rec.validity.customError,
801
+ message: sanitizeString(rec.validity.message),
802
+ }
803
+ : null;
804
+
805
+ const label = rec.label
806
+ ? {
807
+ text: sanitizeString(rec.label.text),
808
+ ...(rec.label.rect ? { rect: rec.label.rect } : {}),
809
+ source: LABEL_SOURCE_ALLOWLIST.has(rec.label.source) ? rec.label.source : 'unknown',
810
+ }
811
+ : null;
812
+
813
+ const { items: valueLineBoxes, truncated: valueLineBoxesTruncated } = capArray(rec.valueLines, MAX_RECTS);
814
+ const { items: placeholderLines, truncated: placeholderLinesTruncated } = capArray(rec.placeholderLines, MAX_RECTS);
815
+ const { items: selectionRects, truncated: selectionRectsTruncated } = capArray(rec.selectionRects, MAX_RECTS);
816
+
817
+ const visibleSubstring = rec.visibleRange
818
+ ? {
819
+ start: rec.visibleRange.start,
820
+ end: rec.visibleRange.end,
821
+ text: sanitizeString(rec.value.slice(rec.visibleRange.start, rec.visibleRange.end)),
822
+ }
823
+ : undefined;
824
+
825
+ const record: FormsElementRecord = {
826
+ id: rec.markId,
827
+ selector,
828
+ // objectId missing (no bridge slot) or describeBackendNodeId failing
829
+ // to resolve are BOTH "identity did not resolve" — resolvedIdentity()
830
+ // turns either case into the same honest `backendNodeId: null` +
831
+ // `identityUnresolved: true` shape (I-3/I-5).
832
+ ...resolvedIdentity(backendNodeId),
833
+ type: normalizeControlType(rec.type),
834
+ tagName: sanitizeString(rec.tagName),
835
+ valueLength: rec.value.length,
836
+ text: cappedValue.value,
837
+ value: cappedValue.value,
838
+ ...(cappedValue.capped ? { capped: true } : {}),
839
+ placeholder: placeholderSanitized !== null ? { text: placeholderSanitized, lines: placeholderLines } : undefined,
840
+ ...(placeholderLinesTruncated ? { placeholderLinesTruncated } : {}),
841
+ selectionStart: rec.selectionStart,
842
+ selectionEnd: rec.selectionEnd,
843
+ scroll: { left: rec.scrollLeft, top: rec.scrollTop },
844
+ dimensions: {
845
+ clientWidth: rec.clientWidth,
846
+ clientHeight: rec.clientHeight,
847
+ scrollWidth: rec.scrollWidth,
848
+ scrollHeight: rec.scrollHeight,
849
+ },
850
+ rect: rec.rect,
851
+ valueLineBoxes,
852
+ ...(valueLineBoxesTruncated ? { valueLineBoxesTruncated } : {}),
853
+ caretRect: rec.caretRect,
854
+ selectionRects,
855
+ ...(selectionRectsTruncated ? { selectionRectsTruncated } : {}),
856
+ // Set only for a `textarea`: wrapped-line layout cannot be faithfully
857
+ // computed from `measureText` alone (the browser's line-breaking
858
+ // engine isn't reproducible from glyph-advance widths), so the
859
+ // layout facts above are left empty/null and this names the reason
860
+ // rather than silently approximating them.
861
+ ...(rec.textLayoutUnavailable ? { textLayout: { available: false, reason: rec.textLayoutUnavailable } } : {}),
862
+ // I-4: honest marker for the single-line y/height rects above — `true`
863
+ // when Chrome's computed `line-height` was the `normal` keyword (no
864
+ // real metric to read) and the `1.2 * font-size` heuristic was used
865
+ // instead; omitted entirely for controls that never compute these
866
+ // rects (textarea/select/contenteditable/checkable/password/file/
867
+ // range/color), matching `textLayout` above's N/A-by-omission shape.
868
+ ...(typeof rec.lineHeightApproximate === 'boolean' ? { lineHeightApproximate: rec.lineHeightApproximate } : {}),
869
+ visibleSubstring,
870
+ validity,
871
+ required: rec.required,
872
+ pattern: rec.pattern !== null ? sanitizeString(rec.pattern) : null,
873
+ checked: rec.checked,
874
+ multiple: rec.multiple,
875
+ label,
876
+ autofill: { isAutofilled: rec.autofilled },
877
+ pseudoState: rec.pseudoState,
878
+ // CDP/DOM does not expose native UA-shadow internals for most
879
+ // form-control parts (sliders, spin buttons, calendar pickers) —
880
+ // this object is populated only when a control's native part
881
+ // geometry is independently observable; empty is the honest default.
882
+ nativePartDimensions: {},
883
+ isContentEditable: rec.isContentEditable,
884
+ };
885
+ controls.push(record);
886
+ }
887
+ } finally {
888
+ for (const objectId of objectIds) {
889
+ if (!objectId) continue;
890
+ try {
891
+ await ctx.client.send('Runtime.releaseObject', { objectId });
892
+ } catch {
893
+ bridgeCleanupFailed = true;
894
+ }
895
+ }
896
+ }
897
+
898
+ ctx.write.json('forms.json', {
899
+ controls,
900
+ coverage,
901
+ available,
902
+ ...(unavailableReason ? { unavailableReason } : {}),
903
+ ...(bridgeCleanupFailed ? { bridgeCleanupFailed: true } : {}),
904
+ } satisfies FormsJson);
905
+ };