@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.
- package/bin/capture +32786 -9989
- package/package.json +9 -3
- package/src/capture.ts +86 -163
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -116
- package/src/cdp/bridge/protocol.ts +187 -1
- package/src/cdp/bridge/server.ts +211 -84
- package/src/cdp/bridge/spawn.ts +110 -1
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +35 -4
- package/src/cdp/commands/cdp.ts +266 -69
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +87 -0
- package/src/cdp/commands/measure/check.ts +109 -0
- package/src/cdp/commands/measure/diff.ts +120 -0
- package/src/cdp/commands/measure/explain.ts +133 -0
- package/src/cdp/commands/measure/index.ts +106 -0
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +52 -0
- package/src/cdp/commands/measure/map-layers.ts +59 -0
- package/src/cdp/commands/measure/map-scroll.ts +94 -0
- package/src/cdp/commands/measure/snap.ts +416 -0
- package/src/cdp/commands/measure/sweep.ts +227 -0
- package/src/cdp/commands/motion/index.ts +61 -0
- package/src/cdp/commands/motion/jank.ts +132 -0
- package/src/cdp/commands/motion/mask.ts +92 -0
- package/src/cdp/commands/motion/rec.ts +534 -0
- package/src/cdp/commands/motion/response.ts +76 -0
- package/src/cdp/commands/motion/timeline.ts +152 -0
- 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/coordinates.ts +158 -0
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +76 -26
- package/src/cdp/domains.ts +75 -0
- 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 +1894 -0
- 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/source-map.ts +309 -0
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +87 -0
- package/src/cdp/types.ts +87 -6
- 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 +526 -0
- package/src/output/diff.ts +111 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +987 -0
- package/src/output/selector.ts +185 -0
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +481 -0
- package/src/session/commands.ts +1237 -368
- 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 +302 -34
- 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 -77
- 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
|
+
};
|