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