@crouton-kit/capture 1.3.7 → 1.3.9

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 (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  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 +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  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/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -0,0 +1,713 @@
1
+ /**
2
+ * `text.json` collector — per-text-bearing-element line boxes (from
3
+ * `Range.getClientRects`), binary-searched wrap points, baseline metrics,
4
+ * writing-mode/bidi rect order, truncation facts, and platform font
5
+ * fallback facts (`CSS.getPlatformFontsForNode`). Text content is preserved
6
+ * up to the shared structural output cap.
7
+ *
8
+ * All measurement happens inside one `Runtime.evaluate` walk of the live
9
+ * page (line boxes via native `Range.getClientRects`, baselines
10
+ * approximated via an offscreen `<canvas>` `measureText` per the design's
11
+ * "canvas measureText only as a supplemental metric, not the rendered
12
+ * truth" — the line rects themselves ARE the rendered truth). The walk
13
+ * never mutates the DOM AND never assigns anything to `window` or any
14
+ * other page-observable location: its return value (a plain in-memory
15
+ * `{ facts, elements }` object, never a global) is read back purely
16
+ * through CDP's own remote-object identity — `Runtime.evaluate({
17
+ * returnByValue: false})` hands back an `objectId` for that object with
18
+ * zero page visibility into it, `Runtime.getProperties` resolves each of
19
+ * `facts`'/`elements`'s own `objectId`s, `Runtime.callFunctionOn({
20
+ * returnByValue: true})` reads `facts` out by value, and a second
21
+ * `Runtime.getProperties` on `elements` resolves each matched element's
22
+ * own `objectId` (the CDP identity bridge). A page can predefine a setter
23
+ * for any global name it can guess; it can observe nothing here, because
24
+ * nothing is ever set on it. Each element's stable CDP `backendNodeId`
25
+ * (the cross-artifact join key) is then resolved via one
26
+ * `DOM.describeNode` per resolved element for both `nodeId` — the only
27
+ * identifier `CSS.getPlatformFontsForNode` accepts — and `backendNodeId`.
28
+ * No `setAttribute`/`removeAttribute` pair and no page-observable global
29
+ * either, so nothing in the emitted `screenshot.png`/`dom.html`/any other
30
+ * baseline collector running concurrently in the same `Promise.all` (see
31
+ * `snapshot.ts`) can ever observe this collector having run.
32
+ */
33
+
34
+ import type { CDPClient } from '../../client.js';
35
+ import type { Collector, ElementRecord } from '../types.js';
36
+ import { capArray, capString, sanitizeString } from '../redaction.js';
37
+
38
+ /** Hard cap on line boxes emitted per text element — `Range.getClientRects` is bounded by real layout, but the array is page-shaped, so this caps it for parity with geometry's track cap; the overflow is a factual `linesTruncated` count. */
39
+ const MAX_TEXT_LINES = 500;
40
+
41
+ /** Hard cap on text-bearing elements emitted per snapshot — the single source of truth for the page-side walk's own `MAX_ELEMENTS` (interpolated into `TEXT_WALK_EXPRESSION` below) and for the host-side `elementsTruncated` fact, so the two can never drift apart. */
42
+ const MAX_TEXT_ELEMENTS = 800;
43
+
44
+ /** CSS `direction` computed-style values — never anything else. */
45
+ const DIRECTION_ALLOWLIST = new Set(['ltr', 'rtl', 'auto']);
46
+ /** CSS `writing-mode` keyword set (modern values; legacy `lr-tb`/`rl-tb`/`tb-rl` deliberately excluded — not emitted by any current browser's `getComputedStyle`). */
47
+ const WRITING_MODE_ALLOWLIST = new Set(['horizontal-tb', 'vertical-rl', 'vertical-lr', 'sideways-rl', 'sideways-lr']);
48
+ /** `bidiOrder` is derived page-side from computed `direction`; it is still a page-controlled value that must be pinned to a known keyword rather than trusted raw. */
49
+ const BIDI_ORDER_ALLOWLIST = new Set(['ltr', 'rtl', 'mixed']);
50
+ /** The three truncation-facts this collector reports; any other page-controlled value normalizes to `'unknown'`. */
51
+ const TRUNCATION_STYLE_ALLOWLIST = new Set(['ellipsis', 'clip', 'none']);
52
+
53
+ /** Normalizes a page-controlled enum-ish computed-style string to a known value, else `'unknown'` — never preserves an arbitrary raw string for a field that should only ever hold one of a fixed set of CSS keywords. */
54
+ function normalizeEnum(value: string, allowlist: ReadonlySet<string>): string {
55
+ return allowlist.has(value) ? value : 'unknown';
56
+ }
57
+
58
+ interface RawLineBox {
59
+ readonly x: number;
60
+ readonly y: number;
61
+ readonly width: number;
62
+ readonly height: number;
63
+ readonly baseline: number | null;
64
+ /** `true` when `baseline` came from the crude `rect.height * 0.2` heuristic (no real per-font descent metric available) rather than the canvas `TextMetrics.actualBoundingBoxDescent` reading — the honest I-4 marker so a derived guess is never mistaken for a font-metric-backed value. Meaningless when `baseline` is `null` (already an honest "unavailable", not an estimate). */
65
+ readonly baselineApproximate: boolean;
66
+ }
67
+
68
+ interface RawTextRecord {
69
+ readonly markId: string;
70
+ readonly selector: string;
71
+ readonly text: string;
72
+ readonly lines: RawLineBox[];
73
+ /** One entry per line boundary (`lineRects.length - 1` entries). A `number` is a measured wrap offset; `null` means the binary search's `Range.setStart`/`setEnd` failed mid-search for that boundary — the search invariant broke, so no offset is emitted at all rather than an exact-looking but unverified guess (I-4). An index past the end of this array (never `null`) means there is no such boundary, i.e. the last line. */
74
+ readonly wrapOffsets: Array<number | null>;
75
+ readonly writingMode: string;
76
+ readonly direction: string;
77
+ readonly bidiOrder: 'ltr' | 'rtl' | 'mixed';
78
+ readonly fontFamily: string;
79
+ readonly fontSize: string;
80
+ readonly fontWeight: string;
81
+ readonly lineHeight: string;
82
+ readonly isContentEditable: boolean;
83
+ readonly truncated: boolean;
84
+ readonly truncationStyle: 'ellipsis' | 'clip' | 'none';
85
+ readonly scrollWidth: number;
86
+ readonly clientWidth: number;
87
+ }
88
+
89
+ /**
90
+ * The page-side walk. Runs once via `Runtime.evaluate({returnByValue:
91
+ * true})`. Finds every visible element whose DIRECT (non-descendant-owned)
92
+ * text-node children carry non-whitespace text — i.e. text-bearing leaf
93
+ * runs, not every ancestor that merely contains text somewhere inside it —
94
+ * so each rendered text run is reported once, not once per ancestor.
95
+ */
96
+ const TEXT_WALK_EXPRESSION = `(() => {
97
+ const MAX_ELEMENTS = ${MAX_TEXT_ELEMENTS};
98
+ const results = [];
99
+ const elements = [];
100
+ let counter = 0;
101
+
102
+ function cssSelector(el) {
103
+ if (el.id) return '#' + el.id;
104
+ const parts = [];
105
+ let node = el;
106
+ let depth = 0;
107
+ while (node && node.nodeType === 1 && depth < 6) {
108
+ let part = node.tagName.toLowerCase();
109
+ if (node.classList && node.classList.length) {
110
+ part += '.' + Array.from(node.classList).slice(0, 3).join('.');
111
+ }
112
+ const parent = node.parentElement;
113
+ if (parent) {
114
+ const siblings = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
115
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
116
+ }
117
+ parts.unshift(part);
118
+ node = parent;
119
+ depth += 1;
120
+ }
121
+ return parts.join(' > ');
122
+ }
123
+
124
+ function isVisible(el) {
125
+ const style = getComputedStyle(el);
126
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
127
+ if (parseFloat(style.opacity) === 0) return false;
128
+ const rect = el.getBoundingClientRect();
129
+ return rect.width > 0 && rect.height > 0;
130
+ }
131
+
132
+ function directTextNodes(el) {
133
+ const out = [];
134
+ for (const child of el.childNodes) {
135
+ if (child.nodeType === 3 && child.textContent && child.textContent.trim().length > 0) {
136
+ out.push(child);
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ let __canvas = null;
143
+ function baselineFor(rect, fontString) {
144
+ if (!__canvas) __canvas = document.createElement('canvas');
145
+ const ctx = __canvas.getContext('2d');
146
+ if (!ctx) return { value: null, approximate: false };
147
+ try {
148
+ ctx.font = fontString;
149
+ const m = ctx.measureText('Hg');
150
+ const hasExactDescent = typeof m.actualBoundingBoxDescent === 'number' && m.actualBoundingBoxDescent > 0;
151
+ const descent = hasExactDescent ? m.actualBoundingBoxDescent : rect.height * 0.2;
152
+ return { value: rect.bottom - descent, approximate: !hasExactDescent };
153
+ } catch (e) {
154
+ return { value: null, approximate: false };
155
+ }
156
+ }
157
+
158
+ function findWrapOffsets(textNode, fullText, lineRects) {
159
+ if (lineRects.length <= 1) return [];
160
+ const offsets = [];
161
+ let searchStart = 0;
162
+ for (let i = 0; i < lineRects.length - 1; i++) {
163
+ const nextTop = lineRects[i + 1].y;
164
+ let lo = searchStart;
165
+ let hi = fullText.length;
166
+ let rangeOpFailed = false;
167
+ while (lo < hi) {
168
+ const mid = (lo + hi) >> 1;
169
+ const r = document.createRange();
170
+ try {
171
+ r.setStart(textNode, mid);
172
+ r.setEnd(textNode, Math.min(mid + 1, fullText.length));
173
+ } catch (e) {
174
+ // A Range.setStart/setEnd failure mid-binary-search breaks the
175
+ // search invariant — any lo/hi bound the loop would still produce
176
+ // is an unverified guess, not a measured wrap point. Abandon the
177
+ // search for THIS boundary rather than mutating hi=mid and
178
+ // continuing, which previously emitted an exact-looking but
179
+ // silently corrupted offset (I-4).
180
+ rangeOpFailed = true;
181
+ break;
182
+ }
183
+ const rects = r.getClientRects();
184
+ const top = rects.length ? rects[0].y : null;
185
+ if (top !== null && top >= nextTop - 1) hi = mid;
186
+ else lo = mid + 1;
187
+ }
188
+ if (rangeOpFailed) {
189
+ // null (never a number) marks this boundary's wrap offset as
190
+ // honestly unavailable — distinct from an out-of-range array index
191
+ // (which means "no such boundary", i.e. the last line), so the host
192
+ // side can tell a corrupted-search boundary apart from a normal one.
193
+ offsets.push(null);
194
+ // searchStart intentionally NOT advanced to a corrupted bound — the
195
+ // next boundary's search must not be seeded from an unverified lo.
196
+ } else {
197
+ offsets.push(lo);
198
+ searchStart = lo;
199
+ }
200
+ }
201
+ return offsets;
202
+ }
203
+
204
+ const candidates = document.querySelectorAll('body, body *');
205
+ // D-C: elementsTotal counts every visible, text-bearing candidate this
206
+ // walk finds — including those past MAX_ELEMENTS, cheaply (no Range/rect
207
+ // work for them) — so the host can report an honest total-vs-kept
208
+ // truncation fact instead of a silent MAX_ELEMENTS drop.
209
+ let elementsTotal = 0;
210
+ for (const el of candidates) {
211
+ if (!(el instanceof Element)) continue;
212
+ if (!isVisible(el)) continue;
213
+ const textNodes = directTextNodes(el);
214
+ if (!textNodes.length) continue;
215
+ elementsTotal += 1;
216
+ if (results.length >= MAX_ELEMENTS) continue;
217
+
218
+ const range = document.createRange();
219
+ range.setStart(textNodes[0], 0);
220
+ const lastNode = textNodes[textNodes.length - 1];
221
+ range.setEnd(lastNode, lastNode.textContent.length);
222
+ const clientRects = Array.from(range.getClientRects());
223
+ if (!clientRects.length) continue;
224
+
225
+ const style = getComputedStyle(el);
226
+ const fontString = style.fontStyle + ' ' + style.fontWeight + ' ' + style.fontSize + ' ' + style.fontFamily;
227
+ const fullText = textNodes.map((n) => n.textContent).join('');
228
+
229
+ const lines = clientRects.map((r) => {
230
+ const b = baselineFor(r, fontString);
231
+ return { x: r.x, y: r.y, width: r.width, height: r.height, baseline: b.value, baselineApproximate: b.approximate };
232
+ });
233
+
234
+ const wrapOffsets = textNodes.length === 1 ? findWrapOffsets(textNodes[0], fullText, clientRects) : [];
235
+
236
+ const dir = style.direction === 'rtl' ? 'rtl' : 'ltr';
237
+ const scrollWidth = el.scrollWidth;
238
+ const clientWidth = el.clientWidth;
239
+ const overflowClip = scrollWidth > clientWidth;
240
+ const truncationStyle = !overflowClip ? 'none' : style.textOverflow === 'ellipsis' ? 'ellipsis' : 'clip';
241
+
242
+ const markId = 'txt-' + (counter++);
243
+ elements.push(el);
244
+
245
+ results.push({
246
+ markId: markId,
247
+ selector: cssSelector(el),
248
+ text: fullText,
249
+ lines: lines,
250
+ wrapOffsets: wrapOffsets,
251
+ writingMode: style.writingMode,
252
+ direction: style.direction,
253
+ bidiOrder: dir,
254
+ fontFamily: style.fontFamily,
255
+ fontSize: style.fontSize,
256
+ fontWeight: style.fontWeight,
257
+ lineHeight: style.lineHeight,
258
+ isContentEditable: !!el.isContentEditable,
259
+ truncated: overflowClip,
260
+ truncationStyle: truncationStyle,
261
+ scrollWidth: scrollWidth,
262
+ clientWidth: clientWidth,
263
+ });
264
+ }
265
+
266
+ // Frame/shadow scope facts (D5): the walk is top-document, non-piercing,
267
+ // so iframe and shadow-DOM text is absent from this artifact. Count them
268
+ // so downstream reads omission as an explicit scope fact, not a negative.
269
+ const iframesNotWalked = document.querySelectorAll('iframe').length;
270
+ let shadowRootsNotWalked = 0;
271
+ const allEls = document.querySelectorAll('*');
272
+ for (const e of allEls) { if (e.shadowRoot) shadowRootsNotWalked++; }
273
+ return {
274
+ facts: { records: results, iframesNotWalked: iframesNotWalked, shadowRootsNotWalked: shadowRootsNotWalked, elementsTotal: elementsTotal },
275
+ elements: elements,
276
+ };
277
+ })()`;
278
+
279
+ interface PlatformFontFact {
280
+ readonly familyName: string;
281
+ readonly isCustomFont: boolean;
282
+ readonly glyphCount?: number;
283
+ }
284
+
285
+ interface TextWalkResult {
286
+ readonly records: RawTextRecord[];
287
+ readonly iframesNotWalked: number;
288
+ readonly shadowRootsNotWalked: number;
289
+ /** Total visible, text-bearing candidates the walk found, BEFORE the `MAX_ELEMENTS` cap — always `>= records.length`; the gap is what `elementsTruncated` (I-5) reports rather than silently dropping. */
290
+ readonly elementsTotal: number;
291
+ }
292
+
293
+ /** 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 TextJson.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`). */
294
+ export type TextUnavailableReason =
295
+ | 'walk-evaluate-threw'
296
+ | 'walk-evaluate-returned-no-object'
297
+ | 'walk-facts-unavailable'
298
+ /** T11 (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). */
299
+ | 'walk-records-malformed';
300
+
301
+ interface DescribedNodeIds {
302
+ readonly nodeId?: number;
303
+ readonly backendNodeId?: number;
304
+ }
305
+
306
+ /**
307
+ * Resolves both the CDP `nodeId` (the only identifier
308
+ * `CSS.getPlatformFontsForNode` accepts — it has no `backendNodeId`/
309
+ * `objectId` variant) and the stable `backendNodeId` (the cross-artifact
310
+ * join key) from one element's `objectId`, via a single `DOM.describeNode`
311
+ * call — no live-DOM marker attribute, no `DOM.getDocument` tree walk.
312
+ * Best-effort: returns `{}` (never throws) if CDP can't describe the node.
313
+ */
314
+ async function describeNodeIds(client: CDPClient, objectId: string): Promise<DescribedNodeIds> {
315
+ try {
316
+ const res = (await client.send('DOM.describeNode', { objectId })) as {
317
+ node?: { nodeId?: number; backendNodeId?: number };
318
+ };
319
+ return { nodeId: res.node?.nodeId, backendNodeId: res.node?.backendNodeId };
320
+ } catch {
321
+ return {};
322
+ }
323
+ }
324
+
325
+ /** Fixed, factual reason the per-element platform-font source (`CSS.getPlatformFontsForNode`) could not be read — never a raw exception message. `platform-fonts-node-id-unresolved` covers both "no bridged `objectId`" and "`DOM.describeNode` didn't return a `nodeId`" (the CDP call was never even attempted); `platform-fonts-read-threw` covers `CSS.getPlatformFontsForNode` itself rejecting. Present only when {@link TextElementRecord.platformFontsAvailable} is `false`. */
326
+ export type PlatformFontsUnavailableReason =
327
+ | 'platform-fonts-node-id-unresolved'
328
+ | 'platform-fonts-read-threw'
329
+ /** T14 (I-4/I-5, Layer 2): `CSS.getPlatformFontsForNode` resolved without throwing, but its `fonts` field was missing/malformed — distinct from a genuine successful read that legitimately found zero fonts. */
330
+ | 'platform-fonts-malformed';
331
+
332
+ /** `text.json` per-element record shape — mirrors {@link ElementRecord} but requires `backendNodeId: number | null` (never omitted) plus an honest `identityUnresolved` marker when identity resolution failed, the same shape `hittest.ts` already uses (I-3/I-5). */
333
+ interface TextElementRecord extends Omit<ElementRecord, 'backendNodeId'> {
334
+ readonly backendNodeId: number | null;
335
+ /** `true` when {@link backendNodeId} is `null` because identity resolution failed (no bridged `objectId`, or `DOM.describeNode` threw/returned nothing) — never omit this alongside a `null` backendNodeId, so a downstream join can never mistake an unresolved record for a resolved one. Absent (not `false`) when identity resolved. */
336
+ readonly identityUnresolved?: true;
337
+ /** `false` only when the platform-font source read could not be attempted or threw — NEVER when the browser genuinely reported zero platform fonts for this element (that is a real `platformFonts: []` with `platformFontsAvailable: true`). Always present (I-5) so "could not read" and "read, genuinely empty" are never the same shape. */
338
+ readonly platformFontsAvailable: boolean;
339
+ /** Present only when {@link platformFontsAvailable} is `false`. */
340
+ readonly platformFontsUnavailableReason?: PlatformFontsUnavailableReason;
341
+ }
342
+
343
+ /** Builds the honest `{ backendNodeId, identityUnresolved }` pair every text-bearing record carries — `null` (never an omitted key) when identity did not resolve, mirroring `hittest.ts`'s `resolvedIdentity` (I-3/I-5). */
344
+ function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: number | null; identityUnresolved?: true } {
345
+ return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
346
+ }
347
+
348
+ /** `text.json`'s on-disk shape. */
349
+ export interface TextJson {
350
+ readonly elements: TextElementRecord[];
351
+ readonly coverage: Record<string, unknown>;
352
+ /** `false` when the walk's held `facts` could not be read — `elements: []` with an all-zero `coverage` is then "could not collect", not "genuinely no text on the page" (I-4/I-5). Always `true` on a normal run, including one where the page really has no text-bearing elements. */
353
+ readonly available: boolean;
354
+ /** Present only when `available` is `false`. */
355
+ readonly unavailableReason?: TextUnavailableReason;
356
+ readonly bridgeCleanupFailed?: boolean;
357
+ }
358
+
359
+ // ============================================================================
360
+ // CDP-only identity bridge — reads the walk's held return value (a plain
361
+ // `{ facts, elements }` object CDP hands back as a `RemoteObject`) purely
362
+ // through `Runtime.getProperties`/`Runtime.callFunctionOn`/
363
+ // `Runtime.releaseObject`. NONE of these ever assigns to `window` or any
364
+ // other page-observable location, so a page that predefines a setter for
365
+ // a guessed global name (the exact reported attack) has nothing to
366
+ // observe: this collector never sets a property on anything the page can
367
+ // see.
368
+ // ============================================================================
369
+
370
+ /** 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. */
371
+ async function ownPropertyObjectIds(client: CDPClient, objectId: string): Promise<Map<string, string>> {
372
+ const propsResult = (await client.send('Runtime.getProperties', {
373
+ objectId,
374
+ ownProperties: true,
375
+ })) as { result?: Array<{ name: string; value?: { objectId?: string } }> };
376
+ const out = new Map<string, string>();
377
+ for (const prop of propsResult.result ?? []) {
378
+ if (prop.value?.objectId) out.set(prop.name, prop.value.objectId);
379
+ }
380
+ return out;
381
+ }
382
+
383
+ /** 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. */
384
+ async function readHeldValue<T>(client: CDPClient, objectId: string): Promise<T | undefined> {
385
+ const res = (await client.send('Runtime.callFunctionOn', {
386
+ objectId,
387
+ functionDeclaration: 'function() { return this; }',
388
+ returnByValue: true,
389
+ })) as { result?: { value?: T } };
390
+ return res.result?.value;
391
+ }
392
+
393
+ /**
394
+ * Resolves each numeric-index own property of a held live-array `objectId`
395
+ * (the walk's `elements` handle array) to that element's own `objectId` —
396
+ * the CDP identity bridge every `backendNodeId` resolution in this file
397
+ * depends on, sourced from the walk's held return value rather than a
398
+ * page-observable global. `count` bounds the returned array (indices
399
+ * outside `[0, count)` are ignored even if present).
400
+ */
401
+ async function resolveIndexedObjectIds(
402
+ client: CDPClient,
403
+ arrayObjectId: string,
404
+ count: number,
405
+ ): Promise<Array<string | undefined>> {
406
+ const out = new Array<string | undefined>(count).fill(undefined);
407
+ if (count <= 0) return out;
408
+ const propsResult = (await client.send('Runtime.getProperties', {
409
+ objectId: arrayObjectId,
410
+ ownProperties: true,
411
+ })) as { result?: Array<{ name: string; value?: { objectId?: string } }> };
412
+ for (const prop of propsResult.result ?? []) {
413
+ if (!/^\d+$/.test(prop.name)) continue;
414
+ const idx = Number(prop.name);
415
+ if (idx < 0 || idx >= count) continue;
416
+ out[idx] = prop.value?.objectId;
417
+ }
418
+ return out;
419
+ }
420
+
421
+ export const collectText: Collector = async (ctx) => {
422
+ // The walk's return value is a plain in-memory `{ facts, elements }`
423
+ // object — never a `window` global, never a live-DOM attribute — so
424
+ // nothing another baseline collector or the `screenshot.png`/`dom.html`
425
+ // capture running concurrently in the same `Promise.all` (see
426
+ // `snapshot.ts`) could ever observe. `Runtime.evaluate({returnByValue:
427
+ // false})` hands that object back as a CDP `RemoteObject` (an `objectId`
428
+ // with zero page visibility into it); every held `objectId` (the
429
+ // container, `facts`, `elements`) is released via `Runtime.releaseObject`
430
+ // in `finally`, unconditionally, so cleanup still runs even when the
431
+ // walk or the bridge rejects. A release failure is recorded as the
432
+ // factual `bridgeCleanupFailed` rather than swallowed, but — unlike the
433
+ // old window-global side channel — it can NEVER imply a contaminated
434
+ // baseline: there is no page-observable state left to leak, only
435
+ // CDP-session-scoped remote-object memory that is freed when the tab
436
+ // closes. A thrown top-level evaluate/bridge read (`Runtime.evaluate`,
437
+ // the held-container `Runtime.getProperties`, or the held-`facts`
438
+ // `Runtime.callFunctionOn`) is caught below and turned into an honest
439
+ // `available: false` artifact rather than propagating — cleanup still
440
+ // runs unconditionally via `finally`. The per-element object-id bridge
441
+ // (identity resolution for each already-read record) is separately
442
+ // best-effort: its own failure still emits the record, with identity
443
+ // encoded explicitly as `backendNodeId: null` + `identityUnresolved: true`
444
+ // (never an omitted key — see `resolvedIdentity`).
445
+ let bridgeCleanupFailed = false;
446
+ let walkValue: TextWalkResult | undefined;
447
+ let objectIds: Array<string | undefined> = [];
448
+ const heldObjectIds: string[] = [];
449
+ // I-4/I-5: distinguishes "the walk's held `facts` could not be read" from
450
+ // "the page really has zero text-bearing elements" — both would otherwise
451
+ // collapse to the same `elements: []` with an all-zero coverage, falsely
452
+ // claiming a genuinely-empty read when in fact the required facts read
453
+ // never happened. Mirrors `hittest.ts`'s `available`/`unavailableReason`.
454
+ let available = true;
455
+ let unavailableReason: TextUnavailableReason | undefined;
456
+ try {
457
+ const walkEval = (await ctx.client.send('Runtime.evaluate', {
458
+ expression: TEXT_WALK_EXPRESSION,
459
+ returnByValue: false,
460
+ })) as { result?: { objectId?: string } };
461
+ const resultObjectId = walkEval.result?.objectId;
462
+
463
+ if (resultObjectId) {
464
+ heldObjectIds.push(resultObjectId);
465
+ const containerIds = await ownPropertyObjectIds(ctx.client, resultObjectId);
466
+ const factsObjectId = containerIds.get('facts');
467
+ const elementsObjectId = containerIds.get('elements');
468
+
469
+ if (factsObjectId) {
470
+ heldObjectIds.push(factsObjectId);
471
+ walkValue = await readHeldValue<TextWalkResult>(ctx.client, factsObjectId);
472
+ }
473
+
474
+ // A missing `facts` objectId on the held container and a
475
+ // `readHeldValue()` that resolves to `undefined` without throwing are
476
+ // BOTH "the required facts read did not happen" — neither may fall
477
+ // through to the initialized-empty `raw`/`coverage` defaults below
478
+ // looking identical to a genuinely empty page.
479
+ if (walkValue === undefined) {
480
+ available = false;
481
+ unavailableReason = 'walk-facts-unavailable';
482
+ } else if (!Array.isArray(walkValue.records)) {
483
+ // T11 (I-4/I-5, Layer 2): the held facts object itself read back
484
+ // fine, but its `records` field is missing/malformed on an
485
+ // otherwise-present object — a broken facts contract, NOT "the page
486
+ // has zero text-bearing elements". `walkValue?.records ?? []`
487
+ // would silently coerce this into a fabricated empty-success
488
+ // result; treat the whole read as unavailable instead (reusing the
489
+ // same all-zero/empty shape as walk-facts-unavailable below).
490
+ available = false;
491
+ unavailableReason = 'walk-records-malformed';
492
+ walkValue = undefined;
493
+ }
494
+
495
+ const rawLength = walkValue?.records?.length ?? 0;
496
+ if (elementsObjectId && rawLength > 0) {
497
+ heldObjectIds.push(elementsObjectId);
498
+ try {
499
+ objectIds = await resolveIndexedObjectIds(ctx.client, elementsObjectId, rawLength);
500
+ } catch {
501
+ // Best-effort — records are still written, with identity encoded
502
+ // explicitly as backendNodeId: null + identityUnresolved: true
503
+ // (never an omitted key — see resolvedIdentity).
504
+ }
505
+ }
506
+ } else {
507
+ available = false;
508
+ unavailableReason = 'walk-evaluate-returned-no-object';
509
+ }
510
+ } catch {
511
+ // `Runtime.evaluate`, the held-container `Runtime.getProperties`
512
+ // (`ownPropertyObjectIds`), or the held-`facts` `Runtime.callFunctionOn`
513
+ // (`readHeldValue`) rejected outright — reset to the honest-empty state
514
+ // rather than letting the collector crash without ever writing an
515
+ // unavailable artifact (mirrors `hittest.ts`).
516
+ walkValue = undefined;
517
+ objectIds = [];
518
+ available = false;
519
+ unavailableReason = 'walk-evaluate-threw';
520
+ } finally {
521
+ for (const id of heldObjectIds) {
522
+ try {
523
+ await ctx.client.send('Runtime.releaseObject', { objectId: id });
524
+ } catch {
525
+ bridgeCleanupFailed = true;
526
+ }
527
+ }
528
+ }
529
+
530
+ const raw = walkValue?.records ?? [];
531
+
532
+ // T12 (I-4/I-5, Layer 2): each coverage field is validated independently
533
+ // of `records` — a malformed/non-number `elementsTotal`/`iframesNotWalked`/
534
+ // `shadowRootsNotWalked` on an otherwise-valid facts object must surface a
535
+ // malformed marker, not silently coerce into a genuine zero/uncapped fact
536
+ // via `?? 0`/`?? raw.length`. Only evaluated when `walkValue` itself is
537
+ // defined (records valid) — when the whole read failed, the top-level
538
+ // `available`/`unavailableReason` already covers it and these per-field
539
+ // markers would be redundant noise.
540
+ const elementsTotalValid = walkValue !== undefined && typeof walkValue.elementsTotal === 'number';
541
+ const elementsTotal = elementsTotalValid ? (walkValue as TextWalkResult).elementsTotal : raw.length;
542
+ const iframesNotWalkedValid = walkValue !== undefined && typeof walkValue.iframesNotWalked === 'number';
543
+ const shadowRootsNotWalkedValid = walkValue !== undefined && typeof walkValue.shadowRootsNotWalked === 'number';
544
+
545
+ const coverage = {
546
+ scope: 'top-document' as const,
547
+ iframesNotWalked: iframesNotWalkedValid ? (walkValue as TextWalkResult).iframesNotWalked : 0,
548
+ ...(walkValue !== undefined && !iframesNotWalkedValid ? { iframesNotWalkedUnavailable: true } : {}),
549
+ shadowRootsNotWalked: shadowRootsNotWalkedValid ? (walkValue as TextWalkResult).shadowRootsNotWalked : 0,
550
+ ...(walkValue !== undefined && !shadowRootsNotWalkedValid ? { shadowRootsNotWalkedUnavailable: true } : {}),
551
+ ...(walkValue !== undefined && !elementsTotalValid ? { elementsTotalUnavailable: true } : {}),
552
+ // I-5: the top-level MAX_ELEMENTS cap silently dropped excess elements
553
+ // before this fact existed. Same convention as the per-element
554
+ // `linesTruncated` count below — present only when the cap actually
555
+ // dropped something and elementsTotal itself is a valid measured number,
556
+ // so an uncapped snapshot's coverage stays exactly as before.
557
+ ...(elementsTotalValid && elementsTotal > raw.length ? { elementsTotal, elementsTruncated: elementsTotal - raw.length } : {}),
558
+ };
559
+
560
+ const elements: TextElementRecord[] = [];
561
+ // Per-element object IDs (`objectIds`, resolved above) are a SEPARATE
562
+ // remote-object handle per element, distinct from the container/facts/
563
+ // elements-array handles the earlier `finally` already released — each
564
+ // is released here once the loop is done reading through it (unconditionally,
565
+ // even on a mid-loop throw), so no per-element CDP remote-object handle
566
+ // outlives this collector.
567
+ try {
568
+ for (let i = 0; i < raw.length; i += 1) {
569
+ const rec = raw[i];
570
+ const objectId = objectIds[i];
571
+
572
+ let backendNodeId: number | undefined;
573
+ let platformFonts: PlatformFontFact[] = [];
574
+ // Raw (pre-cap) platform font family names, kept only for the
575
+ // `fallbackUsed` comparison below. A capped family name could otherwise
576
+ // turn a real substring match against the raw CSS `fontFamily` into a
577
+ // false fallback report. Only the bounded `platformFonts` facts leave
578
+ // this function.
579
+ let rawPlatformFontFamilies: string[] = [];
580
+ // I-4/I-5: `platformFontsAvailable` distinguishes "the platform-font
581
+ // source was never actually read" (no bridged identity, `nodeId`
582
+ // unresolved, or `CSS.getPlatformFontsForNode` threw) from "it was read
583
+ // and genuinely returned zero fonts" — both would otherwise collapse to
584
+ // the same empty `platformFonts: []` / `fallbackUsed: false` shape,
585
+ // over-claiming a fallback-font observation the read never made.
586
+ let platformFontsAvailable = false;
587
+ let platformFontsUnavailableReason: PlatformFontsUnavailableReason | undefined =
588
+ 'platform-fonts-node-id-unresolved';
589
+ if (objectId) {
590
+ const described = await describeNodeIds(ctx.client, objectId);
591
+ backendNodeId = described.backendNodeId;
592
+ if (described.nodeId !== undefined) {
593
+ try {
594
+ const fontsResult = (await ctx.client.send('CSS.getPlatformFontsForNode', {
595
+ nodeId: described.nodeId,
596
+ })) as { fonts?: PlatformFontFact[] };
597
+ if (!Array.isArray(fontsResult.fonts)) {
598
+ // T14 (I-4/I-5, Layer 2): a missing/malformed `fonts` field on an
599
+ // otherwise-successful CDP response is NOT the same fact as
600
+ // "genuinely read, zero platform fonts" — `fonts ?? []` would
601
+ // silently fabricate a zero-font observation while still
602
+ // claiming platformFontsAvailable:true. Mark it malformed
603
+ // instead of coercing to the benign empty-array default.
604
+ platformFontsUnavailableReason = 'platform-fonts-malformed';
605
+ } else {
606
+ rawPlatformFontFamilies = fontsResult.fonts.map((font) => font.familyName ?? '');
607
+ // Explicit allowlisted object — never spread the page-shaped font
608
+ // fact (D8b); only the three known keys are surfaced.
609
+ platformFonts = fontsResult.fonts.map((font) => {
610
+ const fact: PlatformFontFact = {
611
+ familyName: sanitizeString(font.familyName),
612
+ isCustomFont: !!font.isCustomFont,
613
+ };
614
+ return typeof font.glyphCount === 'number' ? { ...fact, glyphCount: font.glyphCount } : fact;
615
+ });
616
+ platformFontsAvailable = true;
617
+ platformFontsUnavailableReason = undefined;
618
+ }
619
+ } catch {
620
+ // Platform font inventory read failed — leave platformFontsAvailable
621
+ // false with the read-threw reason rather than silently keeping the
622
+ // initialized-empty platformFonts/rawPlatformFontFamilies looking
623
+ // like a genuine zero-fonts observation.
624
+ platformFontsUnavailableReason = 'platform-fonts-read-threw';
625
+ }
626
+ }
627
+ }
628
+
629
+ const cappedText = capString(rec.text);
630
+ const selector = sanitizeString(rec.selector);
631
+ // Raw-to-raw: compares each uncapped platform font family name
632
+ // against the raw CSS `fontFamily` string — never the bounded
633
+ // `platformFonts` facts, so capping a font name can never turn a real
634
+ // match into a false fallback report. `null` (never
635
+ // `false`) when the platform-font source itself was never read —
636
+ // otherwise a read failure is indistinguishable from a genuine
637
+ // "no fallback font was used" observation (I-4/I-5).
638
+ const fallbackUsed = platformFontsAvailable
639
+ ? rawPlatformFontFamilies.some(
640
+ (familyName) => familyName && !rec.fontFamily.toLowerCase().includes(familyName.toLowerCase()),
641
+ )
642
+ : null;
643
+
644
+ const { items: cappedLines, truncated: linesTruncated } = capArray(rec.lines, MAX_TEXT_LINES);
645
+
646
+ const record: TextElementRecord = {
647
+ id: rec.markId,
648
+ selector,
649
+ // objectId missing (no bridge slot) or DOM.describeNode failing to
650
+ // resolve a backendNodeId are BOTH "identity did not resolve" —
651
+ // resolvedIdentity() turns either case into the same honest
652
+ // `backendNodeId: null` + `identityUnresolved: true` shape (I-3/I-5).
653
+ ...resolvedIdentity(backendNodeId),
654
+ text: cappedText.value,
655
+ textLength: rec.text.length,
656
+ ...(cappedText.capped ? { capped: true } : {}),
657
+ lines: cappedLines.map((line, index) => {
658
+ const wrapOffset = rec.wrapOffsets[index];
659
+ return {
660
+ index,
661
+ rect: { x: line.x, y: line.y, width: line.width, height: line.height },
662
+ baseline: line.baseline,
663
+ baselineApproximate: !!line.baselineApproximate,
664
+ wrapAfterChar: typeof wrapOffset === 'number' ? wrapOffset : undefined,
665
+ // I-4: `null` (as opposed to an out-of-range/absent index) means
666
+ // the Range-op binary search for THIS boundary failed mid-search
667
+ // — the offset is honestly unavailable, never a corrupted guess
668
+ // stamped exact. Omitted entirely when there is no such boundary.
669
+ ...(wrapOffset === null ? { wrapAfterCharUnavailable: true } : {}),
670
+ };
671
+ }),
672
+ lineCount: rec.lines.length,
673
+ ...(linesTruncated ? { linesTruncated } : {}),
674
+ writingMode: normalizeEnum(rec.writingMode, WRITING_MODE_ALLOWLIST),
675
+ direction: normalizeEnum(rec.direction, DIRECTION_ALLOWLIST),
676
+ bidiOrder: normalizeEnum(rec.bidiOrder, BIDI_ORDER_ALLOWLIST),
677
+ font: {
678
+ family: sanitizeString(rec.fontFamily),
679
+ size: sanitizeString(rec.fontSize),
680
+ weight: sanitizeString(rec.fontWeight),
681
+ lineHeight: sanitizeString(rec.lineHeight),
682
+ },
683
+ platformFonts,
684
+ platformFontsAvailable,
685
+ ...(platformFontsUnavailableReason ? { platformFontsUnavailableReason } : {}),
686
+ fallbackUsed,
687
+ isContentEditable: rec.isContentEditable,
688
+ truncated: rec.truncated,
689
+ truncationStyle: normalizeEnum(rec.truncationStyle, TRUNCATION_STYLE_ALLOWLIST),
690
+ scrollWidth: rec.scrollWidth,
691
+ clientWidth: rec.clientWidth,
692
+ };
693
+ elements.push(record);
694
+ }
695
+ } finally {
696
+ for (const objectId of objectIds) {
697
+ if (!objectId) continue;
698
+ try {
699
+ await ctx.client.send('Runtime.releaseObject', { objectId });
700
+ } catch {
701
+ bridgeCleanupFailed = true;
702
+ }
703
+ }
704
+ }
705
+
706
+ ctx.write.json('text.json', {
707
+ elements,
708
+ coverage,
709
+ available,
710
+ ...(unavailableReason ? { unavailableReason } : {}),
711
+ ...(bridgeCleanupFailed ? { bridgeCleanupFailed: true } : {}),
712
+ } satisfies TextJson);
713
+ };