@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,1041 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `focus.json` collector — the keyboard traversal recorded during capture:
|
|
3
|
+
* forward Tab sequence and reverse Shift+Tab, per-step active element
|
|
4
|
+
* identity/role/name/rect, scroll offset changes, focus-visible style
|
|
5
|
+
* facts, DOM-order-vs-tab-order divergence, and focusable-but-unreached /
|
|
6
|
+
* clickable-but-unfocusable elements.
|
|
7
|
+
*
|
|
8
|
+
* The walk is browser-driven (real `Input.dispatchKeyEvent` Tab/Shift+Tab
|
|
9
|
+
* presses, sampled via `document.activeElement`) rather than a JS-computed
|
|
10
|
+
* approximation — the design calls this out as the authoritative
|
|
11
|
+
* measurement (a JS query can enumerate *candidates* but cannot reliably
|
|
12
|
+
* predict native tab-order edge cases).
|
|
13
|
+
*
|
|
14
|
+
* ## Identity join key
|
|
15
|
+
* Each element-bearing record (forward/reverse stops with a real active
|
|
16
|
+
* element, `domOrderDivergence` entries, `unreachedFocusable` candidates, and
|
|
17
|
+
* `clickableUnfocusable` elements) carries `backendNodeId: number | null`
|
|
18
|
+
* (resolved via `DOM.describeNode` off the temporary
|
|
19
|
+
* `data-capture-focus-id`/`data-capture-focus-clickable-id` markers) plus an
|
|
20
|
+
* `identityUnresolved: true` marker — never a silently omitted field — when
|
|
21
|
+
* that resolution fails, mirroring `hittest.ts`'s honest per-record identity
|
|
22
|
+
* shape (I-3/I-5). Whether a stop is element-bearing is decided from the
|
|
23
|
+
* SAMPLE's `document.activeElement !== document.body` fact
|
|
24
|
+
* (`FocusStop`/`WalkResult.hasActiveElement`), NEVER from `id`: `id` is set
|
|
25
|
+
* only when `FOCUS_INIT_SCRIPT` happened to stamp a
|
|
26
|
+
* `data-capture-focus-id` marker on the active element, so a real
|
|
27
|
+
* native-focusable element the candidate selector does not match (e.g. a
|
|
28
|
+
* `contenteditable` form outside `[contenteditable="true"]`'s exact
|
|
29
|
+
* attribute form) can be tab-reached with `id === null` while still being a
|
|
30
|
+
* genuine element — that stop IS element-bearing and, having no marker to
|
|
31
|
+
* resolve, correctly carries `backendNodeId: null` + `identityUnresolved:
|
|
32
|
+
* true`. Only a stop whose active element genuinely IS `document.body`
|
|
33
|
+
* (nothing focused) carries `backendNodeId: null` WITHOUT
|
|
34
|
+
* `identityUnresolved` — there is no element to have failed to resolve. So a
|
|
35
|
+
* logical DOM node joins across `focus.json`/`geometry.json`/`hittest.json`
|
|
36
|
+
* by `backendNodeId` wherever it resolved. The `focus-<n>`/`click-<n>` ids
|
|
37
|
+
* are collector-local artifact handles, not cross-artifact keys.
|
|
38
|
+
*
|
|
39
|
+
* ## Scope
|
|
40
|
+
* Top document, light DOM only — the traversal walks
|
|
41
|
+
* `document.querySelectorAll` and does not descend into iframes or shadow
|
|
42
|
+
* roots. `focus.json`'s `scope` field states this boundary as a fact
|
|
43
|
+
* (including counts of the iframes/open shadow hosts NOT traversed) so a
|
|
44
|
+
* downstream reader cannot mistake omission for a negative finding.
|
|
45
|
+
*
|
|
46
|
+
* ## Restoration
|
|
47
|
+
* The true original scroll offset and "did anything have focus" fact are
|
|
48
|
+
* captured FIRST, by a dedicated non-mutating read (`__captureFocusOrigin`)
|
|
49
|
+
* that runs before any marker is stamped — so if the marker-stamping init
|
|
50
|
+
* (`__captureFocusInit`, which tags candidates/clickable elements and the
|
|
51
|
+
* original active element live in the DOM) throws partway through tagging
|
|
52
|
+
* a large candidate set, the values used to build every restore call still
|
|
53
|
+
* reflect the REAL page state rather than silently falling back to "no
|
|
54
|
+
* focus, scroll (0,0)". The whole window — origin capture, marker
|
|
55
|
+
* stamping, the traversal, and restore — runs inside a `try`/`finally`, so
|
|
56
|
+
* an init that stamped markers then threw still reaches the finally
|
|
57
|
+
* cleanup rather than leaking markers into the baseline `dom.html`.
|
|
58
|
+
*
|
|
59
|
+
* Restore is gated on two independently tracked facts, never on the
|
|
60
|
+
* (possibly-empty) `origin` value alone: whether the origin read itself
|
|
61
|
+
* genuinely returned a value (`originCaptured`), and whether the mutating
|
|
62
|
+
* init script was ever invoked (`mutationStarted`, set immediately before
|
|
63
|
+
* that call — a real browser may partially tag candidates before an init
|
|
64
|
+
* throw reaches Node). A destructive focus/scroll restore (re-focusing the
|
|
65
|
+
* REAL original active element via its dedicated `data-capture-focus-original`
|
|
66
|
+
* marker, tagged FIRST within the init script before any candidate
|
|
67
|
+
* mutation so a programmatically focused `tabindex="-1"` element the
|
|
68
|
+
* candidate filter skips is still restored; blur only when the origin read
|
|
69
|
+
* found no active element; and `window.scrollTo` to the origin-captured
|
|
70
|
+
* coordinates) runs ONLY when `originCaptured` is true. When the origin was
|
|
71
|
+
* never proven — its evaluate returned no value or threw — restore never
|
|
72
|
+
* blurs real focus or scrolls to a stale (0,0) default on the strength of
|
|
73
|
+
* `EMPTY_ORIGIN`: if mutation had already started, only temporary
|
|
74
|
+
* `data-capture-focus-id`/`data-capture-focus-clickable-id`/`-original`
|
|
75
|
+
* markers are stripped; if mutation never started either (the origin read
|
|
76
|
+
* threw before the init call), restore is skipped entirely — there is
|
|
77
|
+
* nothing to clean up and nothing proven to restore. The outcome is
|
|
78
|
+
* recorded factually in `focus.json`'s `restoration` field
|
|
79
|
+
* (`attempted`/`focusRestored`/`markersCleared`/`scrollRestored`, a
|
|
80
|
+
* sanitized `error` when the window threw, and `markerCleanupFailed: true`
|
|
81
|
+
* when the restore evaluate itself threw) — never as prose advice.
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
import type { CDPClient } from '../../client.js';
|
|
85
|
+
import type { Collector } from '../types.js';
|
|
86
|
+
import { sanitizeString } from '../redaction.js';
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// Injected scripts
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
const SHARED_HELPERS = `
|
|
93
|
+
function __rectOf(el) {
|
|
94
|
+
try {
|
|
95
|
+
var r = el.getBoundingClientRect();
|
|
96
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
97
|
+
} catch (e) { return null; }
|
|
98
|
+
}
|
|
99
|
+
function __selectorOf(el) {
|
|
100
|
+
try {
|
|
101
|
+
if (!el || el.nodeType !== 1) return null;
|
|
102
|
+
var tag = el.tagName.toLowerCase();
|
|
103
|
+
var id = el.id ? ('#' + el.id) : '';
|
|
104
|
+
var cls = (el.className && typeof el.className === 'string' && el.className.trim())
|
|
105
|
+
? ('.' + el.className.trim().split(/\\s+/).filter(Boolean).join('.'))
|
|
106
|
+
: '';
|
|
107
|
+
return tag + id + cls;
|
|
108
|
+
} catch (e) { return null; }
|
|
109
|
+
}
|
|
110
|
+
function __isVisible(el) {
|
|
111
|
+
try {
|
|
112
|
+
if (el.offsetParent !== null) return true;
|
|
113
|
+
var rects = el.getClientRects();
|
|
114
|
+
return !!(rects && rects.length > 0);
|
|
115
|
+
} catch (e) { return false; }
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Non-mutating read of the true original focus/scroll state, run BEFORE any
|
|
121
|
+
* marker is stamped anywhere. This is the value every restore call in
|
|
122
|
+
* {@link collectFocus} is built from — never `FOCUS_INIT_SCRIPT`'s return
|
|
123
|
+
* value — so a throw partway through that script's candidate-tagging loop
|
|
124
|
+
* (after it has already mutated some elements) can never cause restoration
|
|
125
|
+
* to fall back to a wrong "no focus, scroll (0,0)" default: this call
|
|
126
|
+
* already captured the real answer first, and it performs no mutation of
|
|
127
|
+
* its own that could itself be left partially applied.
|
|
128
|
+
*/
|
|
129
|
+
const FOCUS_ORIGIN_SCRIPT = `/* __captureFocusOrigin */
|
|
130
|
+
(function() {
|
|
131
|
+
var active = document.activeElement;
|
|
132
|
+
return {
|
|
133
|
+
hadOriginalFocus: !!(active && active !== document.body),
|
|
134
|
+
scrollX: window.scrollX,
|
|
135
|
+
scrollY: window.scrollY,
|
|
136
|
+
};
|
|
137
|
+
})();`;
|
|
138
|
+
|
|
139
|
+
const FOCUS_INIT_SCRIPT = `/* __captureFocusInit */
|
|
140
|
+
(function() {
|
|
141
|
+
${SHARED_HELPERS}
|
|
142
|
+
// Tag the REAL current active element FIRST, before any candidate/clickable
|
|
143
|
+
// mutation below — so a throw partway through those loops still leaves this
|
|
144
|
+
// marker in place for restore-by-marker to re-find the exact original
|
|
145
|
+
// element. (The scroll/hadOriginalFocus VALUES restore actually restores to
|
|
146
|
+
// are captured separately, before this script even runs, by a dedicated
|
|
147
|
+
// non-mutating origin read — this marker only needs to survive long enough
|
|
148
|
+
// to support refocusing the precise element at the end of the walk.)
|
|
149
|
+
var active = document.activeElement;
|
|
150
|
+
if (active && active !== document.body && active.setAttribute) {
|
|
151
|
+
active.setAttribute('data-capture-focus-original', '1');
|
|
152
|
+
}
|
|
153
|
+
var FOCUSABLE_SELECTOR = 'a[href],area[href],input:not([type="hidden"]),select,textarea,button,iframe,[tabindex],[contenteditable="true"],audio[controls],video[controls],summary';
|
|
154
|
+
var all = Array.prototype.slice.call(document.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
155
|
+
var candidates = [];
|
|
156
|
+
var nextId = 1;
|
|
157
|
+
for (var i = 0; i < all.length; i++) {
|
|
158
|
+
var el = all[i];
|
|
159
|
+
if (el.hasAttribute('disabled')) continue;
|
|
160
|
+
var tabindexAttr = el.getAttribute('tabindex');
|
|
161
|
+
if (tabindexAttr !== null && parseInt(tabindexAttr, 10) < 0) continue;
|
|
162
|
+
if (tabindexAttr === null && el.tabIndex < 0) continue;
|
|
163
|
+
var id = 'focus-' + (nextId++);
|
|
164
|
+
el.setAttribute('data-capture-focus-id', id);
|
|
165
|
+
candidates.push({
|
|
166
|
+
id: id,
|
|
167
|
+
selector: __selectorOf(el),
|
|
168
|
+
tabIndex: el.tabIndex,
|
|
169
|
+
rect: __rectOf(el),
|
|
170
|
+
visible: __isVisible(el),
|
|
171
|
+
domIndex: i,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
var clickable = [];
|
|
175
|
+
var clickNextId = 1;
|
|
176
|
+
var shadowHosts = 0;
|
|
177
|
+
var allEls = document.querySelectorAll('*');
|
|
178
|
+
for (var j = 0; j < allEls.length; j++) {
|
|
179
|
+
var e2 = allEls[j];
|
|
180
|
+
if (e2.shadowRoot) shadowHosts++;
|
|
181
|
+
if (clickable.length >= 50) continue;
|
|
182
|
+
if (e2.hasAttribute('data-capture-focus-id')) continue;
|
|
183
|
+
var hasOnclick = e2.hasAttribute('onclick') || typeof e2.onclick === 'function';
|
|
184
|
+
var cursorPointer = false;
|
|
185
|
+
var cursorReadUnavailable = false;
|
|
186
|
+
try { cursorPointer = window.getComputedStyle(e2).cursor === 'pointer'; } catch (err) { cursorReadUnavailable = true; }
|
|
187
|
+
// A failed cursor read must NOT be reported as "not clickable": when
|
|
188
|
+
// getComputedStyle throws, cursorPointer stays false by construction, so
|
|
189
|
+
// without this OR-branch an element whose only clickable evidence would
|
|
190
|
+
// have been cursor:pointer is silently dropped from clickableUnfocusable
|
|
191
|
+
// entirely -- indistinguishable from a genuine non-clickable element. Tag
|
|
192
|
+
// and emit it instead, carrying cursorReadUnavailable: true so the
|
|
193
|
+
// element's clickable status reads as unknown, not negative (I-4/I-5).
|
|
194
|
+
if (hasOnclick || cursorPointer || cursorReadUnavailable) {
|
|
195
|
+
var cid = 'click-' + (clickNextId++);
|
|
196
|
+
e2.setAttribute('data-capture-focus-clickable-id', cid);
|
|
197
|
+
clickable.push({ id: cid, selector: __selectorOf(e2), rect: __rectOf(e2), cursorReadUnavailable: cursorReadUnavailable });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
candidates: candidates,
|
|
202
|
+
clickableUnfocusable: clickable,
|
|
203
|
+
// The scan stops adding NEW candidates once the cap is reached (see
|
|
204
|
+
// above), so whether more matches exist beyond it is genuinely unknown
|
|
205
|
+
// without redoing the expensive computed-style check for every
|
|
206
|
+
// remaining element — this is an honest "cap reached" boolean, not a
|
|
207
|
+
// dropped-item count.
|
|
208
|
+
clickableTruncated: clickable.length >= 50,
|
|
209
|
+
iframesPresent: document.querySelectorAll('iframe').length,
|
|
210
|
+
shadowHostsPresent: shadowHosts,
|
|
211
|
+
};
|
|
212
|
+
})();`;
|
|
213
|
+
|
|
214
|
+
/** Exported for the script-unit regression that proves the sample reads no page attribute/global for identity. */
|
|
215
|
+
export const FOCUS_SAMPLE_SCRIPT = `/* __captureFocusSample */
|
|
216
|
+
(function() {
|
|
217
|
+
${SHARED_HELPERS}
|
|
218
|
+
var active = document.activeElement;
|
|
219
|
+
var id = (active && active.getAttribute) ? active.getAttribute('data-capture-focus-id') : null;
|
|
220
|
+
// Identity for a real-but-untagged active element (id === null &&
|
|
221
|
+
// hasActiveElement, e.g. a contenteditable div outside the candidate
|
|
222
|
+
// selector) is NOT derived in this script. This sample must never stamp a
|
|
223
|
+
// page-visible marker, assign or read a page-reachable global, or trust a
|
|
224
|
+
// page-authored attribute as node identity (I-2): all three are
|
|
225
|
+
// page-controlled and collide/preseed trivially. The collector resolves
|
|
226
|
+
// such a node's stable backendNodeId out of band via a private CDP
|
|
227
|
+
// objectId -> DOM.describeNode bridge on document.activeElement (see
|
|
228
|
+
// resolveActiveElementBackendId), which the page cannot observe or forge.
|
|
229
|
+
// So this read touches no attribute/global for cycle identity and leaves
|
|
230
|
+
// page state exactly as found.
|
|
231
|
+
var focusVisibleStyle = null;
|
|
232
|
+
try {
|
|
233
|
+
var computed = window.getComputedStyle(active);
|
|
234
|
+
focusVisibleStyle = {
|
|
235
|
+
outline: (computed.outlineStyle + ' ' + computed.outlineWidth + ' ' + computed.outlineColor),
|
|
236
|
+
boxShadow: computed.boxShadow,
|
|
237
|
+
};
|
|
238
|
+
} catch (e) {}
|
|
239
|
+
var role = (active && active.getAttribute) ? active.getAttribute('role') : null;
|
|
240
|
+
var name = null;
|
|
241
|
+
if (active) {
|
|
242
|
+
name = active.getAttribute && (active.getAttribute('aria-label') || active.getAttribute('alt') || active.getAttribute('placeholder'));
|
|
243
|
+
if (!name) {
|
|
244
|
+
var text = (active.textContent || '').trim();
|
|
245
|
+
name = text || null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
id: id,
|
|
250
|
+
selector: __selectorOf(active),
|
|
251
|
+
role: role || null,
|
|
252
|
+
name: name || null,
|
|
253
|
+
rect: active ? __rectOf(active) : null,
|
|
254
|
+
tabIndex: active ? active.tabIndex : null,
|
|
255
|
+
focusVisibleStyle: focusVisibleStyle,
|
|
256
|
+
scrollX: window.scrollX,
|
|
257
|
+
scrollY: window.scrollY,
|
|
258
|
+
isBody: active === document.body,
|
|
259
|
+
// The true element-bearing fact: a real, non-body active element. NOT
|
|
260
|
+
// !isBody -- document.activeElement can be null (a genuine no-active
|
|
261
|
+
// sample), and null === document.body is false, so !isBody alone would
|
|
262
|
+
// misclassify a null active element as element-bearing. hasActiveElement
|
|
263
|
+
// is false for BOTH document.body AND null.
|
|
264
|
+
hasActiveElement: !!(active && active !== document.body),
|
|
265
|
+
};
|
|
266
|
+
})();`;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Non-destructive marker-only cleanup, used when the true original
|
|
270
|
+
* focus/scroll was never proven (the origin read returned no value or threw)
|
|
271
|
+
* but the mutating init DID start tagging the page — so there ARE temporary
|
|
272
|
+
* markers to strip, but no proven origin to justify a blur or a scroll to a
|
|
273
|
+
* possibly-wrong `(0,0)`. Touches nothing but the `data-capture-focus-*`
|
|
274
|
+
* marker attributes this collector itself stamped.
|
|
275
|
+
*/
|
|
276
|
+
/** Exported for the script-unit regression that proves cleanup preserves page-owned attributes/globals. */
|
|
277
|
+
export const MARKER_CLEANUP_SCRIPT = `/* __captureFocusMarkerCleanup */
|
|
278
|
+
(function() {
|
|
279
|
+
var origMarkers = document.querySelectorAll('[data-capture-focus-original]');
|
|
280
|
+
for (var m = 0; m < origMarkers.length; m++) { origMarkers[m].removeAttribute('data-capture-focus-original'); }
|
|
281
|
+
var tagged = document.querySelectorAll('[data-capture-focus-id]');
|
|
282
|
+
for (var i = 0; i < tagged.length; i++) { tagged[i].removeAttribute('data-capture-focus-id'); }
|
|
283
|
+
var clk = document.querySelectorAll('[data-capture-focus-clickable-id]');
|
|
284
|
+
for (var k = 0; k < clk.length; k++) { clk[k].removeAttribute('data-capture-focus-clickable-id'); }
|
|
285
|
+
// Only markers this collector stamped are removed -- never a
|
|
286
|
+
// page-authored attribute. Identity is resolved via CDP, so there is no
|
|
287
|
+
// walk marker to strip and no page-owned attribute/global to disturb.
|
|
288
|
+
var markersRemoved = document.querySelectorAll('[data-capture-focus-id],[data-capture-focus-clickable-id],[data-capture-focus-original]').length === 0;
|
|
289
|
+
return { markersRemoved: markersRemoved };
|
|
290
|
+
})();`;
|
|
291
|
+
|
|
292
|
+
/** Exported for the script-unit regression that proves restore preserves page-owned attributes/globals. */
|
|
293
|
+
export function buildRestoreScript(hadOriginalFocus: boolean, scrollX: number, scrollY: number): string {
|
|
294
|
+
return `/* __captureFocusRestore */
|
|
295
|
+
(function() {
|
|
296
|
+
var hadOriginalFocus = ${JSON.stringify(Boolean(hadOriginalFocus))};
|
|
297
|
+
var scrollX = ${JSON.stringify(scrollX)};
|
|
298
|
+
var scrollY = ${JSON.stringify(scrollY)};
|
|
299
|
+
var focusRestored = false;
|
|
300
|
+
// Restore the REAL original active element by its stable marker (covers a
|
|
301
|
+
// programmatically focused tabindex="-1" element the candidate filter
|
|
302
|
+
// skipped). Blur ONLY when there genuinely was no original active element.
|
|
303
|
+
var originalEl = document.querySelector('[data-capture-focus-original]');
|
|
304
|
+
if (originalEl && originalEl.focus) {
|
|
305
|
+
try { originalEl.focus({ preventScroll: true }); focusRestored = document.activeElement === originalEl; } catch (e) {}
|
|
306
|
+
} else if (!hadOriginalFocus) {
|
|
307
|
+
try { document.activeElement && document.activeElement.blur && document.activeElement.blur(); focusRestored = true; } catch (e) {}
|
|
308
|
+
}
|
|
309
|
+
var origMarkers = document.querySelectorAll('[data-capture-focus-original]');
|
|
310
|
+
for (var m = 0; m < origMarkers.length; m++) { origMarkers[m].removeAttribute('data-capture-focus-original'); }
|
|
311
|
+
var tagged = document.querySelectorAll('[data-capture-focus-id]');
|
|
312
|
+
for (var i = 0; i < tagged.length; i++) { tagged[i].removeAttribute('data-capture-focus-id'); }
|
|
313
|
+
var clk = document.querySelectorAll('[data-capture-focus-clickable-id]');
|
|
314
|
+
for (var k = 0; k < clk.length; k++) { clk[k].removeAttribute('data-capture-focus-clickable-id'); }
|
|
315
|
+
// Only collector-stamped markers are removed; no page-authored attribute
|
|
316
|
+
// (identity is CDP-resolved, so no walk marker exists to strip).
|
|
317
|
+
var markersRemoved = document.querySelectorAll('[data-capture-focus-id],[data-capture-focus-clickable-id],[data-capture-focus-original]').length === 0;
|
|
318
|
+
window.scrollTo(scrollX, scrollY);
|
|
319
|
+
var scrollRestored = window.scrollX === scrollX && window.scrollY === scrollY;
|
|
320
|
+
return { focusRestored: focusRestored, markersRemoved: markersRemoved, scrollRestored: scrollRestored };
|
|
321
|
+
})();`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ============================================================================
|
|
325
|
+
// Types
|
|
326
|
+
// ============================================================================
|
|
327
|
+
|
|
328
|
+
interface FocusCandidate {
|
|
329
|
+
readonly id: string;
|
|
330
|
+
readonly selector: string | null;
|
|
331
|
+
readonly tabIndex: number;
|
|
332
|
+
readonly rect: { x: number; y: number; width: number; height: number } | null;
|
|
333
|
+
readonly visible: boolean;
|
|
334
|
+
readonly domIndex: number;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
interface FocusInitResult {
|
|
338
|
+
readonly candidates: FocusCandidate[];
|
|
339
|
+
/** `cursorReadUnavailable` is `true` when this element's `getComputedStyle(...).cursor` read threw during the clickable scan — the element was tagged and emitted anyway (I-4/I-5) precisely because a failed read must not silently omit an element whose only clickable evidence might have been `cursor:pointer`. */
|
|
340
|
+
readonly clickableUnfocusable: Array<{ id: string; selector: string | null; rect: { x: number; y: number; width: number; height: number } | null; cursorReadUnavailable?: boolean }>;
|
|
341
|
+
/** `true` when the 50-candidate cap on `clickableUnfocusable` was reached — the scan may not have visited every element on the page. Optional (rather than `boolean`) because this is untrusted page-returned JSON, not a TS-enforced guarantee — a malformed init result can omit it even though the in-page script always sets it (see {@link FocusReport.clickableUnfocusableTruncationUnavailable}). */
|
|
342
|
+
readonly clickableTruncated?: boolean;
|
|
343
|
+
readonly iframesPresent: number;
|
|
344
|
+
readonly shadowHostsPresent: number;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Result of the non-mutating {@link FOCUS_ORIGIN_SCRIPT} read — the values every restore call is built from. */
|
|
348
|
+
interface FocusOriginResult {
|
|
349
|
+
readonly hadOriginalFocus: boolean;
|
|
350
|
+
readonly scrollX: number;
|
|
351
|
+
readonly scrollY: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
interface FocusSampleRaw {
|
|
355
|
+
readonly id: string | null;
|
|
356
|
+
readonly selector: string | null;
|
|
357
|
+
readonly role: string | null;
|
|
358
|
+
readonly name: string | null;
|
|
359
|
+
readonly rect: { x: number; y: number; width: number; height: number } | null;
|
|
360
|
+
readonly tabIndex: number | null;
|
|
361
|
+
readonly focusVisibleStyle: { outline: string; boxShadow: string } | null;
|
|
362
|
+
readonly scrollX: number;
|
|
363
|
+
readonly scrollY: number;
|
|
364
|
+
readonly isBody: boolean;
|
|
365
|
+
/** `true` only for a real, non-body active element (`document.activeElement && document.activeElement !== document.body`) — false for BOTH `document.body` and a `null` active element. This, not `!isBody`, is the element-bearing classifier: `document.activeElement` can genuinely be `null`, and `null === document.body` is false, so `isBody` alone cannot distinguish "nothing focused (null)" from "a real element". */
|
|
366
|
+
readonly hasActiveElement: boolean;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
interface FocusRestoreResult {
|
|
370
|
+
readonly focusRestored: boolean;
|
|
371
|
+
readonly markersRemoved: boolean;
|
|
372
|
+
readonly scrollRestored: boolean;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface FocusStop {
|
|
376
|
+
readonly step: number;
|
|
377
|
+
readonly id: string | null;
|
|
378
|
+
/** `null` (never an omitted key) either because this stop is not element-bearing (the sampled active element genuinely WAS `document.body` — nothing was focused) or because it IS element-bearing but identity resolution failed — see {@link identityUnresolved} to tell the two apart. Whether a stop is element-bearing is decided from `document.activeElement !== document.body` at capture time, NEVER from {@link id}: `id` reflects only whether `FOCUS_INIT_SCRIPT` stamped a marker on the active element, so a real element outside the candidate selector can be active with `id === null` and still be element-bearing. */
|
|
379
|
+
readonly backendNodeId: number | null;
|
|
380
|
+
/** `true` only when this stop IS element-bearing (a real element — tagged or not — was active) but the identity did not resolve: either there was no marker to resolve (untagged active element) or the marker→backendNodeId lookup failed. Absent (not `false`) when identity resolved OR when the stop is not element-bearing (`document.body`). */
|
|
381
|
+
readonly identityUnresolved?: true;
|
|
382
|
+
readonly selector: string | null;
|
|
383
|
+
readonly role: string | null;
|
|
384
|
+
readonly name: string | null;
|
|
385
|
+
readonly rect: { x: number; y: number; width: number; height: number } | null;
|
|
386
|
+
readonly tabIndex: number | null;
|
|
387
|
+
readonly focusVisibleStyle: { outline: string; boxShadow: string } | null;
|
|
388
|
+
readonly domIndex: number | null;
|
|
389
|
+
readonly scrollBefore: { x: number; y: number };
|
|
390
|
+
readonly scrollAfter: { x: number; y: number };
|
|
391
|
+
readonly scrollJump: boolean;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Factual boundary of what `focus.json` measured — top document, light DOM only. */
|
|
395
|
+
export interface FocusScope {
|
|
396
|
+
readonly root: 'top-document';
|
|
397
|
+
readonly shadowDom: 'light-only';
|
|
398
|
+
readonly iframesPresent: number;
|
|
399
|
+
readonly shadowHostsPresent: number;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Factual outcome of restoring page state after the (mutating) traversal. */
|
|
403
|
+
export interface FocusRestoration {
|
|
404
|
+
readonly attempted: boolean;
|
|
405
|
+
readonly focusRestored: boolean;
|
|
406
|
+
readonly markersCleared: boolean;
|
|
407
|
+
readonly scrollRestored: boolean;
|
|
408
|
+
readonly error?: string;
|
|
409
|
+
/** `true` when the final restore/cleanup evaluate ITSELF threw — markers may remain in the (baseline) DOM. Present only on failure. */
|
|
410
|
+
readonly markerCleanupFailed?: boolean;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Fixed, factual reason the traversal (origin read → init → forward walk →
|
|
415
|
+
* reverse walk) did not complete — never a raw exception message, which is
|
|
416
|
+
* unbounded/page-influenced. Present only when {@link FocusReport.available}
|
|
417
|
+
* is `false`.
|
|
418
|
+
*/
|
|
419
|
+
export type FocusUnavailableReason =
|
|
420
|
+
| 'origin-read-threw'
|
|
421
|
+
| 'init-unavailable'
|
|
422
|
+
| 'forward-walk-threw'
|
|
423
|
+
| 'reverse-walk-threw';
|
|
424
|
+
|
|
425
|
+
export interface FocusReport {
|
|
426
|
+
/**
|
|
427
|
+
* `false` when the origin read, the candidate/clickable init, the forward
|
|
428
|
+
* walk, or the reverse walk failed to complete (a CDP throw, or an
|
|
429
|
+
* evaluate that returned no value at all — see {@link evaluate}'s doc).
|
|
430
|
+
* Read this FIRST: when `false`, EVERY other field below — `forward`/
|
|
431
|
+
* `reverse`/their truncation flags, `domOrderDivergence`,
|
|
432
|
+
* `unreachedFocusable`, `clickableUnfocusable`(Truncated), `candidateCount`,
|
|
433
|
+
* `scope` — reflects only whatever this run happened to capture before the
|
|
434
|
+
* failure (possibly nothing at all, or a stale/partial `init` the forward
|
|
435
|
+
* walk never got to visit). None of it is a genuine "no focus order"/"no
|
|
436
|
+
* candidates"/"cap not reached" observation, and must not be read as one
|
|
437
|
+
* (I-4/I-5).
|
|
438
|
+
*/
|
|
439
|
+
readonly available: boolean;
|
|
440
|
+
/** Present only when `available` is `false`. */
|
|
441
|
+
readonly unavailableReason?: FocusUnavailableReason;
|
|
442
|
+
readonly forward: FocusStop[];
|
|
443
|
+
/** `true` only when the forward walk exhausted {@link MAX_STEPS_HARD_CAP} steps without reaching a natural stop — the real forward tab order may extend beyond `forward`. Meaningless when `available` is `false` — read `available` first. */
|
|
444
|
+
readonly forwardTruncated: boolean;
|
|
445
|
+
readonly reverse: FocusStop[];
|
|
446
|
+
/** Same meaning as {@link forwardTruncated}, for the reverse (Shift+Tab) walk. Meaningless when `available` is `false` — read `available` first. */
|
|
447
|
+
readonly reverseTruncated: boolean;
|
|
448
|
+
/** Every entry's `id` is non-null (see {@link FocusStop.domIndex} — only set for a matched candidate), so every entry is element-bearing: `backendNodeId` is `number | null` and `identityUnresolved` follows the same rule as {@link FocusStop}. */
|
|
449
|
+
readonly domOrderDivergence: Array<{ step: number; id: string | null; backendNodeId: number | null; identityUnresolved?: true; domIndex: number; previousDomIndex: number }>;
|
|
450
|
+
/** Every entry is a real tagged focusable candidate (element-bearing) — `backendNodeId` is `number | null`, `identityUnresolved: true` when the marker→backendNodeId resolution failed. */
|
|
451
|
+
readonly unreachedFocusable: Array<{ id: string; backendNodeId: number | null; identityUnresolved?: true; selector: string | null; rect: { x: number; y: number; width: number; height: number } | null; visible: boolean }>;
|
|
452
|
+
/** Every entry is a real clickable element the scan tagged (element-bearing) — `backendNodeId` is `number | null`, `identityUnresolved: true` when the marker→backendNodeId resolution failed. `cursorReadUnavailable: true` (present only on failure, per I-4/I-5) marks an entry whose `getComputedStyle(...).cursor` read threw during the in-page scan — its clickable status could not be confirmed by cursor alone (it may still have `hasOnclick` evidence), so it is reported as unresolved rather than silently dropped as "not clickable". */
|
|
453
|
+
readonly clickableUnfocusable: Array<{ backendNodeId: number | null; identityUnresolved?: true; selector: string | null; rect: { x: number; y: number; width: number; height: number } | null; cursorReadUnavailable?: true }>;
|
|
454
|
+
/** `true` when the 50-candidate cap on `clickableUnfocusable` was reached — see the module doc's Truncation facts section. Meaningless when `available` is `false` — read `available` first. */
|
|
455
|
+
readonly clickableUnfocusableTruncated: boolean;
|
|
456
|
+
/** `true` (Layer 2, I-4/I-5) when the init evaluate itself succeeded (`available` is `true`) but the otherwise-valid returned value was MISSING or non-boolean `clickableTruncated` — a malformed successful init, distinct from a genuinely-computed `clickableTruncated: false`. When `true`, `clickableUnfocusableTruncated` above falls back to `false`, which is not a real measurement. Absent (not `false`) on a normal run; meaningless (and never set) when {@link available} is `false`, since that failure is already reported by `available`/`unavailableReason`. */
|
|
457
|
+
readonly clickableUnfocusableTruncationUnavailable?: true;
|
|
458
|
+
/** Meaningless (always `0` when the init step never completed) when `available` is `false` — read `available` first. */
|
|
459
|
+
readonly candidateCount: number;
|
|
460
|
+
/** `iframesPresent`/`shadowHostsPresent` are meaningless (always `0`) when `available` is `false` — read `available` first. */
|
|
461
|
+
readonly scope: FocusScope;
|
|
462
|
+
readonly restoration: FocusRestoration;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ============================================================================
|
|
466
|
+
// CDP driving
|
|
467
|
+
// ============================================================================
|
|
468
|
+
|
|
469
|
+
const MAX_STEPS_HARD_CAP = 300;
|
|
470
|
+
|
|
471
|
+
/** Returns `undefined` (rather than throwing) when the CDP response carries no value at all — a foreign/degenerate `Runtime.evaluate` response we can't act on, not a legitimate "no active element" sample (which the scripts represent with `{id: null, ...}`, a well-formed value). */
|
|
472
|
+
async function evaluate<T>(client: CDPClient, expression: string): Promise<T | undefined> {
|
|
473
|
+
const response = (await client.send('Runtime.evaluate', { expression, returnByValue: true })) as {
|
|
474
|
+
result?: { value?: T };
|
|
475
|
+
};
|
|
476
|
+
return response.result?.value;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Resolves a `<markerAttr> → backendNodeId` map off the temporary marker
|
|
481
|
+
* attributes the init script stamped, following `states.ts`'s
|
|
482
|
+
* `DOM.describeNode` pattern. Best-effort: any CDP hiccup yields an empty
|
|
483
|
+
* map (the join key is simply absent from those records), never a throw
|
|
484
|
+
* that would abort the traversal.
|
|
485
|
+
*/
|
|
486
|
+
async function resolveMarkerBackendIds(client: CDPClient, markerAttr: string): Promise<Map<string, number>> {
|
|
487
|
+
const map = new Map<string, number>();
|
|
488
|
+
try {
|
|
489
|
+
const doc = (await client.send('DOM.getDocument', { depth: -1, pierce: false })) as { root?: { nodeId?: number } };
|
|
490
|
+
const rootId = doc.root?.nodeId;
|
|
491
|
+
if (rootId === undefined) return map;
|
|
492
|
+
const res = (await client.send('DOM.querySelectorAll', { nodeId: rootId, selector: `[${markerAttr}]` })) as { nodeIds?: number[] };
|
|
493
|
+
for (const nodeId of res.nodeIds ?? []) {
|
|
494
|
+
const described = (await client.send('DOM.describeNode', { nodeId })) as { node?: { backendNodeId?: number; attributes?: string[] } };
|
|
495
|
+
const backendNodeId = described.node?.backendNodeId;
|
|
496
|
+
const attrs = described.node?.attributes ?? [];
|
|
497
|
+
if (backendNodeId === undefined) continue;
|
|
498
|
+
for (let i = 0; i + 1 < attrs.length; i += 2) {
|
|
499
|
+
if (attrs[i] === markerAttr) {
|
|
500
|
+
map.set(attrs[i + 1], backendNodeId);
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
// Best-effort identity resolution — leave the map empty on any failure.
|
|
507
|
+
}
|
|
508
|
+
return map;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const EMPTY_INIT: FocusInitResult = {
|
|
512
|
+
candidates: [],
|
|
513
|
+
clickableUnfocusable: [],
|
|
514
|
+
clickableTruncated: false,
|
|
515
|
+
iframesPresent: 0,
|
|
516
|
+
shadowHostsPresent: 0,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const EMPTY_ORIGIN: FocusOriginResult = { hadOriginalFocus: false, scrollX: 0, scrollY: 0 };
|
|
520
|
+
|
|
521
|
+
async function dispatchTab(client: CDPClient, reverse: boolean): Promise<void> {
|
|
522
|
+
const modifiers = reverse ? 8 : 0; // CDP modifier bit for Shift
|
|
523
|
+
const base = {
|
|
524
|
+
modifiers,
|
|
525
|
+
key: 'Tab',
|
|
526
|
+
code: 'Tab',
|
|
527
|
+
windowsVirtualKeyCode: 9,
|
|
528
|
+
nativeVirtualKeyCode: 9,
|
|
529
|
+
};
|
|
530
|
+
await client.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...base });
|
|
531
|
+
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Builds the honest `{ backendNodeId, identityUnresolved }` pair shared by every element-bearing focus record — mirrors hittest.ts's `resolvedIdentity` (per I-3/I-5). Callers must confirm the record IS element-bearing (via `hasActiveElement`/`document.activeElement !== document.body`, NEVER via `id !== null` — see the module doc's Identity join key section) before calling this; a genuinely non-element record (the active element WAS `document.body`) must NOT use this helper — it emits `{ backendNodeId: null }` directly, with no `identityUnresolved`. */
|
|
535
|
+
function resolvedIdentity(backendNodeId: number | undefined): { backendNodeId: number | null; identityUnresolved?: true } {
|
|
536
|
+
return backendNodeId === undefined ? { backendNodeId: null, identityUnresolved: true } : { backendNodeId };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function toStop(step: number, raw: FocusSampleRaw, domIndex: number | null, scrollBefore: { x: number; y: number }): FocusStop {
|
|
540
|
+
const scrollAfter = { x: raw.scrollX, y: raw.scrollY };
|
|
541
|
+
return {
|
|
542
|
+
step,
|
|
543
|
+
id: raw.id,
|
|
544
|
+
// Placeholder — overwritten by `decorate()` once `focusBackendById` is
|
|
545
|
+
// resolved. Present here only so this object satisfies FocusStop's
|
|
546
|
+
// required (never-omitted) `backendNodeId` field.
|
|
547
|
+
backendNodeId: null,
|
|
548
|
+
selector: raw.selector,
|
|
549
|
+
role: raw.role,
|
|
550
|
+
name: raw.name,
|
|
551
|
+
rect: raw.rect,
|
|
552
|
+
tabIndex: raw.tabIndex,
|
|
553
|
+
focusVisibleStyle: raw.focusVisibleStyle,
|
|
554
|
+
domIndex,
|
|
555
|
+
scrollBefore,
|
|
556
|
+
scrollAfter,
|
|
557
|
+
scrollJump: scrollBefore.x !== scrollAfter.x || scrollBefore.y !== scrollAfter.y,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** {@link walk}'s result: the recorded stops, plus whether the hard step cap cut the walk short. */
|
|
562
|
+
interface WalkResult {
|
|
563
|
+
readonly stops: FocusStop[];
|
|
564
|
+
/**
|
|
565
|
+
* Parallel to {@link stops} (same index, same length) — copied directly from
|
|
566
|
+
* that sample's {@link FocusSampleRaw.hasActiveElement}: `true` only for a
|
|
567
|
+
* real, non-body active element, `false` when nothing was focused
|
|
568
|
+
* (`document.activeElement` was `document.body` OR genuinely `null`). This
|
|
569
|
+
* is the element-bearing fact `decorate()` must use for
|
|
570
|
+
* {@link FocusStop.identityUnresolved} — NEVER `stop.id !== null` (that
|
|
571
|
+
* reflects only whether `FOCUS_INIT_SCRIPT` stamped a marker — a real
|
|
572
|
+
* untagged element has `id === null` too) and NEVER `!isBody` alone (`null
|
|
573
|
+
* === document.body` is false, so `!isBody` would misclassify a genuinely
|
|
574
|
+
* unfocused `null` active element as element-bearing).
|
|
575
|
+
*/
|
|
576
|
+
readonly hasActiveElement: boolean[];
|
|
577
|
+
/** `true` only when the walk exhausted {@link MAX_STEPS_HARD_CAP} steps without ever reaching a natural stop (a return to any already-visited stop — the ring wrapping back to a prior stop, or a non-advancing Tab repeating the same element; see {@link stopKey}) — the real tab order may extend further than `stops` records. */
|
|
578
|
+
readonly truncated: boolean;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A stable per-stop cycle-detection key that identifies the SAME focus stop
|
|
583
|
+
* across a full tab ring by true per-node identity — never by geometry or
|
|
584
|
+
* content, which two distinct DOM nodes can share, and NEVER by `raw.id`,
|
|
585
|
+
* which is a page-authorable `data-capture-focus-id` attribute value the
|
|
586
|
+
* collector reads back verbatim. Keying on `id` would let a page control
|
|
587
|
+
* cycle identity: two distinct real active elements carrying the same
|
|
588
|
+
* page-authored `data-capture-focus-id` (or one colliding with a collector
|
|
589
|
+
* `focus-N` marker) would compare equal and the second would be dropped as a
|
|
590
|
+
* false cycle. So identity for EVERY element-bearing stop comes only from the
|
|
591
|
+
* collector-private CDP `backendNodeId`, never from the marker.
|
|
592
|
+
*
|
|
593
|
+
* - A genuinely unfocused stop (`!hasActiveElement`: `document.body` or
|
|
594
|
+
* `null`) keys on a single `body` sentinel — the ring passes through this
|
|
595
|
+
* gap once per cycle, so its second occurrence marks one full cycle. (This
|
|
596
|
+
* is what lets a forward walk whose wraparound passes through the
|
|
597
|
+
* `document.body` gap between a ring's last and first element detect
|
|
598
|
+
* completion instead of running to the {@link MAX_STEPS_HARD_CAP} cap.)
|
|
599
|
+
* - Every real active element — tagged candidate or untagged focusable alike
|
|
600
|
+
* — keys on its CDP-resolved `backendNodeId` (`sampledBackendNodeId`,
|
|
601
|
+
* resolved via the private objectId -> `DOM.describeNode` bridge on
|
|
602
|
+
* `document.activeElement`, NOT any page attribute/global). This
|
|
603
|
+
* backendNodeId is a COLLECTOR-PRIVATE cycle key only — it is never emitted
|
|
604
|
+
* into `FocusStop` JSON (an untagged stop still reports `backendNodeId:
|
|
605
|
+
* null` + `identityUnresolved: true`, since it has no marker the
|
|
606
|
+
* cross-artifact identity path resolved). It is genuine,
|
|
607
|
+
* page-uncontrollable node identity: two distinct nodes that happen to
|
|
608
|
+
* share selector, rect, role, name, AND page-authored
|
|
609
|
+
* `data-capture-focus-id` get distinct backendNodeIds and are BOTH
|
|
610
|
+
* retained, and the same node revisited on the next lap resolves the same
|
|
611
|
+
* backendNodeId regardless of a scroll shift.
|
|
612
|
+
*
|
|
613
|
+
* Returns `null` for the rare real element whose backendNodeId could not be
|
|
614
|
+
* resolved (the objectId bridge failed): identity is unknown, so {@link walk}
|
|
615
|
+
* must treat it as never-a-repeat rather than collapse it against another stop.
|
|
616
|
+
*/
|
|
617
|
+
function stopKey(raw: FocusSampleRaw, sampledBackendNodeId: number | undefined): string | null {
|
|
618
|
+
if (!raw.hasActiveElement) return 'body';
|
|
619
|
+
if (sampledBackendNodeId !== undefined) return `backend:${sampledBackendNodeId}`;
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Resolves the stable `backendNodeId` of the current `document.activeElement`
|
|
625
|
+
* through a collector-private CDP bridge — evaluate `document.activeElement`
|
|
626
|
+
* as a held RemoteObject (never `returnByValue`), `DOM.describeNode` off its
|
|
627
|
+
* `objectId`, then release the handle. This is the ONLY identity source for a
|
|
628
|
+
* real-but-untagged active element: it is invisible to the page (no marker
|
|
629
|
+
* stamped, no global touched, no page-authored attribute trusted) and stable
|
|
630
|
+
* across CDP calls, so a revisit of the same node resolves the same id.
|
|
631
|
+
* Best-effort: any CDP hiccup yields `undefined` (identity unknown), never a
|
|
632
|
+
* throw that would abort the walk.
|
|
633
|
+
*/
|
|
634
|
+
async function resolveActiveElementBackendId(client: CDPClient): Promise<number | undefined> {
|
|
635
|
+
let objectId: string | undefined;
|
|
636
|
+
try {
|
|
637
|
+
const evalRes = (await client.send('Runtime.evaluate', {
|
|
638
|
+
expression: 'document.activeElement',
|
|
639
|
+
returnByValue: false,
|
|
640
|
+
})) as { result?: { objectId?: string } };
|
|
641
|
+
objectId = evalRes.result?.objectId;
|
|
642
|
+
if (objectId === undefined) return undefined;
|
|
643
|
+
const described = (await client.send('DOM.describeNode', { objectId })) as { node?: { backendNodeId?: number } };
|
|
644
|
+
return described.node?.backendNodeId;
|
|
645
|
+
} catch {
|
|
646
|
+
return undefined;
|
|
647
|
+
} finally {
|
|
648
|
+
if (objectId !== undefined) {
|
|
649
|
+
try {
|
|
650
|
+
await client.send('Runtime.releaseObject', { objectId });
|
|
651
|
+
} catch {
|
|
652
|
+
// Releasing the transient handle is best-effort; a failure here does
|
|
653
|
+
// not affect the resolved identity.
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Drives one direction's Tab walk starting from whatever currently has
|
|
661
|
+
* focus, stopping when a step returns to an already-visited stop (a full
|
|
662
|
+
* cycle back to a prior stop, OR a non-advancing Tab that repeats the same
|
|
663
|
+
* element) or at the hard cap. `originScroll` seeds the first step's
|
|
664
|
+
* `scrollBefore` with the real pre-walk scroll offset (the non-mutating
|
|
665
|
+
* {@link FOCUS_ORIGIN_SCRIPT} read, or the origin the reverse walk was just
|
|
666
|
+
* restored to) — never `{x:0,y:0}`, which would report a false scroll jump
|
|
667
|
+
* on step 1 of a page that was already scrolled before this collector ran.
|
|
668
|
+
*/
|
|
669
|
+
async function walk(
|
|
670
|
+
client: CDPClient,
|
|
671
|
+
reverse: boolean,
|
|
672
|
+
candidatesById: Map<string, FocusCandidate>,
|
|
673
|
+
originScroll: { x: number; y: number },
|
|
674
|
+
): Promise<WalkResult> {
|
|
675
|
+
const stops: FocusStop[] = [];
|
|
676
|
+
const hasActiveElement: boolean[] = [];
|
|
677
|
+
// Every stop's cycle key (see {@link stopKey}) seen so far. The FIRST
|
|
678
|
+
// repeat of any key — the ring wrapping back to a prior stop, or a
|
|
679
|
+
// non-advancing Tab repeating the same element — completes the walk.
|
|
680
|
+
const seen = new Set<string>();
|
|
681
|
+
let scrollBefore = { x: originScroll.x, y: originScroll.y };
|
|
682
|
+
// Defaults to "the cap was hit"; the natural-stop branch below flips this
|
|
683
|
+
// to false right before its break, so it survives to the end of the loop
|
|
684
|
+
// ONLY when all MAX_STEPS_HARD_CAP iterations ran without one.
|
|
685
|
+
let truncated = true;
|
|
686
|
+
|
|
687
|
+
for (let step = 1; step <= MAX_STEPS_HARD_CAP; step++) {
|
|
688
|
+
await dispatchTab(client, reverse);
|
|
689
|
+
const raw = await evaluate<FocusSampleRaw>(client, FOCUS_SAMPLE_SCRIPT);
|
|
690
|
+
if (raw === undefined) {
|
|
691
|
+
// Per `evaluate`'s doc, `undefined` means the CDP response carried no
|
|
692
|
+
// value at all (a foreign/degenerate response) — a genuine failure,
|
|
693
|
+
// NEVER a legitimate "nothing focused" sample (that's a well-formed
|
|
694
|
+
// `{ hasActiveElement: false, ... }` value). Coercing this into a quiet
|
|
695
|
+
// walk-complete would report a false natural stop / false truncation
|
|
696
|
+
// fact; throw so the caller can mark the whole traversal unavailable
|
|
697
|
+
// instead (I-5).
|
|
698
|
+
throw new Error('focus sample evaluate returned no value');
|
|
699
|
+
}
|
|
700
|
+
const domIndex = raw.id ? (candidatesById.get(raw.id)?.domIndex ?? null) : null;
|
|
701
|
+
// EVERY element-bearing active element — tagged or untagged — is
|
|
702
|
+
// identified for cycle detection ONLY by its CDP-resolved backendNodeId,
|
|
703
|
+
// resolved here out of band with no page-visible side effect. The
|
|
704
|
+
// page-authorable marker `id` is never a cycle key (see {@link stopKey}),
|
|
705
|
+
// so identity is resolved regardless of `raw.id`; only genuinely-unfocused
|
|
706
|
+
// (body/null) samples skip it.
|
|
707
|
+
const sampledBackendNodeId =
|
|
708
|
+
raw.hasActiveElement ? await resolveActiveElementBackendId(client) : undefined;
|
|
709
|
+
const stop = toStop(step, raw, domIndex, scrollBefore);
|
|
710
|
+
scrollBefore = { x: raw.scrollX, y: raw.scrollY };
|
|
711
|
+
|
|
712
|
+
const key = stopKey(raw, sampledBackendNodeId);
|
|
713
|
+
if (key !== null && seen.has(key)) {
|
|
714
|
+
// Returned to an already-visited stop, matched by stable identity — the
|
|
715
|
+
// single `body` sentinel, or a real active element's CDP-resolved
|
|
716
|
+
// backendNodeId (never the page-authorable marker `id`). The walk is complete
|
|
717
|
+
// (the ring wrapped back to a prior stop, or Tab stopped advancing and
|
|
718
|
+
// repeated the same element). Do NOT record the repeated wraparound
|
|
719
|
+
// stop. This fires even when the repeated stop is untagged (`id ===
|
|
720
|
+
// null`) — the `document.body` ring gap or an untagged focusable — so
|
|
721
|
+
// the forward walk terminates after one cycle instead of running to the
|
|
722
|
+
// hard cap.
|
|
723
|
+
truncated = false;
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
// A `null` key is a real element whose backendNodeId could not be
|
|
727
|
+
// resolved via the objectId bridge — identity is unknown, so it is never
|
|
728
|
+
// treated as a repeat: keep walking (honest truncation at the cap if no
|
|
729
|
+
// keyable stop is ever reached) rather than risk dropping a legitimate
|
|
730
|
+
// distinct stop by guessing it is a cycle.
|
|
731
|
+
if (key !== null) seen.add(key);
|
|
732
|
+
|
|
733
|
+
stops.push(stop);
|
|
734
|
+
// `raw.hasActiveElement` (not `raw.id`, and not `!raw.isBody` —
|
|
735
|
+
// `document.activeElement` can genuinely be `null`, which is neither
|
|
736
|
+
// `document.body` nor a real element) is the true element-bearing fact
|
|
737
|
+
// — see {@link WalkResult.hasActiveElement}.
|
|
738
|
+
hasActiveElement.push(raw.hasActiveElement);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return { stops, hasActiveElement, truncated };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ============================================================================
|
|
745
|
+
// Collector
|
|
746
|
+
// ============================================================================
|
|
747
|
+
|
|
748
|
+
/** Node-side length cap of a page-controlled string; `max` overrides the default 2000 cap. */
|
|
749
|
+
const sanitizeOrNull = (value: string | null, max?: number): string | null =>
|
|
750
|
+
value === null ? null : sanitizeString(value, max === undefined ? undefined : { max });
|
|
751
|
+
|
|
752
|
+
/** Node-side length cap for a focus stop's accessible name (page-controlled). */
|
|
753
|
+
const MAX_FOCUS_NAME_LEN = 200;
|
|
754
|
+
|
|
755
|
+
export const collectFocus: Collector = async (ctx) => {
|
|
756
|
+
const { client } = ctx;
|
|
757
|
+
|
|
758
|
+
let origin: FocusOriginResult = EMPTY_ORIGIN;
|
|
759
|
+
let init: FocusInitResult = EMPTY_INIT;
|
|
760
|
+
let candidatesById = new Map<string, FocusCandidate>();
|
|
761
|
+
let focusBackendById = new Map<string, number>();
|
|
762
|
+
let clickableBackendById = new Map<string, number>();
|
|
763
|
+
let forward: FocusStop[] = [];
|
|
764
|
+
let forwardHasActiveElement: boolean[] = [];
|
|
765
|
+
let forwardTruncated = false;
|
|
766
|
+
let reverse: FocusStop[] = [];
|
|
767
|
+
let reverseHasActiveElement: boolean[] = [];
|
|
768
|
+
let reverseTruncated = false;
|
|
769
|
+
let caughtError: string | undefined;
|
|
770
|
+
let restoreResult: FocusRestoreResult | undefined;
|
|
771
|
+
let markerCleanupFailed = false;
|
|
772
|
+
// Whether the traversal (origin read → init → forward walk → reverse
|
|
773
|
+
// walk) completed without an unrecoverable failure — the report-level
|
|
774
|
+
// I-5 fact. `stage` tracks which step is CURRENTLY running so that if the
|
|
775
|
+
// catch below fires, the attributed reason reflects where it actually
|
|
776
|
+
// failed rather than a single generic label.
|
|
777
|
+
let available = true;
|
|
778
|
+
let stage: FocusUnavailableReason = 'origin-read-threw';
|
|
779
|
+
let unavailableReason: FocusUnavailableReason | undefined;
|
|
780
|
+
// Whether the non-mutating origin read genuinely returned a value (as
|
|
781
|
+
// opposed to `origin` merely holding the EMPTY_ORIGIN default because that
|
|
782
|
+
// read returned nothing or threw before assigning it). Restoring focus/
|
|
783
|
+
// scroll from `origin` is sound ONLY when this is true — EMPTY_ORIGIN is
|
|
784
|
+
// not a real fact about the page, just "we don't know".
|
|
785
|
+
let originCaptured = false;
|
|
786
|
+
// Whether the mutating init script was ever invoked, set immediately
|
|
787
|
+
// before that call (not after) — a real browser may partially tag
|
|
788
|
+
// candidates before an init throw reaches Node, so "invoked" (not
|
|
789
|
+
// "returned") is what determines whether temporary markers may exist.
|
|
790
|
+
let mutationStarted = false;
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
// Stamp candidate/clickable/original markers, resolve the cross-artifact
|
|
794
|
+
// join keys, and run both walks — ALL inside the try. The init script
|
|
795
|
+
// stamps page-side markers, so if anything after it throws, the finally
|
|
796
|
+
// below must still run to strip them (an init that stamped then failed
|
|
797
|
+
// otherwise leaks markers into the baseline dom.html and skips restore).
|
|
798
|
+
// Capture the TRUE original focus/scroll FIRST, via a non-mutating read
|
|
799
|
+
// that cannot itself be left partially applied. Every restore call below
|
|
800
|
+
// is built from `origin`, never from `init` — `init`'s evaluate can throw
|
|
801
|
+
// AFTER it has already tagged some candidates, in which case `init` stays
|
|
802
|
+
// EMPTY_INIT but the page has still been partially mutated; using `init`
|
|
803
|
+
// for restore params in that case would restore to a false "no focus,
|
|
804
|
+
// scroll (0,0)" default instead of the real original.
|
|
805
|
+
const originRaw = await evaluate<FocusOriginResult>(client, FOCUS_ORIGIN_SCRIPT);
|
|
806
|
+
if (originRaw === undefined) {
|
|
807
|
+
// Per `evaluate`'s doc, `undefined` is a genuine CDP-read failure, not
|
|
808
|
+
// a legitimate "nothing focused, scroll (0,0)" origin. Silently
|
|
809
|
+
// proceeding with `EMPTY_ORIGIN` would let a real-but-unrecorded
|
|
810
|
+
// scroll position leak into the forward walk's first `scrollBefore`
|
|
811
|
+
// as a fabricated `{x:0,y:0}` fact (I-4/I-5) — throw before any
|
|
812
|
+
// mutation starts so the traversal is honestly marked unavailable
|
|
813
|
+
// instead (mirrors `FocusRestoration`'s already-documented handling of
|
|
814
|
+
// "the origin read threw before the init call": nothing is proven,
|
|
815
|
+
// nothing has been mutated yet, so there is nothing dishonest to clean
|
|
816
|
+
// up either).
|
|
817
|
+
throw new Error('focus origin evaluate returned no value');
|
|
818
|
+
}
|
|
819
|
+
origin = originRaw;
|
|
820
|
+
originCaptured = true;
|
|
821
|
+
|
|
822
|
+
// Join keys are resolved while the temporary markers are still live
|
|
823
|
+
// (before any restore strips them). `mutationStarted` flips BEFORE this
|
|
824
|
+
// call so a throw from inside it (after real partial tagging) is still
|
|
825
|
+
// recorded as "there may be markers to clean up".
|
|
826
|
+
mutationStarted = true;
|
|
827
|
+
stage = 'init-unavailable';
|
|
828
|
+
const initRaw = await evaluate<FocusInitResult>(client, FOCUS_INIT_SCRIPT);
|
|
829
|
+
if (initRaw === undefined) {
|
|
830
|
+
// Per `evaluate`'s doc, `undefined` is a genuine CDP-read failure, not
|
|
831
|
+
// a legitimate empty init result. Coercing this to `EMPTY_INIT` would
|
|
832
|
+
// report "0 candidates, no iframes, no shadow hosts" as an observed
|
|
833
|
+
// fact about the page rather than "init never completed" (I-5) —
|
|
834
|
+
// throw so the traversal is honestly marked unavailable instead.
|
|
835
|
+
throw new Error('focus init evaluate returned no value');
|
|
836
|
+
}
|
|
837
|
+
init = initRaw;
|
|
838
|
+
candidatesById = new Map(init.candidates.map((c) => [c.id, c]));
|
|
839
|
+
focusBackendById = await resolveMarkerBackendIds(client, 'data-capture-focus-id');
|
|
840
|
+
clickableBackendById = await resolveMarkerBackendIds(client, 'data-capture-focus-clickable-id');
|
|
841
|
+
|
|
842
|
+
stage = 'forward-walk-threw';
|
|
843
|
+
const forwardResult = await walk(client, false, candidatesById, originCaptured ? { x: origin.scrollX, y: origin.scrollY } : { x: 0, y: 0 });
|
|
844
|
+
forward = forwardResult.stops;
|
|
845
|
+
forwardHasActiveElement = forwardResult.hasActiveElement;
|
|
846
|
+
forwardTruncated = forwardResult.truncated;
|
|
847
|
+
|
|
848
|
+
// Refocus the original element before walking in reverse so the reverse
|
|
849
|
+
// walk starts from the same place the forward walk did, independently
|
|
850
|
+
// driven rather than derived by reversing `forward` in code. Re-running
|
|
851
|
+
// init re-stamps the markers the mid-walk restore just cleared. Gated on
|
|
852
|
+
// `originCaptured` for the same reason as the finally restore below — an
|
|
853
|
+
// unproven origin must never blur real focus or scroll to (0,0).
|
|
854
|
+
stage = 'reverse-walk-threw';
|
|
855
|
+
// Both the mid-walk restore-or-cleanup evaluate and the re-stamp evaluate
|
|
856
|
+
// below are real CDP reads through the same `evaluate()` helper as every
|
|
857
|
+
// other step — a degenerate no-value response here is a genuine failure
|
|
858
|
+
// too (I-5), not a legitimate "nothing to report" result: silently
|
|
859
|
+
// ignoring it (the pre-fix behavior) could leave the reverse walk
|
|
860
|
+
// starting from an unrestored focus/scroll state or without re-stamped
|
|
861
|
+
// markers while the report still claimed `available: true`.
|
|
862
|
+
const reverseSetupResult = originCaptured
|
|
863
|
+
? await evaluate<FocusRestoreResult>(client, buildRestoreScript(origin.hadOriginalFocus, origin.scrollX, origin.scrollY))
|
|
864
|
+
: await evaluate<{ markersRemoved: boolean }>(client, MARKER_CLEANUP_SCRIPT);
|
|
865
|
+
if (reverseSetupResult === undefined) {
|
|
866
|
+
throw new Error('focus reverse-walk restore/cleanup evaluate returned no value');
|
|
867
|
+
}
|
|
868
|
+
const reverseReinit = await evaluate<FocusInitResult>(client, FOCUS_INIT_SCRIPT);
|
|
869
|
+
if (reverseReinit === undefined) {
|
|
870
|
+
throw new Error('focus reverse-walk re-init evaluate returned no value');
|
|
871
|
+
}
|
|
872
|
+
// When `originCaptured`, the restore just above scrolled the page back to
|
|
873
|
+
// `origin` before the reverse walk begins, so that's the real pre-step-1
|
|
874
|
+
// scroll. When origin was never proven, the marker-only cleanup above
|
|
875
|
+
// touches no scroll, so the true pre-walk position is wherever the
|
|
876
|
+
// forward walk left it (its last stop's `scrollAfter`), not a fabricated
|
|
877
|
+
// (0,0).
|
|
878
|
+
const reverseOriginScroll = originCaptured
|
|
879
|
+
? { x: origin.scrollX, y: origin.scrollY }
|
|
880
|
+
: (forward.length > 0 ? forward[forward.length - 1].scrollAfter : { x: 0, y: 0 });
|
|
881
|
+
const reverseResult = await walk(client, true, candidatesById, reverseOriginScroll);
|
|
882
|
+
reverse = reverseResult.stops;
|
|
883
|
+
reverseHasActiveElement = reverseResult.hasActiveElement;
|
|
884
|
+
reverseTruncated = reverseResult.truncated;
|
|
885
|
+
} catch (err) {
|
|
886
|
+
caughtError = sanitizeString(err instanceof Error ? err.message : String(err));
|
|
887
|
+
available = false;
|
|
888
|
+
unavailableReason = stage;
|
|
889
|
+
} finally {
|
|
890
|
+
// Restoration is gated on two independently tracked facts, never on the
|
|
891
|
+
// (possibly-empty) `origin` value alone:
|
|
892
|
+
// - `originCaptured`: the true focus/scroll IS proven, so a full
|
|
893
|
+
// destructive restore (refocus-or-blur + scrollTo) is sound.
|
|
894
|
+
// - `mutationStarted` (and NOT originCaptured): nothing is proven about
|
|
895
|
+
// the original state, but the init call was invoked and may have
|
|
896
|
+
// already tagged real candidates — strip those markers WITHOUT
|
|
897
|
+
// touching focus or scroll.
|
|
898
|
+
// - neither: the origin read threw before mutation ever began — there
|
|
899
|
+
// is nothing to clean up and nothing proven to restore, so restore is
|
|
900
|
+
// skipped entirely.
|
|
901
|
+
// If the restore/cleanup evaluate ITSELF throws, markers may remain in
|
|
902
|
+
// the (baseline) DOM — record that as a fact rather than swallowing it.
|
|
903
|
+
if (originCaptured) {
|
|
904
|
+
try {
|
|
905
|
+
restoreResult = await evaluate<FocusRestoreResult>(client, buildRestoreScript(origin.hadOriginalFocus, origin.scrollX, origin.scrollY));
|
|
906
|
+
} catch {
|
|
907
|
+
markerCleanupFailed = true;
|
|
908
|
+
}
|
|
909
|
+
} else if (mutationStarted) {
|
|
910
|
+
try {
|
|
911
|
+
const cleanup = await evaluate<{ markersRemoved: boolean }>(client, MARKER_CLEANUP_SCRIPT);
|
|
912
|
+
restoreResult = { focusRestored: false, markersRemoved: cleanup?.markersRemoved ?? false, scrollRestored: false };
|
|
913
|
+
} catch {
|
|
914
|
+
markerCleanupFailed = true;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// The element-bearing fact is `hasActiveElement[i]`, copied straight from
|
|
920
|
+
// the page-side sample's own `hasActiveElement` (a genuine
|
|
921
|
+
// `document.activeElement && document.activeElement !== document.body`).
|
|
922
|
+
// NEVER `s.id !== null` (id reflects only whether `FOCUS_INIT_SCRIPT`
|
|
923
|
+
// stamped a marker) and NEVER `!raw.isBody` (document.activeElement can be
|
|
924
|
+
// genuinely `null`, and `null === document.body` is false, so `!isBody`
|
|
925
|
+
// alone would misclassify a no-active sample as element-bearing).
|
|
926
|
+
// `hasActiveElement[i] === false` means the sampled active element
|
|
927
|
+
// genuinely was `document.body` OR `null` (nothing focused): it emits
|
|
928
|
+
// `backendNodeId: null` with no `identityUnresolved`, since there is no
|
|
929
|
+
// element whose identity could have failed to resolve. A real
|
|
930
|
+
// native-focusable element outside the candidate selector (e.g. a
|
|
931
|
+
// `contenteditable` form not matched by `[contenteditable="true"]`'s exact
|
|
932
|
+
// attribute form) can be tab-reached with `id === null` while still being a
|
|
933
|
+
// genuine element — `hasActiveElement[i]` is `true` there, so
|
|
934
|
+
// `resolvedIdentity` correctly emits `backendNodeId: null` +
|
|
935
|
+
// `identityUnresolved: true` (no marker to resolve). When the stop IS
|
|
936
|
+
// element-bearing, `resolvedIdentity` emits `backendNodeId: null` +
|
|
937
|
+
// `identityUnresolved: true` whenever there is no marker to resolve OR the
|
|
938
|
+
// marker→backendNodeId lookup came back empty (per I-3/I-5, never silently
|
|
939
|
+
// omitted) — `s.id !== null ? focusBackendById.get(s.id) : undefined`
|
|
940
|
+
// naturally yields `undefined` (unresolved) for an untagged element. The
|
|
941
|
+
// objectId-bridge backendNodeId `walk` resolves is a collector-private cycle
|
|
942
|
+
// key only and is deliberately NOT threaded here: the emitted FocusStop
|
|
943
|
+
// identity contract is unchanged — an untagged stop reports `backendNodeId:
|
|
944
|
+
// null` + `identityUnresolved: true` (no marker the cross-artifact join
|
|
945
|
+
// resolved), never a value derived from a per-sample handle.
|
|
946
|
+
const decorate = (stops: FocusStop[], hasActiveElement: boolean[]): FocusStop[] =>
|
|
947
|
+
stops.map((s, i) => ({
|
|
948
|
+
...s,
|
|
949
|
+
selector: sanitizeOrNull(s.selector),
|
|
950
|
+
role: sanitizeOrNull(s.role),
|
|
951
|
+
name: sanitizeOrNull(s.name, MAX_FOCUS_NAME_LEN),
|
|
952
|
+
...(hasActiveElement[i] ? resolvedIdentity(s.id !== null ? focusBackendById.get(s.id) : undefined) : { backendNodeId: null }),
|
|
953
|
+
}));
|
|
954
|
+
|
|
955
|
+
forward = decorate(forward, forwardHasActiveElement);
|
|
956
|
+
reverse = decorate(reverse, reverseHasActiveElement);
|
|
957
|
+
|
|
958
|
+
// `forward` is already decorated (see `decorate` above) with the honest
|
|
959
|
+
// `backendNodeId`/`identityUnresolved` pair resolved off this collector's
|
|
960
|
+
// marker/`DOM.describeNode` bridge, so each divergence entry carries the
|
|
961
|
+
// same stable join key (and the same identity-failure marker) its stop
|
|
962
|
+
// does — a downstream reader can join a divergence entry to `geometry.json`'s
|
|
963
|
+
// record for the same DOM node. Every divergence entry's stop has a
|
|
964
|
+
// non-null `domIndex`, which only a non-null `id` (a real tagged
|
|
965
|
+
// candidate) can produce, so every entry here is element-bearing.
|
|
966
|
+
const domOrderDivergence: FocusReport['domOrderDivergence'] = [];
|
|
967
|
+
let previousDomIndex: number | null = null;
|
|
968
|
+
for (const stop of forward) {
|
|
969
|
+
if (stop.domIndex !== null && previousDomIndex !== null && stop.domIndex < previousDomIndex) {
|
|
970
|
+
domOrderDivergence.push({
|
|
971
|
+
step: stop.step,
|
|
972
|
+
id: stop.id,
|
|
973
|
+
backendNodeId: stop.backendNodeId,
|
|
974
|
+
...(stop.identityUnresolved ? { identityUnresolved: true as const } : {}),
|
|
975
|
+
domIndex: stop.domIndex,
|
|
976
|
+
previousDomIndex,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
if (stop.domIndex !== null) previousDomIndex = stop.domIndex;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const visitedIds = new Set(forward.map((s) => s.id).filter((id): id is string => id !== null));
|
|
983
|
+
// Every `init.candidates` entry is a real element the init script tagged
|
|
984
|
+
// (element-bearing) — `resolvedIdentity` is always the right call, never
|
|
985
|
+
// the non-element `{ backendNodeId: null }` branch.
|
|
986
|
+
const unreachedFocusable = init.candidates
|
|
987
|
+
.filter((c) => !visitedIds.has(c.id))
|
|
988
|
+
.map((c) => ({ id: c.id, ...resolvedIdentity(focusBackendById.get(c.id)), selector: sanitizeOrNull(c.selector), rect: c.rect, visible: c.visible }));
|
|
989
|
+
|
|
990
|
+
// Every `init.clickableUnfocusable` entry is a real element the init
|
|
991
|
+
// script tagged (element-bearing) — same reasoning as `unreachedFocusable`.
|
|
992
|
+
const clickableUnfocusable = init.clickableUnfocusable.map((cl) => ({
|
|
993
|
+
...resolvedIdentity(clickableBackendById.get(cl.id)),
|
|
994
|
+
selector: sanitizeOrNull(cl.selector),
|
|
995
|
+
rect: cl.rect,
|
|
996
|
+
...(cl.cursorReadUnavailable ? { cursorReadUnavailable: true as const } : {}),
|
|
997
|
+
}));
|
|
998
|
+
|
|
999
|
+
// I-4/I-5 (Layer 2): `init` here is the value the init evaluate ITSELF
|
|
1000
|
+
// returned (a well-formed value, per the `available`/`unavailableReason`
|
|
1001
|
+
// gate above) -- so a MISSING or non-boolean `clickableTruncated` on it is
|
|
1002
|
+
// a malformed successful response, not a genuine "cap not reached"
|
|
1003
|
+
// observation. Gated on `available` for the same reason as scroll.ts's
|
|
1004
|
+
// `scrollContainersCountUnavailable`: when the traversal itself failed,
|
|
1005
|
+
// `init` is left at `EMPTY_INIT` and `clickableTruncated` is "missing" by
|
|
1006
|
+
// construction -- that failure is already fully reported via
|
|
1007
|
+
// `available`/`unavailableReason`, so flagging this on top of it would be
|
|
1008
|
+
// redundant noise, not a new fact.
|
|
1009
|
+
const clickableUnfocusableTruncationUnavailable = available && typeof init.clickableTruncated !== 'boolean';
|
|
1010
|
+
|
|
1011
|
+
const report: FocusReport = {
|
|
1012
|
+
available,
|
|
1013
|
+
...(unavailableReason ? { unavailableReason } : {}),
|
|
1014
|
+
forward,
|
|
1015
|
+
forwardTruncated,
|
|
1016
|
+
reverse,
|
|
1017
|
+
reverseTruncated,
|
|
1018
|
+
domOrderDivergence,
|
|
1019
|
+
unreachedFocusable,
|
|
1020
|
+
clickableUnfocusable,
|
|
1021
|
+
clickableUnfocusableTruncated: init.clickableTruncated ?? false,
|
|
1022
|
+
...(clickableUnfocusableTruncationUnavailable ? { clickableUnfocusableTruncationUnavailable: true as const } : {}),
|
|
1023
|
+
candidateCount: init.candidates.length,
|
|
1024
|
+
scope: {
|
|
1025
|
+
root: 'top-document',
|
|
1026
|
+
shadowDom: 'light-only',
|
|
1027
|
+
iframesPresent: init.iframesPresent,
|
|
1028
|
+
shadowHostsPresent: init.shadowHostsPresent,
|
|
1029
|
+
},
|
|
1030
|
+
restoration: {
|
|
1031
|
+
attempted: originCaptured || mutationStarted,
|
|
1032
|
+
focusRestored: restoreResult?.focusRestored ?? false,
|
|
1033
|
+
markersCleared: restoreResult?.markersRemoved ?? false,
|
|
1034
|
+
scrollRestored: restoreResult?.scrollRestored ?? false,
|
|
1035
|
+
...(caughtError ? { error: caughtError } : {}),
|
|
1036
|
+
...(markerCleanupFailed ? { markerCleanupFailed: true } : {}),
|
|
1037
|
+
},
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
ctx.write.json('focus.json', report);
|
|
1041
|
+
};
|