@crouton-kit/capture 1.3.8 → 1.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `animation.json` collector — the settled-capture animation inventory:
|
|
3
|
+
* `document.getAnimations({subtree:true})` target, duration, iteration
|
|
4
|
+
* count, run state, infinite-loop flag, frozen status, and (the
|
|
5
|
+
* cross-artifact join key) each target's `backendNodeId`.
|
|
6
|
+
*
|
|
7
|
+
* Identity (D3): to attach `backendNodeId` to an animation's target this
|
|
8
|
+
* collector runs its OWN inventory `Runtime.evaluate` that enumerates
|
|
9
|
+
* `getAnimations()` AND, in that SAME script, collects each element
|
|
10
|
+
* `a.effect.target` into a page-side array, recording the target's index
|
|
11
|
+
* on the record as `targetIdx`. Resolving the targets in a SECOND
|
|
12
|
+
* independent `getAnimations()` call would risk an enumeration-order
|
|
13
|
+
* mismatch (a finished animation dropping out, or reordering between the
|
|
14
|
+
* two calls), so enumeration and the identity collection MUST share one
|
|
15
|
+
* evaluate. The walk never assigns anything to `window` or any other
|
|
16
|
+
* page-observable location: its return value is a plain in-memory
|
|
17
|
+
* `{ facts, elements }` object, read back purely through CDP's own
|
|
18
|
+
* remote-object identity — the exact same CDP-only identity bridge
|
|
19
|
+
* geometry.ts uses and exports (`ownPropertyObjectIds`/`readHeldValue`/
|
|
20
|
+
* `resolveIndexedObjectIds`/`describeBackendNodeId`), reused here rather
|
|
21
|
+
* than duplicated. A page can predefine a setter for any global name it can
|
|
22
|
+
* guess; it can observe nothing here, because nothing is ever set on it.
|
|
23
|
+
* Every held container/facts/elements `objectId` is released via
|
|
24
|
+
* `Runtime.releaseObject` in `finally` (unconditionally) — a release
|
|
25
|
+
* failure is recorded as the factual `bridgeCleanupFailed`, but can never
|
|
26
|
+
* imply a contaminated baseline: there is no page-observable state left to
|
|
27
|
+
* leak, only CDP-session-scoped remote-object memory freed when the tab
|
|
28
|
+
* closes.
|
|
29
|
+
*
|
|
30
|
+
* The inventory script's `facts` array has the same bare record shape as
|
|
31
|
+
* settle.ts's `collectAnimationEvidence` (`selector, animationName,
|
|
32
|
+
* durationMs, iterationCount, infinite, playState`) plus the `targetIdx`
|
|
33
|
+
* bridge index; this collector adds `id`, `frozen`, `backendNodeId`, and a
|
|
34
|
+
* `coverage` scope fact on top. By the time this collector runs,
|
|
35
|
+
* `snapshot.ts` has already applied `--freeze-animations` (if requested)
|
|
36
|
+
* via settle.ts's CDP freeze helpers — this collector only reads the
|
|
37
|
+
* resulting inventory, never freezes.
|
|
38
|
+
*
|
|
39
|
+
* `frozen` honesty (I-4): `--freeze-animations` pauses EVERY animation
|
|
40
|
+
* that existed at freeze time via settle.ts's own, separate
|
|
41
|
+
* `getAnimations()` walk, and CDP's `Animation.setPlaybackRate` override
|
|
42
|
+
* is deliberately invisible to JS (confirmed: it never touches the
|
|
43
|
+
* WAAPI-visible `Animation.playbackRate`). By the time THIS collector
|
|
44
|
+
* re-enumerates independently, an animation the freeze forced from
|
|
45
|
+
* `'running'` to `'paused'` is indistinguishable, by playState alone, from
|
|
46
|
+
* one the page had already paused (or created already-paused) before or
|
|
47
|
+
* without the freeze ever touching it — WAAPI exposes no pause-history.
|
|
48
|
+
* `frozen` is therefore honestly scoped as "paused while freeze was
|
|
49
|
+
* requested" (co-occurrence), never as proof this specific animation was
|
|
50
|
+
* forced — see the field doc below. `report.freezeRequested` restates
|
|
51
|
+
* `ctx.freezeAnimations` so a reader of `animation.json` alone (without
|
|
52
|
+
* `meta.json`) can tell "freeze wasn't requested" apart from "freeze was
|
|
53
|
+
* requested but nothing ended up paused".
|
|
54
|
+
*
|
|
55
|
+
* Scope (D5): `getAnimations({subtree:true})` covers the top document's own
|
|
56
|
+
* shadow subtrees but NOT nested iframe documents (each has its own
|
|
57
|
+
* `getAnimations`). `coverage.iframesNotWalked` counts the top-document
|
|
58
|
+
* iframes whose animations are therefore absent — read as one
|
|
59
|
+
* order-independent count (a bare `querySelectorAll('iframe').length`, no
|
|
60
|
+
* enumeration coupling) — so downstream cannot read the omission as "no
|
|
61
|
+
* animation".
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import type { Collector } from '../types.js';
|
|
65
|
+
import { ownPropertyObjectIds, readHeldValue, resolveIndexedObjectIds, describeBackendNodeId } from './geometry.js';
|
|
66
|
+
import { sanitizeString } from '../redaction.js';
|
|
67
|
+
|
|
68
|
+
export interface AnimationRecord {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
/**
|
|
71
|
+
* The cross-artifact join key — the target element's `backendNodeId`.
|
|
72
|
+
* OMITTED (not `null`) when this animation genuinely has no element
|
|
73
|
+
* target (e.g. a bare `Animation` with a null effect) — there is nothing
|
|
74
|
+
* to identify. Present as `null` (never silently omitted) alongside
|
|
75
|
+
* `identityUnresolved: true` when the animation DOES have an element
|
|
76
|
+
* target but CDP could not resolve its identity (`describeBackendNodeId`
|
|
77
|
+
* failed or the bridge object was never held) — see {@link
|
|
78
|
+
* identityUnresolved}. Per I-3/I-5, "no target" and "target present but
|
|
79
|
+
* unresolved" must never collapse to the same shape.
|
|
80
|
+
*/
|
|
81
|
+
readonly backendNodeId?: number | null;
|
|
82
|
+
/** `true` when this animation has an element target but {@link backendNodeId} is `null` because identity resolution failed or was never attempted — never omit this alongside a `null` backendNodeId. Absent (not `false`) when identity resolved or the animation has no element target at all. */
|
|
83
|
+
readonly identityUnresolved?: boolean;
|
|
84
|
+
readonly selector: string | null;
|
|
85
|
+
readonly animationName: string | null;
|
|
86
|
+
readonly durationMs: number | null;
|
|
87
|
+
readonly iterationCount: number | 'infinite' | null;
|
|
88
|
+
readonly infinite: boolean;
|
|
89
|
+
readonly playState: string;
|
|
90
|
+
/**
|
|
91
|
+
* True when `--freeze-animations` was requested for this capture AND
|
|
92
|
+
* this animation's `playState` is `'paused'` at read time. This is a
|
|
93
|
+
* CO-OCCURRENCE fact, not proof of causation: an animation the page
|
|
94
|
+
* itself had already paused (or created already-paused) before or
|
|
95
|
+
* independent of the freeze is indistinguishable, from this collector's
|
|
96
|
+
* own independent post-freeze `getAnimations()` re-enumeration, from one
|
|
97
|
+
* the freeze mechanism forced from `'running'` to `'paused'` — WAAPI
|
|
98
|
+
* exposes no per-animation pause history, and CDP's
|
|
99
|
+
* `Animation.setPlaybackRate` override never touches the JS-visible
|
|
100
|
+
* `Animation.playbackRate`. Read `frozen` as "paused while freeze was
|
|
101
|
+
* requested", never as a verified per-animation tool-intervention claim.
|
|
102
|
+
*/
|
|
103
|
+
readonly frozen: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Explicit factual scope for `animation.json`: `getAnimations({subtree:true})` walks the top document and its shadow subtrees, never nested iframe documents. */
|
|
107
|
+
export interface AnimationCoverage {
|
|
108
|
+
readonly scope: 'top-document';
|
|
109
|
+
/** Count of top-document `<iframe>` elements whose own animations are NOT enumerated (each iframe document has its own `getAnimations`). Meaningless (always `0`) when `available` below is `false` — read `available` first. */
|
|
110
|
+
readonly iframesNotWalked: number;
|
|
111
|
+
/** `false` when the iframe-count evaluate itself failed (the page-side count threw, or the CDP round trip threw/returned no usable value) — `iframesNotWalked:0` is then "could not count", not "zero iframes" (I-4/I-5). Always `true` on a normal run, including a page with genuinely zero iframes. */
|
|
112
|
+
readonly available: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The fixed, factual reasons `animation.json`'s inventory could not be read — never a raw exception message, which is unbounded/page-influenced. */
|
|
116
|
+
export type AnimationUnavailableReason =
|
|
117
|
+
| 'inventory-evaluate-threw'
|
|
118
|
+
| 'inventory-evaluate-returned-no-object'
|
|
119
|
+
| 'inventory-facts-unavailable'
|
|
120
|
+
| 'inventory-meta-unavailable'
|
|
121
|
+
| 'get-animations-threw';
|
|
122
|
+
|
|
123
|
+
export interface AnimationReport {
|
|
124
|
+
/** `false` when the inventory evaluate/bridge itself failed, OR the page-side `document.getAnimations()` walk itself threw — an empty `animations` list is then "could not enumerate", not "genuinely no animations" (I-5). Always `true` on a normal run, including one where the page really has zero animations. */
|
|
125
|
+
readonly available: boolean;
|
|
126
|
+
/** Present only when `available` is `false`. */
|
|
127
|
+
readonly unavailableReason?: AnimationUnavailableReason;
|
|
128
|
+
/** Restates `ctx.freezeAnimations` at the artifact level — lets a reader of `animation.json` alone (without `meta.json`) tell "freeze wasn't requested" apart from "freeze was requested but nothing ended up paused" (`frozenCount:0` is otherwise ambiguous between the two). */
|
|
129
|
+
readonly freezeRequested: boolean;
|
|
130
|
+
readonly animations: readonly AnimationRecord[];
|
|
131
|
+
readonly infiniteCount: number;
|
|
132
|
+
readonly frozenCount: number;
|
|
133
|
+
readonly coverage: AnimationCoverage;
|
|
134
|
+
/** True when the try/finally release of a held CDP bridge object threw — a factual marker that CDP-session-scoped remote-object memory may not have been freed early, never a diagnosis and never page-observable state. Absent when release succeeded. */
|
|
135
|
+
readonly bridgeCleanupFailed?: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Page-side raw record — the settle.ts base shape plus a `targetIdx` into the held (never page-observable) elements array (absent/`-1` when the animation has no element target). */
|
|
139
|
+
interface RawAnimationRecord {
|
|
140
|
+
readonly selector: string | null;
|
|
141
|
+
readonly animationName: string | null;
|
|
142
|
+
readonly durationMs: number | null;
|
|
143
|
+
readonly iterationCount: number | 'infinite' | null;
|
|
144
|
+
readonly infinite: boolean;
|
|
145
|
+
readonly playState: string;
|
|
146
|
+
readonly targetIdx?: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Enumerates `getAnimations({subtree:true})` and, in the SAME pass, collects
|
|
151
|
+
* each element target into a page-side array at the index recorded as
|
|
152
|
+
* `targetIdx` — so a target's `backendNodeId` is resolved against the
|
|
153
|
+
* exact element the record describes, with no second-call ordering skew.
|
|
154
|
+
* Returns `{ facts, elements, meta }`: `facts` is the bare record ARRAY
|
|
155
|
+
* (identical to settle.ts's inventory shape, plus `targetIdx`); `elements`
|
|
156
|
+
* is the live target array, in `targetIdx` order; `meta.ok` is `false`
|
|
157
|
+
* when `document.getAnimations()` (or reading its timing) itself threw —
|
|
158
|
+
* distinguishing that failure from a genuinely-empty successful walk is
|
|
159
|
+
* the entire point (I-4/I-5): a thrown enumeration discards any partial
|
|
160
|
+
* `facts`/`elements` accumulated so far (never returns a partial result as
|
|
161
|
+
* if it were the complete truth). None of `facts`/`elements`/`meta` is
|
|
162
|
+
* ever assigned to `window` or any other page-observable location — the
|
|
163
|
+
* return value is a plain in-memory object, read back only through its own
|
|
164
|
+
* held CDP `objectId` (see {@link collectAnimation}). `describeTarget`
|
|
165
|
+
* matches settle.ts verbatim so the `selector` base field is identical
|
|
166
|
+
* across both `animation.json` branches.
|
|
167
|
+
*/
|
|
168
|
+
const ANIMATION_INVENTORY_SCRIPT = `/* __captureAnimationInventory */
|
|
169
|
+
(function() {
|
|
170
|
+
function describeTarget(node) {
|
|
171
|
+
try {
|
|
172
|
+
if (!node || node.nodeType !== 1) return null;
|
|
173
|
+
var tag = node.tagName ? node.tagName.toLowerCase() : null;
|
|
174
|
+
var cls = (node.className && typeof node.className === 'string')
|
|
175
|
+
? ('.' + node.className.trim().split(/\\s+/).join('.'))
|
|
176
|
+
: '';
|
|
177
|
+
return tag ? (tag + cls) : null;
|
|
178
|
+
} catch (e) { return null; }
|
|
179
|
+
}
|
|
180
|
+
var out = [];
|
|
181
|
+
var targets = [];
|
|
182
|
+
try {
|
|
183
|
+
var anims = document.getAnimations({ subtree: true });
|
|
184
|
+
for (var i = 0; i < anims.length; i++) {
|
|
185
|
+
var a = anims[i];
|
|
186
|
+
var timing = (a.effect && a.effect.getComputedTiming) ? a.effect.getComputedTiming() : null;
|
|
187
|
+
var iterations = timing ? timing.iterations : null;
|
|
188
|
+
var infinite = iterations === Infinity;
|
|
189
|
+
var target = (a.effect && a.effect.target) ? a.effect.target : null;
|
|
190
|
+
var targetIdx = -1;
|
|
191
|
+
if (target && target.nodeType === 1) {
|
|
192
|
+
targetIdx = targets.length;
|
|
193
|
+
targets.push(target);
|
|
194
|
+
}
|
|
195
|
+
out.push({
|
|
196
|
+
selector: target ? describeTarget(target) : null,
|
|
197
|
+
animationName: a.animationName || a.id || null,
|
|
198
|
+
durationMs: timing ? timing.duration : null,
|
|
199
|
+
iterationCount: infinite ? 'infinite' : (typeof iterations === 'number' ? iterations : null),
|
|
200
|
+
infinite: infinite,
|
|
201
|
+
playState: a.playState,
|
|
202
|
+
targetIdx: targetIdx,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
} catch (e) {
|
|
206
|
+
return { facts: [], elements: [], meta: { ok: false } };
|
|
207
|
+
}
|
|
208
|
+
return { facts: out, elements: targets, meta: { ok: true } };
|
|
209
|
+
})();`;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Order-independent count of top-document iframes whose animations are not
|
|
213
|
+
* enumerated. Its own evaluate — a bare count, no coupling to the inventory
|
|
214
|
+
* enumeration order. Returns `{ count, ok }` rather than a bare number so a
|
|
215
|
+
* page-side `querySelectorAll` throw is distinguishable from a genuine
|
|
216
|
+
* zero-iframe page (I-4/I-5) — see {@link AnimationCoverage.available}.
|
|
217
|
+
*/
|
|
218
|
+
const IFRAME_COUNT_SCRIPT = `(function () { try { return { count: document.querySelectorAll('iframe').length, ok: true }; } catch (e) { return { count: 0, ok: false }; } })()`;
|
|
219
|
+
|
|
220
|
+
export const collectAnimation: Collector = async (ctx) => {
|
|
221
|
+
let raw: RawAnimationRecord[] = [];
|
|
222
|
+
let bridgeCleanupFailed = false;
|
|
223
|
+
// I-5: distinguishes "the evaluate/bridge failed" from "the page really
|
|
224
|
+
// has no animations" — both otherwise collapse to an empty `animations`
|
|
225
|
+
// list, indistinguishable to a reader.
|
|
226
|
+
let available = true;
|
|
227
|
+
let unavailableReason: AnimationReport['unavailableReason'];
|
|
228
|
+
const heldObjectIds: string[] = [];
|
|
229
|
+
// Preallocated below once `raw` (and so `targetCount`) is known — filled
|
|
230
|
+
// in the SAME try block as the walk, before the held ids are released.
|
|
231
|
+
let backendNodeIds: Array<number | undefined> = [];
|
|
232
|
+
try {
|
|
233
|
+
const walkEval = (await ctx.client.send('Runtime.evaluate', {
|
|
234
|
+
expression: ANIMATION_INVENTORY_SCRIPT,
|
|
235
|
+
returnByValue: false,
|
|
236
|
+
})) as { result?: { objectId?: string } };
|
|
237
|
+
const resultObjectId = walkEval.result?.objectId;
|
|
238
|
+
|
|
239
|
+
if (resultObjectId) {
|
|
240
|
+
heldObjectIds.push(resultObjectId);
|
|
241
|
+
const containerIds = await ownPropertyObjectIds(ctx.client, resultObjectId);
|
|
242
|
+
const factsObjectId = containerIds.get('facts');
|
|
243
|
+
const elementsObjectId = containerIds.get('elements');
|
|
244
|
+
const metaObjectId = containerIds.get('meta');
|
|
245
|
+
|
|
246
|
+
// I-4: a missing objectId for a required held property, OR a
|
|
247
|
+
// readHeldValue() that resolves (without throwing) to `undefined`, is
|
|
248
|
+
// the SAME fact as a thrown read -- the property was never actually
|
|
249
|
+
// read. Read both before deciding success/failure so neither can
|
|
250
|
+
// silently fall through to the initialized empty/default artifact
|
|
251
|
+
// (mirrors geometry.ts's facts/meta pattern).
|
|
252
|
+
let factsValue: RawAnimationRecord[] | undefined;
|
|
253
|
+
if (factsObjectId) {
|
|
254
|
+
heldObjectIds.push(factsObjectId);
|
|
255
|
+
factsValue = await readHeldValue<RawAnimationRecord[]>(ctx.client, factsObjectId);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let metaValue: { ok?: boolean } | undefined;
|
|
259
|
+
if (metaObjectId) {
|
|
260
|
+
heldObjectIds.push(metaObjectId);
|
|
261
|
+
metaValue = await readHeldValue<{ ok?: boolean }>(ctx.client, metaObjectId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (factsValue === undefined) {
|
|
265
|
+
available = false;
|
|
266
|
+
unavailableReason = 'inventory-facts-unavailable';
|
|
267
|
+
} else if (metaObjectId && metaValue === undefined) {
|
|
268
|
+
// `meta` rides the SAME returned object literal as `facts` -- when
|
|
269
|
+
// the container has a `meta` OWN PROPERTY (a real production
|
|
270
|
+
// inventory return always does) but reading it failed, that is a
|
|
271
|
+
// broken bridge read, not "the page has zero animations". A
|
|
272
|
+
// container with NO `meta` property at all is treated as
|
|
273
|
+
// undetermined-but-fine below rather than unavailable, so this
|
|
274
|
+
// stays compatible with any caller driving the pre-existing
|
|
275
|
+
// `{ facts, elements }` shape without the newer `ok` signal.
|
|
276
|
+
available = false;
|
|
277
|
+
unavailableReason = 'inventory-meta-unavailable';
|
|
278
|
+
} else if (metaValue?.ok === false) {
|
|
279
|
+
// The page-side `document.getAnimations()` walk itself threw --
|
|
280
|
+
// `facts`/`elements` were reset to empty by the script's own catch
|
|
281
|
+
// branch, so there is nothing partial to trust here either.
|
|
282
|
+
available = false;
|
|
283
|
+
unavailableReason = 'get-animations-threw';
|
|
284
|
+
} else {
|
|
285
|
+
raw = factsValue;
|
|
286
|
+
const targetCount = raw.reduce((n, a) => (typeof a.targetIdx === 'number' && a.targetIdx >= 0 ? n + 1 : n), 0);
|
|
287
|
+
backendNodeIds = new Array<number | undefined>(targetCount).fill(undefined);
|
|
288
|
+
|
|
289
|
+
if (elementsObjectId && targetCount > 0) {
|
|
290
|
+
heldObjectIds.push(elementsObjectId);
|
|
291
|
+
const objectIds = await resolveIndexedObjectIds(ctx.client, elementsObjectId, targetCount);
|
|
292
|
+
// Each resolved per-target element objectId is its OWN held remote
|
|
293
|
+
// reference, distinct from the container/facts/elements array
|
|
294
|
+
// objectIds above -- it must be released too, or every animated
|
|
295
|
+
// element this run touched leaks CDP-session-scoped memory.
|
|
296
|
+
for (const objectId of objectIds) {
|
|
297
|
+
if (objectId) heldObjectIds.push(objectId);
|
|
298
|
+
}
|
|
299
|
+
await Promise.all(
|
|
300
|
+
objectIds.map(async (objectId, idx) => {
|
|
301
|
+
if (!objectId) return;
|
|
302
|
+
backendNodeIds[idx] = await describeBackendNodeId(ctx.client, objectId);
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
available = false;
|
|
309
|
+
unavailableReason = 'inventory-evaluate-returned-no-object';
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
raw = [];
|
|
313
|
+
available = false;
|
|
314
|
+
unavailableReason = 'inventory-evaluate-threw';
|
|
315
|
+
} finally {
|
|
316
|
+
// Runs UNCONDITIONALLY: every held container/facts/elements/per-target
|
|
317
|
+
// objectId must be released even when a step above throws, so a later
|
|
318
|
+
// capture on the same tab never collides with anything this run held.
|
|
319
|
+
for (const id of heldObjectIds) {
|
|
320
|
+
try {
|
|
321
|
+
await ctx.client.send('Runtime.releaseObject', { objectId: id });
|
|
322
|
+
} catch {
|
|
323
|
+
bridgeCleanupFailed = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let iframesNotWalked = 0;
|
|
329
|
+
let iframeCoverageAvailable = true;
|
|
330
|
+
try {
|
|
331
|
+
const frameResult = (await ctx.client.send('Runtime.evaluate', {
|
|
332
|
+
expression: IFRAME_COUNT_SCRIPT,
|
|
333
|
+
returnByValue: true,
|
|
334
|
+
})) as { result?: { value?: { count?: number; ok?: boolean } } };
|
|
335
|
+
const value = frameResult.result?.value;
|
|
336
|
+
if (value && value.ok === true && typeof value.count === 'number') {
|
|
337
|
+
iframesNotWalked = value.count;
|
|
338
|
+
} else {
|
|
339
|
+
// The evaluate itself succeeded but the page-side count threw (or the
|
|
340
|
+
// round trip returned no usable value) -- 0 here would otherwise be
|
|
341
|
+
// indistinguishable from a genuine zero-iframe page.
|
|
342
|
+
iframeCoverageAvailable = false;
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
iframeCoverageAvailable = false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const animations: AnimationRecord[] = raw.map((a, i) => {
|
|
349
|
+
const hasTarget = typeof a.targetIdx === 'number' && a.targetIdx >= 0;
|
|
350
|
+
// I-3/I-5: "no element target" (omit the field — nothing to identify)
|
|
351
|
+
// and "target present but identity resolution failed" (backendNodeId:
|
|
352
|
+
// null + identityUnresolved:true) must never collapse to the same
|
|
353
|
+
// undefined-field shape — mirrors hittest.ts's `resolvedIdentity`.
|
|
354
|
+
const resolvedBackendNodeId = hasTarget ? backendNodeIds[a.targetIdx as number] : undefined;
|
|
355
|
+
const identity: Pick<AnimationRecord, 'backendNodeId' | 'identityUnresolved'> = !hasTarget
|
|
356
|
+
? {}
|
|
357
|
+
: resolvedBackendNodeId === undefined
|
|
358
|
+
? { backendNodeId: null, identityUnresolved: true }
|
|
359
|
+
: { backendNodeId: resolvedBackendNodeId };
|
|
360
|
+
return {
|
|
361
|
+
id: `anim-${i + 1}`,
|
|
362
|
+
...identity,
|
|
363
|
+
selector: a.selector !== null ? sanitizeString(a.selector, { max: 300 }) : null,
|
|
364
|
+
animationName: a.animationName !== null ? sanitizeString(a.animationName, { max: 200 }) : null,
|
|
365
|
+
durationMs: a.durationMs,
|
|
366
|
+
iterationCount: a.iterationCount,
|
|
367
|
+
infinite: a.infinite,
|
|
368
|
+
playState: a.playState,
|
|
369
|
+
frozen: ctx.freezeAnimations && a.playState === 'paused',
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const report: AnimationReport = {
|
|
374
|
+
available,
|
|
375
|
+
...(unavailableReason ? { unavailableReason } : {}),
|
|
376
|
+
freezeRequested: ctx.freezeAnimations,
|
|
377
|
+
animations,
|
|
378
|
+
infiniteCount: animations.filter((a) => a.infinite).length,
|
|
379
|
+
frozenCount: animations.filter((a) => a.frozen).length,
|
|
380
|
+
coverage: { scope: 'top-document', iframesNotWalked, available: iframeCoverageAvailable },
|
|
381
|
+
...(bridgeCleanupFailed ? { bridgeCleanupFailed: true } : {}),
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
ctx.write.json('animation.json', report);
|
|
385
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ax.json` collector — the full accessibility tree (`Accessibility.
|
|
3
|
+
* getFullAXTree()`) flattened into a per-node index: role, name,
|
|
4
|
+
* description, ignored/ignoredReasons, a curated allowlist of ARIA
|
|
5
|
+
* states, and the AX parent/child structure. `backendNodeId` (from
|
|
6
|
+
* `backendDOMNodeId`, free on every AX node) is the cross-file join key
|
|
7
|
+
* shared with `styles.json`/`media.json` per the U04 contract — no
|
|
8
|
+
* coordination with those files is needed.
|
|
9
|
+
*
|
|
10
|
+
* "geometry ids ... when possible": U07's `geometry.json` is a
|
|
11
|
+
* concurrently-written sibling collector (`Promise.all` in `snapshot.ts`,
|
|
12
|
+
* no cross-collector read ordering) that this file must not depend on or
|
|
13
|
+
* read at capture time. Read here as "attach a `rect` fact directly per AX
|
|
14
|
+
* node via a best-effort `DOM.getBoxModel` lookup" rather than a join
|
|
15
|
+
* against `geometry.json` — a deliberate, bounded reading documented in
|
|
16
|
+
* the U08 build-plan report, not a defect.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { axisAlignedRectFromQuad, type Quad, type Rect } from '../../coordinates.js';
|
|
20
|
+
import { capArray, sanitizeString } from '../redaction.js';
|
|
21
|
+
import type { Collector } from '../types.js';
|
|
22
|
+
|
|
23
|
+
/** How many non-ignored AX nodes (with a `backendDOMNodeId`) get a follow-up `DOM.getBoxModel` rect lookup. */
|
|
24
|
+
const AX_MAX_RECT_LOOKUPS = 300;
|
|
25
|
+
/** Hard cap on AX nodes written to `ax.json` — the AX tree is page-controlled and otherwise unbounded; a generous bound keeps the artifact size sane. Nodes beyond this are dropped (in document order) and counted in `truncated`. */
|
|
26
|
+
const AX_MAX_NODES = 5000;
|
|
27
|
+
|
|
28
|
+
/** Curated allowlist of AX properties surfaced as `states` — the common interactive/structural states, not the full CDP property list. */
|
|
29
|
+
const STATE_ALLOWLIST = [
|
|
30
|
+
'checked',
|
|
31
|
+
'expanded',
|
|
32
|
+
'disabled',
|
|
33
|
+
'invalid',
|
|
34
|
+
'pressed',
|
|
35
|
+
'selected',
|
|
36
|
+
'required',
|
|
37
|
+
'readonly',
|
|
38
|
+
'multiselectable',
|
|
39
|
+
'busy',
|
|
40
|
+
'hidden',
|
|
41
|
+
'modal',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Raw CDP shapes (modeled locally — no CDP protocol package is imported)
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
interface AXPropertyValue {
|
|
49
|
+
type: string;
|
|
50
|
+
value?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface AXProperty {
|
|
54
|
+
name: string;
|
|
55
|
+
value: AXPropertyValue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface AXNode {
|
|
59
|
+
nodeId: string;
|
|
60
|
+
ignored: boolean;
|
|
61
|
+
ignoredReasons?: AXProperty[];
|
|
62
|
+
role?: AXPropertyValue;
|
|
63
|
+
name?: AXPropertyValue;
|
|
64
|
+
description?: AXPropertyValue;
|
|
65
|
+
properties?: AXProperty[];
|
|
66
|
+
childIds?: string[];
|
|
67
|
+
parentId?: string;
|
|
68
|
+
backendDOMNodeId?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Output shape
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
export interface AxNodeRecord {
|
|
76
|
+
id: string;
|
|
77
|
+
axId: string;
|
|
78
|
+
role: unknown;
|
|
79
|
+
axName?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
ignored: boolean;
|
|
82
|
+
ignoredReasons: string[];
|
|
83
|
+
backendNodeId?: number;
|
|
84
|
+
parentAxId?: string;
|
|
85
|
+
childAxIds: string[];
|
|
86
|
+
states: Record<string, unknown>;
|
|
87
|
+
/** Present only when the per-node `DOM.getBoxModel` lookup was attempted (node is eligible: not ignored, has a `backendDOMNodeId`, and wasn't skipped by {@link AxReport.rectLookupsTruncated}) AND succeeded with a real content quad. Absent for an ineligible/skipped node (never attempted) and for an attempted-but-failed node (see {@link rectUnavailable}) -- both of those also omit `rect`, but only the latter carries a failure marker. */
|
|
88
|
+
rect?: Rect;
|
|
89
|
+
/**
|
|
90
|
+
* `true` when this node's `DOM.getBoxModel` rect lookup was ATTEMPTED and
|
|
91
|
+
* FAILED (threw, or returned no/malformed `model.content`) -- distinct
|
|
92
|
+
* from a node whose lookup was never attempted at all (ignored, no
|
|
93
|
+
* `backendDOMNodeId`, or skipped by {@link AxReport.rectLookupsTruncated}),
|
|
94
|
+
* which also omits `rect` but carries no failure marker (I-5: a genuine
|
|
95
|
+
* "not attempted"/scope omission must not look identical to a genuine
|
|
96
|
+
* read failure). See {@link AxRectUnavailableReason} for which failure.
|
|
97
|
+
* Absent (not `false`) when the lookup wasn't attempted or succeeded.
|
|
98
|
+
*/
|
|
99
|
+
rectUnavailable?: true;
|
|
100
|
+
/** Present only when {@link rectUnavailable} is `true`. */
|
|
101
|
+
rectUnavailableReason?: AxRectUnavailableReason;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fixed, factual reason a single AX node's per-node `DOM.getBoxModel` rect
|
|
106
|
+
* lookup did not complete -- never a raw exception message. Present only on
|
|
107
|
+
* {@link AxNodeRecord.rectUnavailable}.
|
|
108
|
+
*
|
|
109
|
+
* - `box-model-read-threw`: `DOM.getBoxModel` itself threw (protocol error,
|
|
110
|
+
* detached/invalid backend node reference, ...) -- a genuine read
|
|
111
|
+
* failure, NOT proof the node has no box. Empirically, a genuinely
|
|
112
|
+
* non-rendered element (e.g. `display:none`) is excluded from the AX tree
|
|
113
|
+
* entirely (or arrives `ignored`) and so is never in the eligible set this
|
|
114
|
+
* function attempts at all; a REAL zero-size rendered box (e.g.
|
|
115
|
+
* `width:0;height:0;overflow:hidden`) resolves `DOM.getBoxModel`
|
|
116
|
+
* successfully with a degenerate (zero-area) content quad rather than
|
|
117
|
+
* throwing. A throw on an eligible node is therefore a read failure to
|
|
118
|
+
* report, not a silent "no box" to infer.
|
|
119
|
+
* - `box-model-no-content`: `DOM.getBoxModel` resolved without throwing but
|
|
120
|
+
* the response carried no `model.content` field, `content` was not an
|
|
121
|
+
* array of exactly 8 entries, or one of those 8 entries was not a finite
|
|
122
|
+
* number (e.g. `NaN`/non-numeric) -- any malformed response, distinct
|
|
123
|
+
* from a clean 8-finite-number quad (which is honestly represented as a
|
|
124
|
+
* real, if possibly degenerate/zero-area, `rect`). A partially-numeric or
|
|
125
|
+
* `NaN`-bearing quad is never silently coerced through
|
|
126
|
+
* `axisAlignedRectFromQuad` -- that would emit a `NaN` rect that
|
|
127
|
+
* serializes as `null` fields with no failure marker at all.
|
|
128
|
+
*/
|
|
129
|
+
export type AxRectUnavailableReason = 'box-model-read-threw' | 'box-model-no-content';
|
|
130
|
+
|
|
131
|
+
/** Fixed, factual reason `Accessibility.getFullAXTree` could not be read (never a raw exception message, which is unbounded/page-influenced) — present only when {@link AxReport.available} is `false`. */
|
|
132
|
+
export type AxUnavailableReason = 'axtree-unavailable' | 'axtree-returned-no-nodes';
|
|
133
|
+
|
|
134
|
+
export interface AxReport {
|
|
135
|
+
nodes: AxNodeRecord[];
|
|
136
|
+
/** Present only when the tree exceeded {@link AX_MAX_NODES} — the count of AX nodes dropped past the cap. */
|
|
137
|
+
truncated?: number;
|
|
138
|
+
/** Present only when the eligible (non-ignored, with a `backendDOMNodeId`) node set exceeded {@link AX_MAX_RECT_LOOKUPS} — the count of nodes whose `rect` lookup was skipped by the cap, so their `rect` is absent by cap rather than by measurement (e.g. non-rendered). */
|
|
139
|
+
rectLookupsTruncated?: number;
|
|
140
|
+
/** Explicit scope fact (D5): `Accessibility.getFullAXTree` is top-document only, so omission of iframe AX nodes is a stated scope boundary, never a negative fact. */
|
|
141
|
+
coverage: { scope: 'top-document' };
|
|
142
|
+
/** `false` when `Accessibility.getFullAXTree` itself failed (threw, or returned no `nodes` field) — `nodes: []` is then "could not collect", not "genuinely empty tree" (I-5). Always `true` on a normal run, including one where the page genuinely has no AX nodes. */
|
|
143
|
+
available: boolean;
|
|
144
|
+
/** Present only when `available` is `false`. */
|
|
145
|
+
unavailableReason?: AxUnavailableReason;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ============================================================================
|
|
149
|
+
// Helpers
|
|
150
|
+
// ============================================================================
|
|
151
|
+
|
|
152
|
+
/** Caps an optional page-controlled AX string, or returns `undefined` when absent. */
|
|
153
|
+
function sanitizeOptional(value: string | null | undefined): string | undefined {
|
|
154
|
+
if (value === null || value === undefined) return undefined;
|
|
155
|
+
return sanitizeString(value);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function deriveStates(properties: AXProperty[] | undefined): Record<string, unknown> {
|
|
159
|
+
const states: Record<string, unknown> = {};
|
|
160
|
+
if (!properties) return states;
|
|
161
|
+
for (const prop of properties) {
|
|
162
|
+
if ((STATE_ALLOWLIST as readonly string[]).includes(prop.name)) {
|
|
163
|
+
states[prop.name] = prop.value?.value;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return states;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// Collector
|
|
171
|
+
// ============================================================================
|
|
172
|
+
|
|
173
|
+
export const collectAx: Collector = async (ctx) => {
|
|
174
|
+
const { client } = ctx;
|
|
175
|
+
|
|
176
|
+
// I-5: a missing `nodes` field (the read failed) and a genuinely empty tree both look like `[]`
|
|
177
|
+
// unless the failure is distinguished explicitly — `available:false` is the honest signal a
|
|
178
|
+
// downstream reader needs to tell "could not collect" from "the page really has zero AX nodes".
|
|
179
|
+
let allAxNodes: AXNode[];
|
|
180
|
+
let available = true;
|
|
181
|
+
let unavailableReason: AxUnavailableReason | undefined;
|
|
182
|
+
try {
|
|
183
|
+
const treeResponse = (await client.send('Accessibility.getFullAXTree')) as { nodes?: AXNode[] };
|
|
184
|
+
if (treeResponse.nodes === undefined) {
|
|
185
|
+
allAxNodes = [];
|
|
186
|
+
available = false;
|
|
187
|
+
unavailableReason = 'axtree-returned-no-nodes';
|
|
188
|
+
} else {
|
|
189
|
+
allAxNodes = treeResponse.nodes;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
allAxNodes = [];
|
|
193
|
+
available = false;
|
|
194
|
+
unavailableReason = 'axtree-unavailable';
|
|
195
|
+
}
|
|
196
|
+
const axNodes = allAxNodes.slice(0, AX_MAX_NODES);
|
|
197
|
+
const truncated = allAxNodes.length > AX_MAX_NODES ? allAxNodes.length - AX_MAX_NODES : undefined;
|
|
198
|
+
|
|
199
|
+
const {
|
|
200
|
+
rects: rectByNodeId,
|
|
201
|
+
failures: rectFailureByNodeId,
|
|
202
|
+
truncated: rectLookupsTruncated,
|
|
203
|
+
} = await resolveRects(client, axNodes);
|
|
204
|
+
|
|
205
|
+
const nodes: AxNodeRecord[] = axNodes.map((node, index) => {
|
|
206
|
+
const nameValue = node.name?.value;
|
|
207
|
+
const descriptionValue = node.description?.value;
|
|
208
|
+
const rectFailureReason = node.backendDOMNodeId !== undefined ? rectFailureByNodeId.get(node.backendDOMNodeId) : undefined;
|
|
209
|
+
return {
|
|
210
|
+
id: `ax-${index}`,
|
|
211
|
+
axId: node.nodeId,
|
|
212
|
+
role: node.role?.value ?? null,
|
|
213
|
+
axName: sanitizeOptional(nameValue === undefined || nameValue === null ? undefined : String(nameValue)),
|
|
214
|
+
description: sanitizeOptional(descriptionValue === undefined || descriptionValue === null ? undefined : String(descriptionValue)),
|
|
215
|
+
ignored: node.ignored,
|
|
216
|
+
ignoredReasons: (node.ignoredReasons ?? []).map((r) => r.name),
|
|
217
|
+
backendNodeId: node.backendDOMNodeId,
|
|
218
|
+
parentAxId: node.parentId,
|
|
219
|
+
childAxIds: node.childIds ?? [],
|
|
220
|
+
states: deriveStates(node.properties),
|
|
221
|
+
rect: node.backendDOMNodeId !== undefined ? rectByNodeId.get(node.backendDOMNodeId) : undefined,
|
|
222
|
+
...(rectFailureReason !== undefined ? { rectUnavailable: true as const, rectUnavailableReason: rectFailureReason } : {}),
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
ctx.write.json('ax.json', {
|
|
227
|
+
nodes,
|
|
228
|
+
truncated,
|
|
229
|
+
rectLookupsTruncated: rectLookupsTruncated > 0 ? rectLookupsTruncated : undefined,
|
|
230
|
+
coverage: { scope: 'top-document' },
|
|
231
|
+
available,
|
|
232
|
+
...(unavailableReason ? { unavailableReason } : {}),
|
|
233
|
+
} satisfies AxReport);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Best-effort `rect` lookup for up to {@link AX_MAX_RECT_LOOKUPS} non-ignored
|
|
238
|
+
* AX nodes with a `backendDOMNodeId`, in tree order. A genuine per-node read
|
|
239
|
+
* FAILURE (the `DOM.getBoxModel` call throws, or resolves with no/malformed
|
|
240
|
+
* `model.content`) is reported back in `failures` (I-5 — see
|
|
241
|
+
* {@link AxRectUnavailableReason}), never silently folded into "no rect";
|
|
242
|
+
* only a node this function never attempts at all (filtered out of
|
|
243
|
+
* `eligible`, or skipped by the cap) legitimately omits both `rect` and a
|
|
244
|
+
* failure marker. Nodes skipped by the cap are counted in the returned
|
|
245
|
+
* `truncated` (a cap that can drop real data emits a fact, via the shared
|
|
246
|
+
* {@link capArray} authority).
|
|
247
|
+
*/
|
|
248
|
+
async function resolveRects(
|
|
249
|
+
client: Parameters<Collector>[0]['client'],
|
|
250
|
+
axNodes: readonly AXNode[],
|
|
251
|
+
): Promise<{ rects: Map<number, Rect>; failures: Map<number, AxRectUnavailableReason>; truncated: number }> {
|
|
252
|
+
const eligible = axNodes.filter((node) => !node.ignored && node.backendDOMNodeId !== undefined);
|
|
253
|
+
const { items: capped, truncated } = capArray(eligible, AX_MAX_RECT_LOOKUPS);
|
|
254
|
+
|
|
255
|
+
const rects = new Map<number, Rect>();
|
|
256
|
+
const failures = new Map<number, AxRectUnavailableReason>();
|
|
257
|
+
for (const node of capped) {
|
|
258
|
+
const backendNodeId = node.backendDOMNodeId as number;
|
|
259
|
+
try {
|
|
260
|
+
const boxResponse = (await client.send('DOM.getBoxModel', { backendNodeId })) as {
|
|
261
|
+
model?: { content: number[] };
|
|
262
|
+
};
|
|
263
|
+
const content = boxResponse.model?.content;
|
|
264
|
+
if (!Array.isArray(content) || content.length !== 8 || !content.every((n) => typeof n === 'number' && Number.isFinite(n))) {
|
|
265
|
+
failures.set(backendNodeId, 'box-model-no-content');
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
rects.set(backendNodeId, axisAlignedRectFromQuad(content as Quad));
|
|
269
|
+
} catch {
|
|
270
|
+
// A genuine read failure (protocol error, detached/invalid node) — NOT proof of "no box"; see
|
|
271
|
+
// AxRectUnavailableReason's `box-model-read-threw` doc for why a real non-rendered element never
|
|
272
|
+
// reaches this catch. Mark it explicitly rather than silently omitting the rect.
|
|
273
|
+
failures.set(backendNodeId, 'box-model-read-threw');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { rects, failures, truncated };
|
|
278
|
+
}
|