@silbercue/chrome 0.2.0
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/LICENSE +21 -0
- package/README.md +229 -0
- package/build/cache/a11y-tree.d.ts +252 -0
- package/build/cache/a11y-tree.js +1956 -0
- package/build/cache/index.d.ts +8 -0
- package/build/cache/index.js +4 -0
- package/build/cache/selector-cache.d.ts +47 -0
- package/build/cache/selector-cache.js +119 -0
- package/build/cache/session-defaults.d.ts +27 -0
- package/build/cache/session-defaults.js +130 -0
- package/build/cache/tab-state-cache.d.ts +39 -0
- package/build/cache/tab-state-cache.js +171 -0
- package/build/cdp/cdp-client.d.ts +25 -0
- package/build/cdp/cdp-client.js +146 -0
- package/build/cdp/chrome-launcher.d.ts +85 -0
- package/build/cdp/chrome-launcher.js +502 -0
- package/build/cdp/console-collector.d.ts +53 -0
- package/build/cdp/console-collector.js +147 -0
- package/build/cdp/debug.d.ts +1 -0
- package/build/cdp/debug.js +6 -0
- package/build/cdp/dialog-handler.d.ts +54 -0
- package/build/cdp/dialog-handler.js +129 -0
- package/build/cdp/dom-watcher.d.ts +45 -0
- package/build/cdp/dom-watcher.js +195 -0
- package/build/cdp/emulation.d.ts +12 -0
- package/build/cdp/emulation.js +17 -0
- package/build/cdp/index.d.ts +11 -0
- package/build/cdp/index.js +6 -0
- package/build/cdp/network-collector.d.ts +77 -0
- package/build/cdp/network-collector.js +257 -0
- package/build/cdp/protocol.d.ts +20 -0
- package/build/cdp/protocol.js +1 -0
- package/build/cdp/session-manager.d.ts +62 -0
- package/build/cdp/session-manager.js +205 -0
- package/build/cdp/settle.d.ts +16 -0
- package/build/cdp/settle.js +71 -0
- package/build/cli/license-commands.d.ts +19 -0
- package/build/cli/license-commands.js +199 -0
- package/build/cli/top-level-commands.d.ts +49 -0
- package/build/cli/top-level-commands.js +222 -0
- package/build/hooks/index.d.ts +2 -0
- package/build/hooks/index.js +1 -0
- package/build/hooks/pro-hooks.d.ts +126 -0
- package/build/hooks/pro-hooks.js +17 -0
- package/build/index.d.ts +4 -0
- package/build/index.js +86 -0
- package/build/license/free-tier-config.d.ts +14 -0
- package/build/license/free-tier-config.js +18 -0
- package/build/license/index.d.ts +4 -0
- package/build/license/index.js +2 -0
- package/build/license/license-status.d.ts +15 -0
- package/build/license/license-status.js +9 -0
- package/build/overlay/session-overlay.d.ts +22 -0
- package/build/overlay/session-overlay.js +372 -0
- package/build/plan/index.d.ts +7 -0
- package/build/plan/index.js +4 -0
- package/build/plan/plan-conditions.d.ts +12 -0
- package/build/plan/plan-conditions.js +242 -0
- package/build/plan/plan-executor.d.ts +49 -0
- package/build/plan/plan-executor.js +259 -0
- package/build/plan/plan-state-store.d.ts +24 -0
- package/build/plan/plan-state-store.js +43 -0
- package/build/plan/plan-variables.d.ts +16 -0
- package/build/plan/plan-variables.js +71 -0
- package/build/registry.d.ts +124 -0
- package/build/registry.js +884 -0
- package/build/server.d.ts +1 -0
- package/build/server.js +245 -0
- package/build/tools/click.d.ts +34 -0
- package/build/tools/click.js +293 -0
- package/build/tools/configure-session.d.ts +15 -0
- package/build/tools/configure-session.js +45 -0
- package/build/tools/console-logs.d.ts +18 -0
- package/build/tools/console-logs.js +44 -0
- package/build/tools/dom-snapshot.d.ts +13 -0
- package/build/tools/dom-snapshot.js +259 -0
- package/build/tools/element-utils.d.ts +23 -0
- package/build/tools/element-utils.js +133 -0
- package/build/tools/error-utils.d.ts +8 -0
- package/build/tools/error-utils.js +27 -0
- package/build/tools/evaluate.d.ts +34 -0
- package/build/tools/evaluate.js +217 -0
- package/build/tools/file-upload.d.ts +20 -0
- package/build/tools/file-upload.js +174 -0
- package/build/tools/fill-form.d.ts +39 -0
- package/build/tools/fill-form.js +256 -0
- package/build/tools/handle-dialog.d.ts +15 -0
- package/build/tools/handle-dialog.js +48 -0
- package/build/tools/index.d.ts +35 -0
- package/build/tools/index.js +18 -0
- package/build/tools/navigate.d.ts +18 -0
- package/build/tools/navigate.js +111 -0
- package/build/tools/network-monitor.d.ts +18 -0
- package/build/tools/network-monitor.js +66 -0
- package/build/tools/observe.d.ts +44 -0
- package/build/tools/observe.js +339 -0
- package/build/tools/press-key.d.ts +33 -0
- package/build/tools/press-key.js +155 -0
- package/build/tools/read-page.d.ts +22 -0
- package/build/tools/read-page.js +100 -0
- package/build/tools/run-plan.d.ts +205 -0
- package/build/tools/run-plan.js +215 -0
- package/build/tools/screenshot.d.ts +16 -0
- package/build/tools/screenshot.js +283 -0
- package/build/tools/scroll.d.ts +28 -0
- package/build/tools/scroll.js +143 -0
- package/build/tools/switch-tab.d.ts +26 -0
- package/build/tools/switch-tab.js +355 -0
- package/build/tools/tab-status.d.ts +7 -0
- package/build/tools/tab-status.js +50 -0
- package/build/tools/type.d.ts +31 -0
- package/build/tools/type.js +247 -0
- package/build/tools/virtual-desk.d.ts +7 -0
- package/build/tools/virtual-desk.js +108 -0
- package/build/tools/visual-constants.d.ts +3 -0
- package/build/tools/visual-constants.js +10 -0
- package/build/tools/wait-for.d.ts +26 -0
- package/build/tools/wait-for.js +323 -0
- package/build/transport/index.d.ts +3 -0
- package/build/transport/index.js +2 -0
- package/build/transport/pipe-transport.d.ts +18 -0
- package/build/transport/pipe-transport.js +63 -0
- package/build/transport/transport.d.ts +8 -0
- package/build/transport/transport.js +1 -0
- package/build/transport/websocket-transport.d.ts +22 -0
- package/build/transport/websocket-transport.js +200 -0
- package/build/types.d.ts +21 -0
- package/build/types.js +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,1956 @@
|
|
|
1
|
+
import { wrapCdpError } from "../tools/error-utils.js";
|
|
2
|
+
import { CLICKABLE_TAGS, CLICKABLE_ROLES, COMPUTED_STYLES } from "../tools/visual-constants.js";
|
|
3
|
+
import { EMULATED_WIDTH, EMULATED_HEIGHT } from "../cdp/emulation.js";
|
|
4
|
+
import { debug } from "../cdp/debug.js";
|
|
5
|
+
/** Strip hash fragment from URL for navigation comparison.
|
|
6
|
+
* Hash-only changes (anchor navigation) should NOT reset refs. */
|
|
7
|
+
function stripHash(url) {
|
|
8
|
+
const idx = url.indexOf("#");
|
|
9
|
+
return idx === -1 ? url : url.slice(0, idx);
|
|
10
|
+
}
|
|
11
|
+
// --- Constants ---
|
|
12
|
+
/** BUG-009: Safety cap to prevent oversized responses that MCP clients truncate silently.
|
|
13
|
+
* ~200KB chars ≈ 50K tokens. Large enough for normal pages, prevents 855KB+ responses. */
|
|
14
|
+
const DEFAULT_MAX_TOKENS = 50_000;
|
|
15
|
+
const INTERACTIVE_ROLES = new Set([
|
|
16
|
+
"button",
|
|
17
|
+
"link",
|
|
18
|
+
"textbox",
|
|
19
|
+
"searchbox",
|
|
20
|
+
"combobox",
|
|
21
|
+
"checkbox",
|
|
22
|
+
"radio",
|
|
23
|
+
"switch",
|
|
24
|
+
"slider",
|
|
25
|
+
"spinbutton",
|
|
26
|
+
"tab",
|
|
27
|
+
"menuitem",
|
|
28
|
+
"menuitemcheckbox",
|
|
29
|
+
"menuitemradio",
|
|
30
|
+
"option",
|
|
31
|
+
"treeitem",
|
|
32
|
+
]);
|
|
33
|
+
// Story 13a.2: Roles included in enriched compact snapshot for LLM orientation
|
|
34
|
+
const CONTEXT_ROLES = new Set([
|
|
35
|
+
"heading",
|
|
36
|
+
"alert",
|
|
37
|
+
"status",
|
|
38
|
+
]);
|
|
39
|
+
const CONTAINER_ROLES = new Set([
|
|
40
|
+
"generic", "group", "region", "list", "listbox",
|
|
41
|
+
"navigation", "complementary", "main", "banner",
|
|
42
|
+
"contentinfo", "form", "search", "toolbar", "tablist",
|
|
43
|
+
"menu", "menubar", "tree", "grid", "table",
|
|
44
|
+
"rowgroup", "row", "treegrid",
|
|
45
|
+
]);
|
|
46
|
+
const CONTENT_ROLES = new Set([
|
|
47
|
+
"heading", "paragraph", "text", "StaticText", "img",
|
|
48
|
+
"figure", "blockquote", "code", "listitem", "cell",
|
|
49
|
+
"columnheader", "rowheader", "caption", "definition",
|
|
50
|
+
"term", "note", "math", "status", "log", "marquee",
|
|
51
|
+
"timer", "alert",
|
|
52
|
+
]);
|
|
53
|
+
function classifyElement(role) {
|
|
54
|
+
if (INTERACTIVE_ROLES.has(role))
|
|
55
|
+
return "interactive";
|
|
56
|
+
if (CONTENT_ROLES.has(role))
|
|
57
|
+
return "content";
|
|
58
|
+
return "container"; // Default: everything else is container
|
|
59
|
+
}
|
|
60
|
+
/** Token estimation for structured A11y output.
|
|
61
|
+
* Ratio ~3.5 chars/token accounts for short tokens (brackets, refs, keywords).
|
|
62
|
+
* FR-H8: Tighter ratio ensures max_tokens is a reliable upper bound. */
|
|
63
|
+
function estimateTokens(text) {
|
|
64
|
+
if (text.length === 0)
|
|
65
|
+
return 0;
|
|
66
|
+
return Math.ceil(text.length / 3.5);
|
|
67
|
+
}
|
|
68
|
+
const LANDMARK_ROLES = new Set([
|
|
69
|
+
"banner",
|
|
70
|
+
"navigation",
|
|
71
|
+
"main",
|
|
72
|
+
"complementary",
|
|
73
|
+
"contentinfo",
|
|
74
|
+
"search",
|
|
75
|
+
"form",
|
|
76
|
+
"region",
|
|
77
|
+
]);
|
|
78
|
+
// Story 13a.2: Extract NodeInfo from AXNode including widget-state properties
|
|
79
|
+
function extractNodeInfo(node) {
|
|
80
|
+
const info = {
|
|
81
|
+
role: node.role?.value ?? "",
|
|
82
|
+
name: node.name?.value ?? "",
|
|
83
|
+
};
|
|
84
|
+
if (node.properties) {
|
|
85
|
+
for (const prop of node.properties) {
|
|
86
|
+
switch (prop.name) {
|
|
87
|
+
case "expanded":
|
|
88
|
+
info.expanded = prop.value.value;
|
|
89
|
+
break;
|
|
90
|
+
case "hasPopup":
|
|
91
|
+
info.hasPopup = prop.value.value;
|
|
92
|
+
break;
|
|
93
|
+
case "checked":
|
|
94
|
+
info.checked = prop.value.value;
|
|
95
|
+
break;
|
|
96
|
+
case "pressed":
|
|
97
|
+
info.pressed = prop.value.value;
|
|
98
|
+
break;
|
|
99
|
+
case "disabled":
|
|
100
|
+
info.disabled = prop.value.value;
|
|
101
|
+
break;
|
|
102
|
+
case "focusable":
|
|
103
|
+
info.focusable = prop.value.value;
|
|
104
|
+
break;
|
|
105
|
+
case "level":
|
|
106
|
+
info.level = prop.value.value;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return info;
|
|
112
|
+
}
|
|
113
|
+
export class A11yTreeProcessor {
|
|
114
|
+
refMap = new Map(); // backendDOMNodeId → refNumber
|
|
115
|
+
reverseMap = new Map(); // refNumber → backendDOMNodeId
|
|
116
|
+
// Story 13a.2: Extended with widget-state props for pre-click classification
|
|
117
|
+
nodeInfoMap = new Map(); // backendDOMNodeId → NodeInfo
|
|
118
|
+
sessionNodeMap = new Map(); // sessionId → Set<backendDOMNodeId>
|
|
119
|
+
nextRef = 1;
|
|
120
|
+
lastUrl = "";
|
|
121
|
+
// Precomputed cache state (Story 7.4)
|
|
122
|
+
_precomputedNodes = null;
|
|
123
|
+
_precomputedUrl = "";
|
|
124
|
+
_precomputedSessionId = "";
|
|
125
|
+
_precomputedDepth = 3;
|
|
126
|
+
// Story 13.1: Ambient Page Context — cache version counter
|
|
127
|
+
// Increments on every cache change (refresh, reset, invalidation).
|
|
128
|
+
// Registry compares this against _lastSentVersion to decide whether to attach page context.
|
|
129
|
+
_cacheVersion = 0;
|
|
130
|
+
/** Story 13.1: Current cache version — increments on every state change */
|
|
131
|
+
get cacheVersion() {
|
|
132
|
+
return this._cacheVersion;
|
|
133
|
+
}
|
|
134
|
+
reset() {
|
|
135
|
+
this.refMap.clear();
|
|
136
|
+
this.reverseMap.clear();
|
|
137
|
+
this.nodeInfoMap.clear();
|
|
138
|
+
this.sessionNodeMap.clear();
|
|
139
|
+
this.nextRef = 1;
|
|
140
|
+
this.lastUrl = "";
|
|
141
|
+
this._cacheVersion++;
|
|
142
|
+
this.invalidatePrecomputed();
|
|
143
|
+
}
|
|
144
|
+
/** Invalidiert den Precomputed-Cache (z.B. nach Navigation oder Reconnect) */
|
|
145
|
+
invalidatePrecomputed() {
|
|
146
|
+
this._precomputedNodes = null;
|
|
147
|
+
this._precomputedUrl = "";
|
|
148
|
+
this._precomputedSessionId = "";
|
|
149
|
+
this._precomputedDepth = 3;
|
|
150
|
+
this._cacheVersion++;
|
|
151
|
+
}
|
|
152
|
+
/** Hintergrund-Refresh: Laedt A11y-Tree und speichert als Cache */
|
|
153
|
+
async refreshPrecomputed(cdpClient, sessionId, sessionManager) {
|
|
154
|
+
// 1. URL pruefen — wenn sich die Basis-URL (ohne Hash) geaendert hat, reset() aufrufen
|
|
155
|
+
// Hash-only-Aenderungen (z.B. /#step-alpha → /#step-beta) behalten Refs,
|
|
156
|
+
// da das DOM bei Anchor-Navigation identisch bleibt.
|
|
157
|
+
const urlResult = await cdpClient.send("Runtime.evaluate", { expression: "document.URL", returnByValue: true }, sessionId);
|
|
158
|
+
const currentUrl = urlResult.result.value;
|
|
159
|
+
if (stripHash(currentUrl) !== stripHash(this.lastUrl)) {
|
|
160
|
+
this.reset();
|
|
161
|
+
}
|
|
162
|
+
this.lastUrl = currentUrl;
|
|
163
|
+
// 2. A11y-Tree via CDP laden (same depth as default getTree)
|
|
164
|
+
const result = await cdpClient.send("Accessibility.getFullAXTree", { depth: 3 }, sessionId);
|
|
165
|
+
if (!result.nodes || result.nodes.length === 0)
|
|
166
|
+
return;
|
|
167
|
+
// 3. Ref-IDs zuweisen (STABIL — bestehende Refs bleiben, neue bekommen neue Nummern)
|
|
168
|
+
for (const node of result.nodes) {
|
|
169
|
+
if (node.ignored || node.backendDOMNodeId === undefined)
|
|
170
|
+
continue;
|
|
171
|
+
if (!this.refMap.has(node.backendDOMNodeId)) {
|
|
172
|
+
const refNum = this.nextRef++;
|
|
173
|
+
this.refMap.set(node.backendDOMNodeId, refNum);
|
|
174
|
+
this.reverseMap.set(refNum, node.backendDOMNodeId);
|
|
175
|
+
}
|
|
176
|
+
this.nodeInfoMap.set(node.backendDOMNodeId, extractNodeInfo(node));
|
|
177
|
+
if (!this.sessionNodeMap.has(sessionId)) {
|
|
178
|
+
this.sessionNodeMap.set(sessionId, new Set());
|
|
179
|
+
}
|
|
180
|
+
this.sessionNodeMap.get(sessionId).add(node.backendDOMNodeId);
|
|
181
|
+
sessionManager?.registerNode(node.backendDOMNodeId, sessionId);
|
|
182
|
+
}
|
|
183
|
+
// 4. Cache speichern
|
|
184
|
+
this._precomputedNodes = result.nodes;
|
|
185
|
+
this._precomputedUrl = currentUrl;
|
|
186
|
+
this._precomputedSessionId = sessionId;
|
|
187
|
+
this._precomputedDepth = 3;
|
|
188
|
+
this._cacheVersion++;
|
|
189
|
+
// 5. Register root node for Accessibility.nodesUpdated tracking (Story 13a.2 fix).
|
|
190
|
+
// getFullAXTree does NOT populate Chrome's nodes_requested_ set, so nodesUpdated
|
|
191
|
+
// never fires. A single getRootAXNode call registers the root — 1 extra CDP call.
|
|
192
|
+
try {
|
|
193
|
+
await cdpClient.send("Accessibility.getRootAXNode", {}, sessionId);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Non-critical — nodesUpdated won't work but everything else still does
|
|
197
|
+
debug("A11yTreeProcessor: getRootAXNode failed (nodesUpdated tracking disabled)");
|
|
198
|
+
}
|
|
199
|
+
// 6. FR-004 + FR-005: Enrich nodes with HTML attributes and click listeners
|
|
200
|
+
await this._enrichNodeMetadata(cdpClient, sessionId);
|
|
201
|
+
// Phase 3: FR-001 — detect scrollable containers (1 CDP call total)
|
|
202
|
+
try {
|
|
203
|
+
const scrollResult = await cdpClient.send("Runtime.evaluate", {
|
|
204
|
+
expression: `JSON.stringify([...document.querySelectorAll('*')].filter(el => { const s = getComputedStyle(el); return (s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight; }).map(el => el.id).filter(Boolean))`,
|
|
205
|
+
returnByValue: true,
|
|
206
|
+
}, sessionId);
|
|
207
|
+
const scrollableIds = JSON.parse(scrollResult.result.value || "[]");
|
|
208
|
+
for (const id of scrollableIds) {
|
|
209
|
+
for (const [backendNodeId, info] of this.nodeInfoMap) {
|
|
210
|
+
if (info.htmlId === id) {
|
|
211
|
+
info.isScrollable = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Non-critical — scrollable annotation is nice-to-have
|
|
219
|
+
}
|
|
220
|
+
debug("A11yTreeProcessor: precomputed cache refreshed, %d nodes cached (v%d)", result.nodes.length, this._cacheVersion);
|
|
221
|
+
}
|
|
222
|
+
/** Prueft ob ein gueltiger Precomputed-Cache vorliegt */
|
|
223
|
+
hasPrecomputed(sessionId) {
|
|
224
|
+
return this._precomputedNodes !== null
|
|
225
|
+
&& this._precomputedSessionId === sessionId;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* H1: Remove all node references for a detached OOPIF session.
|
|
229
|
+
* Called when an OOPIF frame navigates away or is destroyed.
|
|
230
|
+
*/
|
|
231
|
+
removeNodesForSession(sessionId) {
|
|
232
|
+
const nodeIds = this.sessionNodeMap.get(sessionId);
|
|
233
|
+
if (!nodeIds)
|
|
234
|
+
return;
|
|
235
|
+
for (const backendNodeId of nodeIds) {
|
|
236
|
+
const refNum = this.refMap.get(backendNodeId);
|
|
237
|
+
if (refNum !== undefined) {
|
|
238
|
+
this.reverseMap.delete(refNum);
|
|
239
|
+
}
|
|
240
|
+
this.refMap.delete(backendNodeId);
|
|
241
|
+
this.nodeInfoMap.delete(backendNodeId);
|
|
242
|
+
}
|
|
243
|
+
this.sessionNodeMap.delete(sessionId);
|
|
244
|
+
}
|
|
245
|
+
resolveRef(ref) {
|
|
246
|
+
const match = ref.match(/^e(\d+)$/);
|
|
247
|
+
if (!match)
|
|
248
|
+
return undefined;
|
|
249
|
+
const refNum = parseInt(match[1], 10);
|
|
250
|
+
return this.reverseMap.get(refNum);
|
|
251
|
+
}
|
|
252
|
+
getNodeInfo(backendNodeId) {
|
|
253
|
+
return this.nodeInfoMap.get(backendNodeId);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* UX-001: Find an element by visible text (name). Returns ref string and backendNodeId.
|
|
257
|
+
* Matching priority: exact → case-insensitive exact → partial substring.
|
|
258
|
+
* Within each tier, interactive roles (button, link, etc.) are preferred.
|
|
259
|
+
*/
|
|
260
|
+
findByText(text) {
|
|
261
|
+
if (!text || this.reverseMap.size === 0)
|
|
262
|
+
return null;
|
|
263
|
+
const lower = text.toLowerCase();
|
|
264
|
+
const exact = [];
|
|
265
|
+
const iexact = [];
|
|
266
|
+
const partial = [];
|
|
267
|
+
for (const [refNum, backendNodeId] of this.reverseMap) {
|
|
268
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
269
|
+
if (!info || !info.name)
|
|
270
|
+
continue;
|
|
271
|
+
const interactive = INTERACTIVE_ROLES.has(info.role) || !!info.isClickable;
|
|
272
|
+
if (info.name === text) {
|
|
273
|
+
exact.push({ refNum, backendNodeId, interactive });
|
|
274
|
+
}
|
|
275
|
+
else if (info.name.toLowerCase() === lower) {
|
|
276
|
+
iexact.push({ refNum, backendNodeId, interactive });
|
|
277
|
+
}
|
|
278
|
+
else if (info.name.toLowerCase().includes(lower)) {
|
|
279
|
+
partial.push({ refNum, backendNodeId, interactive });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Pick best candidate: prefer interactive, then lowest refNum (most stable)
|
|
283
|
+
const pick = (candidates) => {
|
|
284
|
+
const interactive = candidates.filter(c => c.interactive);
|
|
285
|
+
const best = (interactive.length > 0 ? interactive : candidates)
|
|
286
|
+
.sort((a, b) => a.refNum - b.refNum)[0];
|
|
287
|
+
return best ? { ref: `e${best.refNum}`, backendNodeId: best.backendNodeId } : null;
|
|
288
|
+
};
|
|
289
|
+
return pick(exact) ?? pick(iexact) ?? pick(partial);
|
|
290
|
+
}
|
|
291
|
+
/** Returns true if the ref map has been populated (i.e. getTree was called at least once). */
|
|
292
|
+
hasRefs() {
|
|
293
|
+
return this.refMap.size > 0;
|
|
294
|
+
}
|
|
295
|
+
/** Number of currently assigned refs (for DOM fingerprinting, Story 7.5) */
|
|
296
|
+
get refCount() {
|
|
297
|
+
return this.refMap.size;
|
|
298
|
+
}
|
|
299
|
+
/** Current page URL (for on-the-fly fingerprint computation, Story 7.5 H1 fix) */
|
|
300
|
+
get currentUrl() {
|
|
301
|
+
return this.lastUrl;
|
|
302
|
+
}
|
|
303
|
+
getRefForBackendNodeId(backendNodeId) {
|
|
304
|
+
const refNum = this.refMap.get(backendNodeId);
|
|
305
|
+
return refNum !== undefined ? `e${refNum}` : undefined;
|
|
306
|
+
}
|
|
307
|
+
async fetchVisualData(cdpClient, sessionId) {
|
|
308
|
+
const snapshot = await cdpClient.send("DOMSnapshot.captureSnapshot", {
|
|
309
|
+
computedStyles: [...COMPUTED_STYLES],
|
|
310
|
+
includeDOMRects: true,
|
|
311
|
+
includeBlendedBackgroundColors: true,
|
|
312
|
+
includePaintOrder: true,
|
|
313
|
+
}, sessionId);
|
|
314
|
+
const visualMap = new Map();
|
|
315
|
+
if (!snapshot.documents || snapshot.documents.length === 0) {
|
|
316
|
+
return visualMap;
|
|
317
|
+
}
|
|
318
|
+
const doc = snapshot.documents[0];
|
|
319
|
+
const strings = snapshot.strings;
|
|
320
|
+
// Build layout index map: nodeIndex → layoutIndex
|
|
321
|
+
const layoutMap = new Map();
|
|
322
|
+
for (let li = 0; li < doc.layout.nodeIndex.length; li++) {
|
|
323
|
+
layoutMap.set(doc.layout.nodeIndex[li], li);
|
|
324
|
+
}
|
|
325
|
+
const totalNodes = doc.nodes.backendNodeId.length;
|
|
326
|
+
for (let ni = 0; ni < totalNodes; ni++) {
|
|
327
|
+
const backendNodeId = doc.nodes.backendNodeId[ni];
|
|
328
|
+
// Only process nodes that have a ref (i.e., are in the A11y tree)
|
|
329
|
+
if (!this.refMap.has(backendNodeId))
|
|
330
|
+
continue;
|
|
331
|
+
const li = layoutMap.get(ni);
|
|
332
|
+
// No layout → hidden element
|
|
333
|
+
if (li === undefined) {
|
|
334
|
+
visualMap.set(backendNodeId, {
|
|
335
|
+
bounds: { x: 0, y: 0, w: 0, h: 0 },
|
|
336
|
+
isClickable: this.computeIsClickable(ni, backendNodeId, doc, strings),
|
|
337
|
+
isVisible: false,
|
|
338
|
+
});
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
// Read bounds
|
|
342
|
+
const boundsArr = doc.layout.bounds[li];
|
|
343
|
+
if (!boundsArr || boundsArr.length < 4)
|
|
344
|
+
continue;
|
|
345
|
+
const [x, y, w, h] = boundsArr;
|
|
346
|
+
// Read computed styles: display, visibility are at indices 0, 1
|
|
347
|
+
const styleProps = doc.layout.styles[li] ?? [];
|
|
348
|
+
const displayVal = this.getSnapshotString(strings, styleProps[0]);
|
|
349
|
+
const visibilityVal = this.getSnapshotString(strings, styleProps[1]);
|
|
350
|
+
// isVisible calculation
|
|
351
|
+
const isVisible = displayVal !== "none" &&
|
|
352
|
+
visibilityVal !== "hidden" &&
|
|
353
|
+
w >= 1 && h >= 1 &&
|
|
354
|
+
x + w > 0 && y + h > 0 &&
|
|
355
|
+
x < EMULATED_WIDTH && y < EMULATED_HEIGHT;
|
|
356
|
+
const isClickable = this.computeIsClickable(ni, backendNodeId, doc, strings);
|
|
357
|
+
visualMap.set(backendNodeId, {
|
|
358
|
+
bounds: {
|
|
359
|
+
x: Math.round(x),
|
|
360
|
+
y: Math.round(y),
|
|
361
|
+
w: Math.round(w),
|
|
362
|
+
h: Math.round(h),
|
|
363
|
+
},
|
|
364
|
+
isClickable,
|
|
365
|
+
isVisible,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return visualMap;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* FR-H5: Enrich nodeInfoMap with HTML attributes (IDs, onclick) and event listeners.
|
|
372
|
+
* Phase 1: DOM.describeNode for HTML IDs + inline onclick detection
|
|
373
|
+
* Phase 2: DOMDebugger.getEventListeners for non-interactive nodes (mousedown, click, pointerdown)
|
|
374
|
+
* Called from both refreshPrecomputed() and getTree() so read_page always has full data.
|
|
375
|
+
*/
|
|
376
|
+
async _enrichNodeMetadata(cdpClient, sessionId) {
|
|
377
|
+
const POTENTIALLY_CLICKABLE_ROLES = new Set(["columnheader", "rowheader", "cell", "generic", "listitem"]);
|
|
378
|
+
try {
|
|
379
|
+
const interactiveNodes = [];
|
|
380
|
+
const checkClickNodes = [];
|
|
381
|
+
for (const [backendNodeId, info] of this.nodeInfoMap) {
|
|
382
|
+
if (INTERACTIVE_ROLES.has(info.role) || CONTEXT_ROLES.has(info.role)) {
|
|
383
|
+
interactiveNodes.push(backendNodeId);
|
|
384
|
+
}
|
|
385
|
+
else if (POTENTIALLY_CLICKABLE_ROLES.has(info.role)) {
|
|
386
|
+
interactiveNodes.push(backendNodeId);
|
|
387
|
+
checkClickNodes.push(backendNodeId);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Phase 1: Batch DOM.describeNode — extract HTML IDs + inline onclick
|
|
391
|
+
if (interactiveNodes.length > 0 && interactiveNodes.length <= 500) {
|
|
392
|
+
await Promise.allSettled(interactiveNodes.map(async (backendNodeId) => {
|
|
393
|
+
try {
|
|
394
|
+
const desc = await cdpClient.send("DOM.describeNode", { backendNodeId, depth: 0 }, sessionId);
|
|
395
|
+
const attrs = desc.node?.attributes;
|
|
396
|
+
if (!attrs)
|
|
397
|
+
return;
|
|
398
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
399
|
+
if (!info)
|
|
400
|
+
return;
|
|
401
|
+
for (let i = 0; i < attrs.length; i += 2) {
|
|
402
|
+
if (attrs[i] === "id" && attrs[i + 1]) {
|
|
403
|
+
info.htmlId = attrs[i + 1];
|
|
404
|
+
}
|
|
405
|
+
if (attrs[i] === "onclick") {
|
|
406
|
+
info.isClickable = true;
|
|
407
|
+
}
|
|
408
|
+
if (attrs[i] === "target" && attrs[i + 1]) {
|
|
409
|
+
info.linkTarget = attrs[i + 1];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch { /* ignore — text nodes etc. don't support describeNode */ }
|
|
414
|
+
}));
|
|
415
|
+
}
|
|
416
|
+
// Phase 2: DOMDebugger.getEventListeners for non-interactive nodes without onclick attribute.
|
|
417
|
+
// Detects addEventListener, React synthetic events, jQuery — anything that registered a click handler.
|
|
418
|
+
const clickCandidates = checkClickNodes.filter(id => !this.nodeInfoMap.get(id)?.isClickable);
|
|
419
|
+
if (clickCandidates.length > 0 && clickCandidates.length <= 200) {
|
|
420
|
+
await Promise.allSettled(clickCandidates.map(async (backendNodeId) => {
|
|
421
|
+
try {
|
|
422
|
+
const resolved = await cdpClient.send("DOM.resolveNode", { backendNodeId }, sessionId);
|
|
423
|
+
const objectId = resolved.object?.objectId;
|
|
424
|
+
if (!objectId)
|
|
425
|
+
return;
|
|
426
|
+
const result = await cdpClient.send("DOMDebugger.getEventListeners", { objectId }, sessionId);
|
|
427
|
+
if (result.listeners?.some(l => l.type === "click" || l.type === "mousedown" || l.type === "pointerdown")) {
|
|
428
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
429
|
+
if (info)
|
|
430
|
+
info.isClickable = true;
|
|
431
|
+
}
|
|
432
|
+
await cdpClient.send("Runtime.releaseObject", { objectId }, sessionId).catch(() => { });
|
|
433
|
+
}
|
|
434
|
+
catch { /* ignore — some nodes can't be resolved */ }
|
|
435
|
+
}));
|
|
436
|
+
}
|
|
437
|
+
// Phase 3: FR-H5 — Enrich unnamed clickable generics with innerText.
|
|
438
|
+
// Separate pass with fresh resolveNode to avoid stale objectId issues.
|
|
439
|
+
const unnamedClickables = [...this.nodeInfoMap.entries()]
|
|
440
|
+
.filter(([, info]) => info.isClickable && !info.name && POTENTIALLY_CLICKABLE_ROLES.has(info.role));
|
|
441
|
+
if (unnamedClickables.length > 0 && unnamedClickables.length <= 100) {
|
|
442
|
+
await Promise.allSettled(unnamedClickables.map(async ([backendNodeId, info]) => {
|
|
443
|
+
try {
|
|
444
|
+
const resolved = await cdpClient.send("DOM.resolveNode", { backendNodeId }, sessionId);
|
|
445
|
+
const oid = resolved.object?.objectId;
|
|
446
|
+
if (!oid)
|
|
447
|
+
return;
|
|
448
|
+
// FR-021: Fetch truncated text AND full length so formatLine can show a truncation marker.
|
|
449
|
+
// Separator \x00 avoids JSON/object marshalling overhead — one string roundtrip, simple split.
|
|
450
|
+
const textResult = await cdpClient.send("Runtime.callFunctionOn", { functionDeclaration: "function(){const t=(this.innerText||this.textContent||'');return t.slice(0,80)+'\\x00'+t.length}", objectId: oid, returnByValue: true }, sessionId);
|
|
451
|
+
if (textResult.result.value) {
|
|
452
|
+
const sep = textResult.result.value.indexOf("\x00");
|
|
453
|
+
if (sep >= 0) {
|
|
454
|
+
const truncated = textResult.result.value.slice(0, sep);
|
|
455
|
+
const fullLength = parseInt(textResult.result.value.slice(sep + 1), 10);
|
|
456
|
+
if (truncated) {
|
|
457
|
+
info.name = truncated;
|
|
458
|
+
if (Number.isFinite(fullLength) && fullLength > truncated.length) {
|
|
459
|
+
info.nameFullLength = fullLength;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
info.name = textResult.result.value; // Legacy fallback
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await cdpClient.send("Runtime.releaseObject", { objectId: oid }, sessionId).catch(() => { });
|
|
468
|
+
}
|
|
469
|
+
catch { /* non-critical */ }
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// Non-critical — IDs and clickability are nice-to-have
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
computeIsClickable(nodeIndex, backendNodeId, doc, strings) {
|
|
478
|
+
// Tag from DOMSnapshot
|
|
479
|
+
const tag = this.getSnapshotString(strings, doc.nodes.nodeName[nodeIndex]);
|
|
480
|
+
if (CLICKABLE_TAGS.has(tag))
|
|
481
|
+
return true;
|
|
482
|
+
// Role from A11y tree nodeInfoMap
|
|
483
|
+
const nodeInfo = this.nodeInfoMap.get(backendNodeId);
|
|
484
|
+
if (nodeInfo && CLICKABLE_ROLES.has(nodeInfo.role))
|
|
485
|
+
return true;
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
getSnapshotString(strings, index) {
|
|
489
|
+
if (index === undefined || index < 0 || index >= strings.length)
|
|
490
|
+
return "";
|
|
491
|
+
return strings[index];
|
|
492
|
+
}
|
|
493
|
+
findClosestRef(ref, roleFilter) {
|
|
494
|
+
const match = ref.match(/^e(\d+)$/);
|
|
495
|
+
if (!match || this.reverseMap.size === 0)
|
|
496
|
+
return null;
|
|
497
|
+
const requested = parseInt(match[1], 10);
|
|
498
|
+
// Build candidate list, optionally filtered by role
|
|
499
|
+
const candidates = [];
|
|
500
|
+
for (const [refNum, backendNodeId] of this.reverseMap.entries()) {
|
|
501
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
502
|
+
const role = info?.role ?? "";
|
|
503
|
+
if (roleFilter && !roleFilter.has(role))
|
|
504
|
+
continue;
|
|
505
|
+
candidates.push({ refNum, backendNodeId, role, name: info?.name ?? "" });
|
|
506
|
+
}
|
|
507
|
+
if (candidates.length === 0)
|
|
508
|
+
return null;
|
|
509
|
+
let closest = candidates[0];
|
|
510
|
+
let minDist = Math.abs(requested - closest.refNum);
|
|
511
|
+
for (const c of candidates) {
|
|
512
|
+
const dist = Math.abs(requested - c.refNum);
|
|
513
|
+
if (dist < minDist) {
|
|
514
|
+
closest = c;
|
|
515
|
+
minDist = dist;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
ref: `e${closest.refNum}`,
|
|
520
|
+
role: closest.role,
|
|
521
|
+
name: closest.name,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
async getTree(cdpClient, sessionId, options = {}, sessionManager) {
|
|
525
|
+
const depth = options.depth ?? 3;
|
|
526
|
+
const filter = options.filter ?? "interactive";
|
|
527
|
+
// FR-002: Separate CDP fetch depth from display depth.
|
|
528
|
+
// Interactive/visual filters need deeper fetch to find elements nested beyond display depth.
|
|
529
|
+
// "all" fetches depth+2 so leaf nodes' text children (StaticText) are always included —
|
|
530
|
+
// without this, elements like <strong> at the depth limit appear empty.
|
|
531
|
+
// "landmark" uses moderate depth.
|
|
532
|
+
const cdpFetchDepth = (filter === "interactive" || filter === "visual")
|
|
533
|
+
? Math.max(depth, 10)
|
|
534
|
+
: filter === "landmark"
|
|
535
|
+
? Math.max(depth, 6)
|
|
536
|
+
: depth + 2;
|
|
537
|
+
// Navigation detection — reset refs on real navigation (path change),
|
|
538
|
+
// but preserve refs on hash-only changes (anchor navigation).
|
|
539
|
+
const urlResult = await cdpClient.send("Runtime.evaluate", { expression: "document.URL", returnByValue: true }, sessionId);
|
|
540
|
+
const currentUrl = urlResult.result.value;
|
|
541
|
+
if (stripHash(currentUrl) !== stripHash(this.lastUrl)) {
|
|
542
|
+
this.refMap.clear();
|
|
543
|
+
this.reverseMap.clear();
|
|
544
|
+
this.nodeInfoMap.clear();
|
|
545
|
+
this.nextRef = 1;
|
|
546
|
+
this.invalidatePrecomputed();
|
|
547
|
+
}
|
|
548
|
+
this.lastUrl = currentUrl;
|
|
549
|
+
// Precomputed cache check — bypass CDP call if cache is valid (Story 7.4)
|
|
550
|
+
// Subtree queries (options.ref) always load fresh — cached tree may not have full depth
|
|
551
|
+
// M1: Depth mismatch → cache miss (cached depth must be >= requested depth)
|
|
552
|
+
// Story 13a.2 fix: fresh=true bypasses cache (read_page after SPA navigation)
|
|
553
|
+
let nodes;
|
|
554
|
+
if (!options.fresh
|
|
555
|
+
&& this._precomputedNodes
|
|
556
|
+
&& this._precomputedSessionId === sessionId
|
|
557
|
+
&& currentUrl === this._precomputedUrl
|
|
558
|
+
&& !options.ref
|
|
559
|
+
&& cdpFetchDepth <= this._precomputedDepth) {
|
|
560
|
+
nodes = this._precomputedNodes;
|
|
561
|
+
debug("A11yTreeProcessor: precomputed cache hit");
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// Fetch A11y tree from CDP — main frame (fallback / cache miss)
|
|
565
|
+
// FR-002: Use cdpFetchDepth (not display depth) so interactive/visual filters find deeply nested elements
|
|
566
|
+
const result = await cdpClient.send("Accessibility.getFullAXTree", { depth: cdpFetchDepth }, sessionId);
|
|
567
|
+
nodes = result.nodes;
|
|
568
|
+
// H1: Prime precomputed cache on fallback (AC #5)
|
|
569
|
+
// Only prime if this is a full-tree query (no ref) with valid nodes
|
|
570
|
+
if (!options.ref && nodes && nodes.length > 0) {
|
|
571
|
+
this._precomputedNodes = nodes;
|
|
572
|
+
this._precomputedUrl = currentUrl;
|
|
573
|
+
this._precomputedSessionId = sessionId;
|
|
574
|
+
this._precomputedDepth = cdpFetchDepth;
|
|
575
|
+
debug("A11yTreeProcessor: cache primed from fallback, %d nodes", nodes.length);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (!nodes || nodes.length === 0) {
|
|
579
|
+
const emptyText = this.formatHeader("", 0, filter, depth);
|
|
580
|
+
return {
|
|
581
|
+
text: emptyText,
|
|
582
|
+
refCount: 0,
|
|
583
|
+
depth,
|
|
584
|
+
tokenCount: estimateTokens(emptyText),
|
|
585
|
+
pageUrl: this.lastUrl,
|
|
586
|
+
...(filter === "visual" ? { hasVisualData: false } : {}),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
// Build nodeId → AXNode map
|
|
590
|
+
const nodeMap = new Map();
|
|
591
|
+
for (const node of nodes) {
|
|
592
|
+
nodeMap.set(node.nodeId, node);
|
|
593
|
+
}
|
|
594
|
+
// Assign refs to all non-ignored nodes with backendDOMNodeId (main frame)
|
|
595
|
+
for (const node of nodes) {
|
|
596
|
+
if (node.ignored || node.backendDOMNodeId === undefined)
|
|
597
|
+
continue;
|
|
598
|
+
if (!this.refMap.has(node.backendDOMNodeId)) {
|
|
599
|
+
const refNum = this.nextRef++;
|
|
600
|
+
this.refMap.set(node.backendDOMNodeId, refNum);
|
|
601
|
+
this.reverseMap.set(refNum, node.backendDOMNodeId);
|
|
602
|
+
}
|
|
603
|
+
// Always update nodeInfoMap with latest role/name
|
|
604
|
+
this.nodeInfoMap.set(node.backendDOMNodeId, extractNodeInfo(node));
|
|
605
|
+
// Track which session owns this node (H1: for cleanup on detach)
|
|
606
|
+
if (!this.sessionNodeMap.has(sessionId)) {
|
|
607
|
+
this.sessionNodeMap.set(sessionId, new Set());
|
|
608
|
+
}
|
|
609
|
+
this.sessionNodeMap.get(sessionId).add(node.backendDOMNodeId);
|
|
610
|
+
// Register node with SessionManager for main frame
|
|
611
|
+
sessionManager?.registerNode(node.backendDOMNodeId, sessionId);
|
|
612
|
+
}
|
|
613
|
+
// Fetch OOPIF A11y trees if SessionManager is available
|
|
614
|
+
const oopifSections = [];
|
|
615
|
+
if (sessionManager) {
|
|
616
|
+
const sessions = sessionManager.getAllSessions();
|
|
617
|
+
const oopifSessions = sessions.filter((s) => !s.isMain);
|
|
618
|
+
if (oopifSessions.length > 0) {
|
|
619
|
+
const oopifResults = await Promise.all(oopifSessions.map(async (s) => {
|
|
620
|
+
try {
|
|
621
|
+
const oopifResult = await cdpClient.send("Accessibility.getFullAXTree", { depth: cdpFetchDepth }, s.sessionId);
|
|
622
|
+
return { url: s.url, nodes: oopifResult.nodes ?? [], sessionId: s.sessionId };
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
// M1: OOPIF may have been detached between getAllSessions and fetch
|
|
626
|
+
wrapCdpError(err, "A11yTreeProcessor.getTree(OOPIF)");
|
|
627
|
+
return { url: s.url, nodes: [], sessionId: s.sessionId };
|
|
628
|
+
}
|
|
629
|
+
}));
|
|
630
|
+
for (const oopifResult of oopifResults) {
|
|
631
|
+
if (oopifResult.nodes.length > 0) {
|
|
632
|
+
oopifSections.push(oopifResult);
|
|
633
|
+
// Assign refs and register nodes for OOPIF
|
|
634
|
+
for (const node of oopifResult.nodes) {
|
|
635
|
+
// Add to nodeMap with session-prefixed keys to avoid collisions
|
|
636
|
+
nodeMap.set(`${oopifResult.sessionId}:${node.nodeId}`, node);
|
|
637
|
+
if (node.ignored || node.backendDOMNodeId === undefined)
|
|
638
|
+
continue;
|
|
639
|
+
if (!this.refMap.has(node.backendDOMNodeId)) {
|
|
640
|
+
const refNum = this.nextRef++;
|
|
641
|
+
this.refMap.set(node.backendDOMNodeId, refNum);
|
|
642
|
+
this.reverseMap.set(refNum, node.backendDOMNodeId);
|
|
643
|
+
}
|
|
644
|
+
this.nodeInfoMap.set(node.backendDOMNodeId, {
|
|
645
|
+
role: node.role?.value ?? "",
|
|
646
|
+
name: node.name?.value ?? "",
|
|
647
|
+
});
|
|
648
|
+
// Track which session owns this node (H1: for cleanup on detach)
|
|
649
|
+
if (!this.sessionNodeMap.has(oopifResult.sessionId)) {
|
|
650
|
+
this.sessionNodeMap.set(oopifResult.sessionId, new Set());
|
|
651
|
+
}
|
|
652
|
+
this.sessionNodeMap.get(oopifResult.sessionId).add(node.backendDOMNodeId);
|
|
653
|
+
sessionManager.registerNode(node.backendDOMNodeId, oopifResult.sessionId);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// FR-H5: Enrich nodes with HTML attributes + event listener detection.
|
|
660
|
+
// Runs in getTree() so read_page(filter: "interactive") sees clickable divs/listItems.
|
|
661
|
+
await this._enrichNodeMetadata(cdpClient, sessionId);
|
|
662
|
+
// Fetch visual data only for "visual" filter — zero overhead for other filters
|
|
663
|
+
let visualMap;
|
|
664
|
+
let visualDataFailed = false;
|
|
665
|
+
if (filter === "visual") {
|
|
666
|
+
try {
|
|
667
|
+
visualMap = await this.fetchVisualData(cdpClient, sessionId);
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
// M1: DOMSnapshot may fail on certain pages — fall back to tree without visual data
|
|
671
|
+
visualMap = undefined;
|
|
672
|
+
visualDataFailed = true;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// Handle subtree query
|
|
676
|
+
if (options.ref) {
|
|
677
|
+
// For subtree, search across all nodes (main + OOPIF)
|
|
678
|
+
const allNodes = [...nodes];
|
|
679
|
+
for (const section of oopifSections) {
|
|
680
|
+
allNodes.push(...section.nodes);
|
|
681
|
+
}
|
|
682
|
+
// Build a combined nodeMap for subtree
|
|
683
|
+
const combinedNodeMap = new Map();
|
|
684
|
+
for (const node of allNodes) {
|
|
685
|
+
combinedNodeMap.set(node.nodeId, node);
|
|
686
|
+
}
|
|
687
|
+
return this.getSubtree(options.ref, combinedNodeMap, allNodes, filter, depth, visualMap, visualDataFailed, options.max_tokens);
|
|
688
|
+
}
|
|
689
|
+
// Get page title from root node
|
|
690
|
+
const pageTitle = this.getPageTitle(nodes);
|
|
691
|
+
// Build tree text from root (main frame)
|
|
692
|
+
const root = nodes[0];
|
|
693
|
+
const lines = [];
|
|
694
|
+
// Ticket-1: Pre-scan the whole tree (main + OOPIFs) to identify groups
|
|
695
|
+
// of ≥10 same-class leaves that should collapse into summary lines.
|
|
696
|
+
// Must run BEFORE renderNode so anchors/suppressed ids are visible to
|
|
697
|
+
// every walk path.
|
|
698
|
+
this.prepareAggregateGroups(root, nodeMap, oopifSections, filter);
|
|
699
|
+
this.renderNode(root, nodeMap, 0, filter, lines, visualMap);
|
|
700
|
+
// Append OOPIF sections
|
|
701
|
+
for (const section of oopifSections) {
|
|
702
|
+
const oopifNodeMap = new Map();
|
|
703
|
+
for (const node of section.nodes) {
|
|
704
|
+
oopifNodeMap.set(node.nodeId, node);
|
|
705
|
+
}
|
|
706
|
+
lines.push(`--- iframe: ${section.url} ---`);
|
|
707
|
+
if (section.nodes.length > 0) {
|
|
708
|
+
this.renderNode(section.nodes[0], oopifNodeMap, 0, filter, lines, visualMap);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
this.clearAggregateGroups();
|
|
712
|
+
// H5: Count only actual element lines, not separator lines (--- iframe: ... ---)
|
|
713
|
+
const refCount = lines.filter((l) => !l.startsWith("--- ")).length;
|
|
714
|
+
// FR-022: Count content nodes (StaticText/paragraph/etc.) hidden by filter:interactive,
|
|
715
|
+
// so read-page.ts can append a hint pointing the LLM at filter:'all' instead of evaluate.
|
|
716
|
+
const hiddenContentCount = filter === "interactive"
|
|
717
|
+
? this.countHiddenContentNodes(root, nodeMap, oopifSections)
|
|
718
|
+
: undefined;
|
|
719
|
+
const text = this.formatHeader(pageTitle, refCount, filter, depth)
|
|
720
|
+
+ (lines.length > 0 ? "\n\n" + lines.join("\n") : "");
|
|
721
|
+
// Token-Budget Downsampling — explicit max_tokens or BUG-009 safety cap
|
|
722
|
+
const effectiveMaxTokens = options.max_tokens ?? DEFAULT_MAX_TOKENS;
|
|
723
|
+
const currentTokens = estimateTokens(text);
|
|
724
|
+
if (currentTokens > effectiveMaxTokens) {
|
|
725
|
+
const downsampled = this.downsampleTree(root, nodeMap, filter, effectiveMaxTokens, oopifSections, visualMap);
|
|
726
|
+
const dsHeader = this.formatDownsampledHeader(pageTitle, downsampled.refCount, filter, depth, currentTokens, downsampled.level);
|
|
727
|
+
// C2: Final budget check — trim body if header+body exceeds budget
|
|
728
|
+
let dsBody = downsampled.lines.join("\n");
|
|
729
|
+
const fullText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
|
|
730
|
+
if (estimateTokens(fullText) > effectiveMaxTokens && dsBody) {
|
|
731
|
+
const headerTokens = estimateTokens(dsHeader + "\n\n");
|
|
732
|
+
const bodyBudget = effectiveMaxTokens - headerTokens;
|
|
733
|
+
const trimmed = this.trimBodyToFit(downsampled.lines, bodyBudget);
|
|
734
|
+
dsBody = trimmed.join("\n");
|
|
735
|
+
}
|
|
736
|
+
const dsText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
|
|
737
|
+
return {
|
|
738
|
+
text: dsText,
|
|
739
|
+
refCount: downsampled.refCount,
|
|
740
|
+
depth,
|
|
741
|
+
tokenCount: estimateTokens(dsText),
|
|
742
|
+
pageUrl: this.lastUrl,
|
|
743
|
+
downsampled: true,
|
|
744
|
+
originalTokens: currentTokens,
|
|
745
|
+
downsampleLevel: downsampled.level,
|
|
746
|
+
...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
|
|
747
|
+
...(hiddenContentCount !== undefined ? { hiddenContentCount } : {}),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
text,
|
|
752
|
+
refCount,
|
|
753
|
+
depth,
|
|
754
|
+
tokenCount: currentTokens,
|
|
755
|
+
pageUrl: this.lastUrl,
|
|
756
|
+
...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
|
|
757
|
+
...(hiddenContentCount !== undefined ? { hiddenContentCount } : {}),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
// --- Downsampling Pipeline (D2Snap) ---
|
|
761
|
+
downsampleTree(root, nodeMap, filter, maxTokens, oopifSections, visualMap) {
|
|
762
|
+
// Try levels 0-4 sequentially until budget is met
|
|
763
|
+
for (let level = 0; level <= 4; level++) {
|
|
764
|
+
const lines = [];
|
|
765
|
+
this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, level, visualMap);
|
|
766
|
+
// Append OOPIF sections
|
|
767
|
+
for (const section of oopifSections) {
|
|
768
|
+
const oopifNodeMap = new Map();
|
|
769
|
+
for (const node of section.nodes) {
|
|
770
|
+
oopifNodeMap.set(node.nodeId, node);
|
|
771
|
+
}
|
|
772
|
+
lines.push(`--- iframe: ${section.url} ---`);
|
|
773
|
+
if (section.nodes.length > 0) {
|
|
774
|
+
this.renderNodeDownsampled(section.nodes[0], oopifNodeMap, 0, filter, lines, level, visualMap);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const refCount = lines.filter((l) => !l.startsWith("--- ")).length;
|
|
778
|
+
// Estimate tokens including a header estimate (~60 chars)
|
|
779
|
+
const estimatedTotal = estimateTokens(lines.join("\n")) + 15;
|
|
780
|
+
if (estimatedTotal <= maxTokens) {
|
|
781
|
+
return { lines, refCount, level };
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// Level 4 still too large → truncate as last resort
|
|
785
|
+
const lines = [];
|
|
786
|
+
this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, 4, visualMap);
|
|
787
|
+
// Append OOPIF sections
|
|
788
|
+
for (const section of oopifSections) {
|
|
789
|
+
const oopifNodeMap = new Map();
|
|
790
|
+
for (const node of section.nodes) {
|
|
791
|
+
oopifNodeMap.set(node.nodeId, node);
|
|
792
|
+
}
|
|
793
|
+
lines.push(`--- iframe: ${section.url} ---`);
|
|
794
|
+
if (section.nodes.length > 0) {
|
|
795
|
+
this.renderNodeDownsampled(section.nodes[0], oopifNodeMap, 0, filter, lines, 4, visualMap);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return this.truncateToFit(lines, maxTokens);
|
|
799
|
+
}
|
|
800
|
+
truncateToFit(lines, maxTokens) {
|
|
801
|
+
// C3: Prioritize elements: dialogs/modals > interactive > content
|
|
802
|
+
const dialogLines = [];
|
|
803
|
+
const interactiveLines = [];
|
|
804
|
+
const contentLines = [];
|
|
805
|
+
// Track dialog context: lines inside a dialog subtree get dialog priority
|
|
806
|
+
let insideDialog = false;
|
|
807
|
+
let dialogIndent = -1;
|
|
808
|
+
for (let i = 0; i < lines.length; i++) {
|
|
809
|
+
const line = lines[i];
|
|
810
|
+
const indent = line.search(/\S/);
|
|
811
|
+
// Detect dialog/modal boundaries
|
|
812
|
+
const roleMatch = line.match(/\[e\d+\]\s+(\S+)/);
|
|
813
|
+
const role = roleMatch ? roleMatch[1] : "";
|
|
814
|
+
if (role === "dialog" || role === "alertdialog") {
|
|
815
|
+
insideDialog = true;
|
|
816
|
+
dialogIndent = indent;
|
|
817
|
+
dialogLines.push({ line, idx: i });
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
// If we were inside a dialog and indentation decreased back to/beyond dialog level, we left the dialog
|
|
821
|
+
if (insideDialog && indent <= dialogIndent && i > 0) {
|
|
822
|
+
insideDialog = false;
|
|
823
|
+
dialogIndent = -1;
|
|
824
|
+
}
|
|
825
|
+
if (insideDialog) {
|
|
826
|
+
dialogLines.push({ line, idx: i });
|
|
827
|
+
}
|
|
828
|
+
else if (/\[e\d+\]/.test(line) && INTERACTIVE_ROLES.has(role)) {
|
|
829
|
+
// Demote option elements to content priority — they fill token budget
|
|
830
|
+
// without adding actionable value (the combobox itself shows the current value)
|
|
831
|
+
if (role === "option") {
|
|
832
|
+
contentLines.push({ line, idx: i });
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
interactiveLines.push({ line, idx: i });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
contentLines.push({ line, idx: i });
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// Build result: dialogs first, then interactive, then content
|
|
843
|
+
const result = [];
|
|
844
|
+
let tokensSoFar = 15; // header estimate
|
|
845
|
+
const addedIndices = new Set();
|
|
846
|
+
// Phase 0: Add all dialog/modal elements (highest priority — always visible)
|
|
847
|
+
for (const { line, idx } of dialogLines) {
|
|
848
|
+
const lineTokens = estimateTokens(line + "\n");
|
|
849
|
+
if (tokensSoFar + lineTokens > maxTokens - 15)
|
|
850
|
+
break;
|
|
851
|
+
result.push(line);
|
|
852
|
+
addedIndices.add(idx);
|
|
853
|
+
tokensSoFar += lineTokens;
|
|
854
|
+
}
|
|
855
|
+
// Phase 1: Add interactive elements
|
|
856
|
+
for (const { line, idx } of interactiveLines) {
|
|
857
|
+
const lineTokens = estimateTokens(line + "\n");
|
|
858
|
+
if (tokensSoFar + lineTokens > maxTokens - 15)
|
|
859
|
+
break;
|
|
860
|
+
result.push(line);
|
|
861
|
+
addedIndices.add(idx);
|
|
862
|
+
tokensSoFar += lineTokens;
|
|
863
|
+
}
|
|
864
|
+
// Phase 2: Fill remaining budget with content lines
|
|
865
|
+
for (const { line, idx } of contentLines) {
|
|
866
|
+
const lineTokens = estimateTokens(line + "\n");
|
|
867
|
+
if (tokensSoFar + lineTokens > maxTokens - 15)
|
|
868
|
+
break;
|
|
869
|
+
result.push(line);
|
|
870
|
+
addedIndices.add(idx);
|
|
871
|
+
tokensSoFar += lineTokens;
|
|
872
|
+
}
|
|
873
|
+
// Add truncation marker if lines were omitted
|
|
874
|
+
const omitted = lines.length - addedIndices.size;
|
|
875
|
+
if (omitted > 0) {
|
|
876
|
+
result.push(`... (truncated, ${omitted} elements omitted)`);
|
|
877
|
+
}
|
|
878
|
+
// Re-sort by original index to maintain document order
|
|
879
|
+
const sortedResult = result
|
|
880
|
+
.filter((l) => !l.startsWith("..."))
|
|
881
|
+
.map((l) => {
|
|
882
|
+
// Find original index
|
|
883
|
+
const origIdx = lines.indexOf(l);
|
|
884
|
+
return { line: l, idx: origIdx };
|
|
885
|
+
})
|
|
886
|
+
.sort((a, b) => a.idx - b.idx)
|
|
887
|
+
.map((entry) => entry.line);
|
|
888
|
+
// Append truncation marker at the end
|
|
889
|
+
if (omitted > 0) {
|
|
890
|
+
sortedResult.push(`... (truncated, ${omitted} elements omitted)`);
|
|
891
|
+
}
|
|
892
|
+
const refCount = sortedResult.filter((l) => !l.startsWith("--- ") && !l.startsWith("...")).length;
|
|
893
|
+
return { lines: sortedResult, refCount, level: 4 };
|
|
894
|
+
}
|
|
895
|
+
/** C2: Trim body lines from the end (content first) until budget is met */
|
|
896
|
+
trimBodyToFit(lines, bodyBudgetTokens) {
|
|
897
|
+
if (bodyBudgetTokens <= 0)
|
|
898
|
+
return [];
|
|
899
|
+
// Separate interactive and content lines
|
|
900
|
+
const interactiveIndices = new Set();
|
|
901
|
+
for (let i = 0; i < lines.length; i++) {
|
|
902
|
+
const line = lines[i];
|
|
903
|
+
const roleMatch = line.match(/\[e\d+\]\s+(\S+)/);
|
|
904
|
+
const role = roleMatch ? roleMatch[1] : "";
|
|
905
|
+
if (INTERACTIVE_ROLES.has(role)) {
|
|
906
|
+
interactiveIndices.add(i);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
// Try removing content lines from the end first
|
|
910
|
+
const result = [...lines];
|
|
911
|
+
while (estimateTokens(result.join("\n")) > bodyBudgetTokens && result.length > 0) {
|
|
912
|
+
// Find last non-interactive line to remove
|
|
913
|
+
let removedContent = false;
|
|
914
|
+
for (let i = result.length - 1; i >= 0; i--) {
|
|
915
|
+
const roleMatch = result[i].match(/\[e\d+\]\s+(\S+)/);
|
|
916
|
+
const role = roleMatch ? roleMatch[1] : "";
|
|
917
|
+
if (!INTERACTIVE_ROLES.has(role)) {
|
|
918
|
+
result.splice(i, 1);
|
|
919
|
+
removedContent = true;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
// If only interactive lines remain and still over budget, remove from end
|
|
924
|
+
if (!removedContent) {
|
|
925
|
+
result.pop();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return result;
|
|
929
|
+
}
|
|
930
|
+
renderNodeDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap) {
|
|
931
|
+
if (node.ignored) {
|
|
932
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const role = this.getRole(node);
|
|
936
|
+
const elementClass = classifyElement(role);
|
|
937
|
+
const passesFilter = this.passesFilter(node, role, filter);
|
|
938
|
+
if (!passesFilter) {
|
|
939
|
+
// Not passing filter — skip but process children at same indent
|
|
940
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (node.backendDOMNodeId === undefined) {
|
|
944
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const refNum = this.refMap.get(node.backendDOMNodeId);
|
|
948
|
+
if (refNum === undefined) {
|
|
949
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const indent = " ".repeat(indentLevel);
|
|
953
|
+
if (elementClass === "interactive") {
|
|
954
|
+
// Interactive: ALWAYS fully preserved
|
|
955
|
+
let line = this.formatLine(indent, refNum, role, node, nodeMap);
|
|
956
|
+
if (visualMap) {
|
|
957
|
+
const vi = visualMap.get(node.backendDOMNodeId);
|
|
958
|
+
if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
|
|
959
|
+
line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
|
|
960
|
+
if (vi.isClickable)
|
|
961
|
+
line += " click";
|
|
962
|
+
if (vi.isVisible)
|
|
963
|
+
line += " vis";
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
line += " [hidden]";
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
lines.push(line);
|
|
970
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
|
|
971
|
+
}
|
|
972
|
+
else if (elementClass === "content") {
|
|
973
|
+
if (level < 4) {
|
|
974
|
+
// Content at levels 0-3: unchanged
|
|
975
|
+
let line = this.formatLine(indent, refNum, role, node, nodeMap);
|
|
976
|
+
if (visualMap) {
|
|
977
|
+
const vi = visualMap.get(node.backendDOMNodeId);
|
|
978
|
+
if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
|
|
979
|
+
line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
|
|
980
|
+
if (vi.isClickable)
|
|
981
|
+
line += " click";
|
|
982
|
+
if (vi.isVisible)
|
|
983
|
+
line += " vis";
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
line += " [hidden]";
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
lines.push(line);
|
|
990
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
// Content at level 4: compact Markdown
|
|
994
|
+
const name = node.name?.value ?? "";
|
|
995
|
+
if (role === "heading") {
|
|
996
|
+
lines.push(`${indent}# ${name} (e${refNum})`);
|
|
997
|
+
}
|
|
998
|
+
else if (role === "listitem") {
|
|
999
|
+
const truncName = name.length > 100 ? name.slice(0, 97) + "..." : name;
|
|
1000
|
+
lines.push(`${indent}- ${truncName} (e${refNum})`);
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
// paragraph, StaticText, img, etc.
|
|
1004
|
+
const truncName = name.length > 100 ? name.slice(0, 97) + "..." : name;
|
|
1005
|
+
if (truncName) {
|
|
1006
|
+
lines.push(`${indent}${truncName} (e${refNum})`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
// Still render children for content nodes (they may have interactive children)
|
|
1010
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
// Container
|
|
1015
|
+
if (level === 0) {
|
|
1016
|
+
// Level 0: no merging, render normally
|
|
1017
|
+
let line = this.formatLine(indent, refNum, role, node, nodeMap);
|
|
1018
|
+
if (visualMap) {
|
|
1019
|
+
const vi = visualMap.get(node.backendDOMNodeId);
|
|
1020
|
+
if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
|
|
1021
|
+
line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
|
|
1022
|
+
if (vi.isClickable)
|
|
1023
|
+
line += " click";
|
|
1024
|
+
if (vi.isVisible)
|
|
1025
|
+
line += " vis";
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
line += " [hidden]";
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
lines.push(line);
|
|
1032
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
|
|
1033
|
+
}
|
|
1034
|
+
else if (level === 1) {
|
|
1035
|
+
// Level 1: remove empty containers (no children)
|
|
1036
|
+
const childCount = this.countVisibleChildren(node, nodeMap);
|
|
1037
|
+
if (childCount === 0) {
|
|
1038
|
+
// H3: Even if no visible direct children, check for interactive descendants
|
|
1039
|
+
if (this.hasInteractiveDescendants(node, nodeMap)) {
|
|
1040
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
1041
|
+
}
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
let line = this.formatLine(indent, refNum, role, node, nodeMap);
|
|
1045
|
+
if (visualMap) {
|
|
1046
|
+
const vi = visualMap.get(node.backendDOMNodeId);
|
|
1047
|
+
if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
|
|
1048
|
+
line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
|
|
1049
|
+
if (vi.isClickable)
|
|
1050
|
+
line += " click";
|
|
1051
|
+
if (vi.isVisible)
|
|
1052
|
+
line += " vis";
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
line += " [hidden]";
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
lines.push(line);
|
|
1059
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
|
|
1060
|
+
}
|
|
1061
|
+
else if (level === 2) {
|
|
1062
|
+
// Level 2: single-child containers merged (child takes container's level)
|
|
1063
|
+
const children = this.getVisibleChildren(node, nodeMap);
|
|
1064
|
+
if (children.length === 0) {
|
|
1065
|
+
// H3: Check for interactive descendants before removing
|
|
1066
|
+
if (this.hasInteractiveDescendants(node, nodeMap)) {
|
|
1067
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
1068
|
+
}
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (children.length === 1) {
|
|
1072
|
+
// Merge: child at container's indent level
|
|
1073
|
+
this.renderNodeDownsampled(children[0], nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
// Multiple children: keep container but render children
|
|
1077
|
+
let line = this.formatLine(indent, refNum, role, node, nodeMap);
|
|
1078
|
+
if (visualMap) {
|
|
1079
|
+
const vi = visualMap.get(node.backendDOMNodeId);
|
|
1080
|
+
if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
|
|
1081
|
+
line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
|
|
1082
|
+
if (vi.isClickable)
|
|
1083
|
+
line += " click";
|
|
1084
|
+
if (vi.isVisible)
|
|
1085
|
+
line += " vis";
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
line += " [hidden]";
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
lines.push(line);
|
|
1092
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
// Level 3-4: container chains flattened, containers as one-line summary
|
|
1096
|
+
const childCount = this.countDescendantElements(node, nodeMap, filter);
|
|
1097
|
+
if (childCount === 0) {
|
|
1098
|
+
// H3: Even with 0 filtered descendants, check for interactive ones
|
|
1099
|
+
if (this.hasInteractiveDescendants(node, nodeMap)) {
|
|
1100
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const name = node.name?.value ?? "";
|
|
1105
|
+
const shortRole = this.shortContainerRole(role);
|
|
1106
|
+
const nameStr = name ? `: ${name}` : "";
|
|
1107
|
+
lines.push(`${indent}[${shortRole}${nameStr}, ${childCount} items]`);
|
|
1108
|
+
// Children rendered at indentLevel + 1
|
|
1109
|
+
this.renderChildrenDownsampled(node, nodeMap, indentLevel + 1, filter, lines, level, visualMap);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
renderChildrenDownsampled(node, nodeMap, indentLevel, filter, lines, level, visualMap) {
|
|
1114
|
+
if (!node.childIds)
|
|
1115
|
+
return;
|
|
1116
|
+
for (const childId of node.childIds) {
|
|
1117
|
+
const child = nodeMap.get(childId);
|
|
1118
|
+
if (child) {
|
|
1119
|
+
this.renderNodeDownsampled(child, nodeMap, indentLevel, filter, lines, level, visualMap);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
countVisibleChildren(node, nodeMap) {
|
|
1124
|
+
if (!node.childIds)
|
|
1125
|
+
return 0;
|
|
1126
|
+
let count = 0;
|
|
1127
|
+
for (const childId of node.childIds) {
|
|
1128
|
+
const child = nodeMap.get(childId);
|
|
1129
|
+
if (child && !child.ignored)
|
|
1130
|
+
count++;
|
|
1131
|
+
}
|
|
1132
|
+
return count;
|
|
1133
|
+
}
|
|
1134
|
+
getVisibleChildren(node, nodeMap) {
|
|
1135
|
+
if (!node.childIds)
|
|
1136
|
+
return [];
|
|
1137
|
+
const children = [];
|
|
1138
|
+
for (const childId of node.childIds) {
|
|
1139
|
+
const child = nodeMap.get(childId);
|
|
1140
|
+
if (child && !child.ignored)
|
|
1141
|
+
children.push(child);
|
|
1142
|
+
}
|
|
1143
|
+
return children;
|
|
1144
|
+
}
|
|
1145
|
+
countDescendantElements(node, nodeMap, filter) {
|
|
1146
|
+
if (!node.childIds)
|
|
1147
|
+
return 0;
|
|
1148
|
+
let count = 0;
|
|
1149
|
+
for (const childId of node.childIds) {
|
|
1150
|
+
const child = nodeMap.get(childId);
|
|
1151
|
+
if (!child || child.ignored)
|
|
1152
|
+
continue;
|
|
1153
|
+
const role = this.getRole(child);
|
|
1154
|
+
if (this.passesFilter(child, role, filter))
|
|
1155
|
+
count++;
|
|
1156
|
+
count += this.countDescendantElements(child, nodeMap, filter);
|
|
1157
|
+
}
|
|
1158
|
+
return count;
|
|
1159
|
+
}
|
|
1160
|
+
/** H3: Check if a node has any interactive descendants (recursive) */
|
|
1161
|
+
hasInteractiveDescendants(node, nodeMap) {
|
|
1162
|
+
if (!node.childIds)
|
|
1163
|
+
return false;
|
|
1164
|
+
for (const childId of node.childIds) {
|
|
1165
|
+
const child = nodeMap.get(childId);
|
|
1166
|
+
if (!child)
|
|
1167
|
+
continue;
|
|
1168
|
+
const role = this.getRole(child);
|
|
1169
|
+
if (INTERACTIVE_ROLES.has(role) && !child.ignored)
|
|
1170
|
+
return true;
|
|
1171
|
+
if (this.hasInteractiveDescendants(child, nodeMap))
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
shortContainerRole(role) {
|
|
1177
|
+
const shortcuts = {
|
|
1178
|
+
navigation: "nav",
|
|
1179
|
+
complementary: "aside",
|
|
1180
|
+
contentinfo: "footer",
|
|
1181
|
+
banner: "header",
|
|
1182
|
+
generic: "div",
|
|
1183
|
+
group: "group",
|
|
1184
|
+
region: "region",
|
|
1185
|
+
form: "form",
|
|
1186
|
+
search: "search",
|
|
1187
|
+
toolbar: "toolbar",
|
|
1188
|
+
tablist: "tabs",
|
|
1189
|
+
menu: "menu",
|
|
1190
|
+
menubar: "menubar",
|
|
1191
|
+
list: "list",
|
|
1192
|
+
listbox: "listbox",
|
|
1193
|
+
tree: "tree",
|
|
1194
|
+
grid: "grid",
|
|
1195
|
+
table: "table",
|
|
1196
|
+
main: "main",
|
|
1197
|
+
rowgroup: "rowgroup",
|
|
1198
|
+
row: "row",
|
|
1199
|
+
treegrid: "treegrid",
|
|
1200
|
+
};
|
|
1201
|
+
return shortcuts[role] ?? role;
|
|
1202
|
+
}
|
|
1203
|
+
formatDownsampledHeader(title, count, filter, depth, originalTokens, level) {
|
|
1204
|
+
const titlePart = title ? `Page: ${title}` : "Page";
|
|
1205
|
+
return `${titlePart} — ${count} ${filter} elements (depth ${depth}, downsampled L${level} from ~${originalTokens} tokens)`;
|
|
1206
|
+
}
|
|
1207
|
+
getSubtree(ref, nodeMap, nodes, filter, depth, visualMap, visualDataFailed = false, maxTokens) {
|
|
1208
|
+
const backendId = this.resolveRef(ref);
|
|
1209
|
+
if (backendId === undefined) {
|
|
1210
|
+
const availableRefs = this.getAvailableRefsRange();
|
|
1211
|
+
const suggestion = this.suggestClosestRef(ref);
|
|
1212
|
+
let errorText = `Element ${ref} not found.`;
|
|
1213
|
+
if (availableRefs)
|
|
1214
|
+
errorText += ` Available refs: ${availableRefs}.`;
|
|
1215
|
+
if (suggestion)
|
|
1216
|
+
errorText += ` Did you mean ${suggestion}?`;
|
|
1217
|
+
throw new RefNotFoundError(errorText);
|
|
1218
|
+
}
|
|
1219
|
+
// Find the AXNode with this backendDOMNodeId
|
|
1220
|
+
const targetNode = nodes.find((n) => n.backendDOMNodeId === backendId);
|
|
1221
|
+
if (!targetNode) {
|
|
1222
|
+
throw new RefNotFoundError(`Element ${ref} not found in current tree.`);
|
|
1223
|
+
}
|
|
1224
|
+
const lines = [];
|
|
1225
|
+
this.renderNode(targetNode, nodeMap, 0, filter, lines, visualMap);
|
|
1226
|
+
const refCount = lines.length;
|
|
1227
|
+
const header = `Subtree for ${ref} — ${refCount} elements`;
|
|
1228
|
+
const text = header + "\n\n" + lines.join("\n");
|
|
1229
|
+
// H2: Apply downsampling when max_tokens is set and subtree exceeds budget
|
|
1230
|
+
if (maxTokens) {
|
|
1231
|
+
const currentTokens = estimateTokens(text);
|
|
1232
|
+
if (currentTokens > maxTokens) {
|
|
1233
|
+
// Downsample the subtree using the same pipeline
|
|
1234
|
+
const downsampled = this.downsampleSubtree(targetNode, nodeMap, filter, maxTokens, visualMap);
|
|
1235
|
+
const dsHeader = `Subtree for ${ref} — ${downsampled.refCount} elements (downsampled L${downsampled.level} from ~${currentTokens} tokens)`;
|
|
1236
|
+
// C2: Final budget check on subtree too
|
|
1237
|
+
let dsBody = downsampled.lines.join("\n");
|
|
1238
|
+
const fullText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
|
|
1239
|
+
if (estimateTokens(fullText) > maxTokens && dsBody) {
|
|
1240
|
+
const headerTokens = estimateTokens(dsHeader + "\n\n");
|
|
1241
|
+
const bodyBudget = maxTokens - headerTokens;
|
|
1242
|
+
const trimmed = this.trimBodyToFit(downsampled.lines, bodyBudget);
|
|
1243
|
+
dsBody = trimmed.join("\n");
|
|
1244
|
+
}
|
|
1245
|
+
const dsText = dsHeader + (dsBody ? "\n\n" + dsBody : "");
|
|
1246
|
+
return {
|
|
1247
|
+
text: dsText,
|
|
1248
|
+
refCount: downsampled.refCount,
|
|
1249
|
+
depth,
|
|
1250
|
+
tokenCount: estimateTokens(dsText),
|
|
1251
|
+
pageUrl: this.lastUrl,
|
|
1252
|
+
downsampled: true,
|
|
1253
|
+
originalTokens: currentTokens,
|
|
1254
|
+
downsampleLevel: downsampled.level,
|
|
1255
|
+
...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return {
|
|
1260
|
+
text,
|
|
1261
|
+
refCount,
|
|
1262
|
+
depth,
|
|
1263
|
+
tokenCount: estimateTokens(text),
|
|
1264
|
+
pageUrl: this.lastUrl,
|
|
1265
|
+
...(filter === "visual" ? { hasVisualData: !visualDataFailed } : {}),
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
/** H2: Downsample a subtree (same algorithm as downsampleTree but for a single root) */
|
|
1269
|
+
downsampleSubtree(root, nodeMap, filter, maxTokens, visualMap) {
|
|
1270
|
+
for (let level = 0; level <= 4; level++) {
|
|
1271
|
+
const lines = [];
|
|
1272
|
+
this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, level, visualMap);
|
|
1273
|
+
const refCount = lines.filter((l) => !l.startsWith("--- ")).length;
|
|
1274
|
+
const estimatedTotal = estimateTokens(lines.join("\n")) + 15;
|
|
1275
|
+
if (estimatedTotal <= maxTokens) {
|
|
1276
|
+
return { lines, refCount, level };
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// Level 4 still too large — truncate as last resort
|
|
1280
|
+
const lines = [];
|
|
1281
|
+
this.renderNodeDownsampled(root, nodeMap, 0, filter, lines, 4, visualMap);
|
|
1282
|
+
return this.truncateToFit(lines, maxTokens);
|
|
1283
|
+
}
|
|
1284
|
+
/** BUG-001: Find the nearest heading sibling before this node in parent's children */
|
|
1285
|
+
findSectionHeading(node, nodeMap) {
|
|
1286
|
+
if (!node.parentId)
|
|
1287
|
+
return null;
|
|
1288
|
+
const parent = nodeMap.get(node.parentId);
|
|
1289
|
+
if (!parent?.childIds)
|
|
1290
|
+
return null;
|
|
1291
|
+
const myIdx = parent.childIds.indexOf(node.nodeId);
|
|
1292
|
+
for (let i = myIdx - 1; i >= 0; i--) {
|
|
1293
|
+
const sibling = nodeMap.get(parent.childIds[i]);
|
|
1294
|
+
if (!sibling)
|
|
1295
|
+
continue;
|
|
1296
|
+
const siblingRole = this.getRole(sibling);
|
|
1297
|
+
if (siblingRole === "heading") {
|
|
1298
|
+
const name = sibling.name?.value;
|
|
1299
|
+
if (name)
|
|
1300
|
+
return name;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return null;
|
|
1304
|
+
}
|
|
1305
|
+
renderNode(node, nodeMap, indentLevel, filter, lines, visualMap) {
|
|
1306
|
+
if (node.ignored) {
|
|
1307
|
+
// Ignored nodes: skip but process children at same indent level
|
|
1308
|
+
this.renderChildren(node, nodeMap, indentLevel, filter, lines, visualMap);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const role = this.getRole(node);
|
|
1312
|
+
const passesFilter = this.passesFilter(node, role, filter);
|
|
1313
|
+
// Ticket-1 global aggregation: suppressed members produce no line AND
|
|
1314
|
+
// no recursion — they are guaranteed leaves by prepareAggregateGroups.
|
|
1315
|
+
if (node.backendDOMNodeId !== undefined &&
|
|
1316
|
+
this._aggregateSuppressed?.has(node.backendDOMNodeId)) {
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
// Anchor members emit the collapse summary at their position instead of
|
|
1320
|
+
// the normal formatLine output, then return (leaves have no children
|
|
1321
|
+
// worth rendering).
|
|
1322
|
+
if (node.backendDOMNodeId !== undefined) {
|
|
1323
|
+
const anchor = this._aggregateAnchors?.get(node.backendDOMNodeId);
|
|
1324
|
+
if (anchor) {
|
|
1325
|
+
this.emitAggregateLine(anchor, indentLevel, lines);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (passesFilter && node.backendDOMNodeId !== undefined) {
|
|
1330
|
+
const refNum = this.refMap.get(node.backendDOMNodeId);
|
|
1331
|
+
if (refNum !== undefined) {
|
|
1332
|
+
const indent = " ".repeat(indentLevel);
|
|
1333
|
+
let line = this.formatLine(indent, refNum, role, node, nodeMap);
|
|
1334
|
+
// Append visual info if visualMap is provided
|
|
1335
|
+
if (visualMap) {
|
|
1336
|
+
const vi = visualMap.get(node.backendDOMNodeId);
|
|
1337
|
+
if (vi && vi.bounds.w > 0 && vi.bounds.h > 0) {
|
|
1338
|
+
line += ` [${vi.bounds.x},${vi.bounds.y} ${vi.bounds.w}x${vi.bounds.h}]`;
|
|
1339
|
+
if (vi.isClickable)
|
|
1340
|
+
line += " click";
|
|
1341
|
+
if (vi.isVisible)
|
|
1342
|
+
line += " vis";
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
line += " [hidden]";
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
lines.push(line);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
// Always render children (even if this node didn't pass filter)
|
|
1352
|
+
const nextIndent = passesFilter ? indentLevel + 1 : indentLevel;
|
|
1353
|
+
this.renderChildren(node, nodeMap, nextIndent, filter, lines, visualMap);
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Ticket-1 / Token-Aggregation: minimum number of same-class leaf elements
|
|
1357
|
+
* inside the rendered subtree before they are collapsed into one summary
|
|
1358
|
+
* line at the first occurrence. Set to 10 so we never aggregate small or
|
|
1359
|
+
* medium lists (button bars, nav menus, dialog actions) but reliably catch
|
|
1360
|
+
* large generated lists like the 240-button benchmark page, even when they
|
|
1361
|
+
* are interleaved with headings/paragraphs/links.
|
|
1362
|
+
*/
|
|
1363
|
+
static AGGREGATE_MIN_COUNT = 10;
|
|
1364
|
+
/**
|
|
1365
|
+
* Ticket-1: Per-render state built by {@link prepareAggregateGroups}. Keys
|
|
1366
|
+
* the first backendDOMNodeId of a ≥10-member aggregation bucket to the
|
|
1367
|
+
* info needed to emit the summary line. Null when no aggregation pass has
|
|
1368
|
+
* been executed (e.g. during subtree renders or tests that bypass getTree).
|
|
1369
|
+
*/
|
|
1370
|
+
_aggregateAnchors = null;
|
|
1371
|
+
/**
|
|
1372
|
+
* Ticket-1: All non-first member backendDOMNodeIds for ≥10-member buckets.
|
|
1373
|
+
* renderNode skips any node whose backendDOMNodeId is in this set — the
|
|
1374
|
+
* line they would have produced is already covered by the anchor's
|
|
1375
|
+
* summary line.
|
|
1376
|
+
*/
|
|
1377
|
+
_aggregateSuppressed = null;
|
|
1378
|
+
/**
|
|
1379
|
+
* Ticket-1: Build a stable aggregation key for a leaf element. Two
|
|
1380
|
+
* sibling leaves share an aggregation class iff their keys are equal:
|
|
1381
|
+
*
|
|
1382
|
+
* - Identical role.
|
|
1383
|
+
* - Either an identical name (e.g. 50× "Submit") OR an identical
|
|
1384
|
+
* name prefix once a trailing run of digits is stripped
|
|
1385
|
+
* (e.g. "Action 1" / "Action 240" → key "button::Action ").
|
|
1386
|
+
*
|
|
1387
|
+
* Returns null when the element shouldn't participate in aggregation
|
|
1388
|
+
* (no role at all). Empty/missing names are treated as their own key
|
|
1389
|
+
* so unnamed buttons within a row still group together.
|
|
1390
|
+
*/
|
|
1391
|
+
aggregationKey(role, name) {
|
|
1392
|
+
if (!role)
|
|
1393
|
+
return null;
|
|
1394
|
+
if (!name)
|
|
1395
|
+
return `${role}::`;
|
|
1396
|
+
const m = name.match(/^(.+?)(\d+)$/);
|
|
1397
|
+
if (m)
|
|
1398
|
+
return `${role}::${m[1]}`;
|
|
1399
|
+
return `${role}::${name}`;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Ticket-1: A child is a "renderable leaf" for aggregation purposes if
|
|
1403
|
+
* it would emit exactly one line under the current filter and carries
|
|
1404
|
+
* no descendants that would also render. We only need to look one level
|
|
1405
|
+
* deep — text wrappers like <span> / <strong> inside a <button> are
|
|
1406
|
+
* either ignored or non-interactive and never produce their own line.
|
|
1407
|
+
*/
|
|
1408
|
+
isRenderableLeaf(node, nodeMap, filter) {
|
|
1409
|
+
if (node.ignored)
|
|
1410
|
+
return false;
|
|
1411
|
+
if (node.backendDOMNodeId === undefined)
|
|
1412
|
+
return false;
|
|
1413
|
+
if (this.refMap.get(node.backendDOMNodeId) === undefined)
|
|
1414
|
+
return false;
|
|
1415
|
+
const role = this.getRole(node);
|
|
1416
|
+
if (!this.passesFilter(node, role, filter))
|
|
1417
|
+
return false;
|
|
1418
|
+
if (!node.childIds || node.childIds.length === 0)
|
|
1419
|
+
return true;
|
|
1420
|
+
for (const childId of node.childIds) {
|
|
1421
|
+
const child = nodeMap.get(childId);
|
|
1422
|
+
if (!child || child.ignored)
|
|
1423
|
+
continue;
|
|
1424
|
+
if (child.backendDOMNodeId === undefined)
|
|
1425
|
+
continue;
|
|
1426
|
+
const childRole = this.getRole(child);
|
|
1427
|
+
if (this.passesFilter(child, childRole, filter)) {
|
|
1428
|
+
// The child would render its own line → parent is not a leaf.
|
|
1429
|
+
return false;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Ticket-1: Walk the renderable subtree (main + OOPIFs) and compute which
|
|
1436
|
+
* leaves should be collapsed into summary lines. Leaves are bucketed by
|
|
1437
|
+
* aggregation key; any bucket with ≥{@link AGGREGATE_MIN_COUNT} members
|
|
1438
|
+
* becomes a collapse group. The first member in DOM order becomes the
|
|
1439
|
+
* "anchor" (its position emits the summary line) and the rest land in
|
|
1440
|
+
* the suppressed set so renderNode skips them.
|
|
1441
|
+
*
|
|
1442
|
+
* This runs independently of the ≥10-consecutive-siblings assumption,
|
|
1443
|
+
* which is why it catches the T4.7 benchmark case where 120 "Action N"
|
|
1444
|
+
* buttons are interleaved with headings, paragraphs, and inputs inside
|
|
1445
|
+
* 60 sections that share a single DOM parent.
|
|
1446
|
+
*/
|
|
1447
|
+
prepareAggregateGroups(root, nodeMap, oopifSections, filter) {
|
|
1448
|
+
const buckets = new Map();
|
|
1449
|
+
const walk = (node, map) => {
|
|
1450
|
+
if (!node.ignored &&
|
|
1451
|
+
node.backendDOMNodeId !== undefined &&
|
|
1452
|
+
this.isRenderableLeaf(node, map, filter)) {
|
|
1453
|
+
const role = this.getRole(node);
|
|
1454
|
+
const name = node.name?.value ?? "";
|
|
1455
|
+
const key = this.aggregationKey(role, name);
|
|
1456
|
+
if (key !== null) {
|
|
1457
|
+
let bucket = buckets.get(key);
|
|
1458
|
+
if (!bucket) {
|
|
1459
|
+
bucket = [];
|
|
1460
|
+
buckets.set(key, bucket);
|
|
1461
|
+
}
|
|
1462
|
+
bucket.push(node);
|
|
1463
|
+
// Leaves by definition have no renderable descendants — skip recursion.
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (node.childIds) {
|
|
1468
|
+
for (const childId of node.childIds) {
|
|
1469
|
+
const child = map.get(childId);
|
|
1470
|
+
if (child)
|
|
1471
|
+
walk(child, map);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
walk(root, nodeMap);
|
|
1476
|
+
for (const section of oopifSections) {
|
|
1477
|
+
const oopifMap = new Map();
|
|
1478
|
+
for (const n of section.nodes)
|
|
1479
|
+
oopifMap.set(n.nodeId, n);
|
|
1480
|
+
if (section.nodes.length > 0)
|
|
1481
|
+
walk(section.nodes[0], oopifMap);
|
|
1482
|
+
}
|
|
1483
|
+
this._aggregateAnchors = new Map();
|
|
1484
|
+
this._aggregateSuppressed = new Set();
|
|
1485
|
+
for (const bucket of buckets.values()) {
|
|
1486
|
+
if (bucket.length < A11yTreeProcessor.AGGREGATE_MIN_COUNT)
|
|
1487
|
+
continue;
|
|
1488
|
+
const first = bucket[0];
|
|
1489
|
+
const last = bucket[bucket.length - 1];
|
|
1490
|
+
const firstId = first.backendDOMNodeId;
|
|
1491
|
+
const firstRef = this.refMap.get(firstId);
|
|
1492
|
+
const lastRef = this.refMap.get(last.backendDOMNodeId);
|
|
1493
|
+
if (firstRef === undefined || lastRef === undefined)
|
|
1494
|
+
continue;
|
|
1495
|
+
this._aggregateAnchors.set(firstId, {
|
|
1496
|
+
count: bucket.length,
|
|
1497
|
+
role: this.getRole(first),
|
|
1498
|
+
firstName: first.name?.value ?? "",
|
|
1499
|
+
lastName: last.name?.value ?? "",
|
|
1500
|
+
firstRef,
|
|
1501
|
+
lastRef,
|
|
1502
|
+
});
|
|
1503
|
+
for (let i = 1; i < bucket.length; i++) {
|
|
1504
|
+
this._aggregateSuppressed.add(bucket[i].backendDOMNodeId);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
/** Reset the per-render aggregation state set up by prepareAggregateGroups. */
|
|
1509
|
+
clearAggregateGroups() {
|
|
1510
|
+
this._aggregateAnchors = null;
|
|
1511
|
+
this._aggregateSuppressed = null;
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Ticket-1: Emit the summary line for a collapse-group anchor. Format is
|
|
1515
|
+
* intentionally compact and still carries the addressable ref band so the
|
|
1516
|
+
* LLM can click({ ref: "eN" }) on any individual element inside it.
|
|
1517
|
+
*/
|
|
1518
|
+
emitAggregateLine(anchor, indentLevel, lines) {
|
|
1519
|
+
const indent = " ".repeat(indentLevel);
|
|
1520
|
+
let line = `${indent}[e${anchor.firstRef}..e${anchor.lastRef}] ${anchor.count}× ${anchor.role}`;
|
|
1521
|
+
if (anchor.firstName && anchor.lastName && anchor.firstName !== anchor.lastName) {
|
|
1522
|
+
line += ` "${anchor.firstName}" .. "${anchor.lastName}"`;
|
|
1523
|
+
}
|
|
1524
|
+
else if (anchor.firstName) {
|
|
1525
|
+
line += ` "${anchor.firstName}"`;
|
|
1526
|
+
}
|
|
1527
|
+
lines.push(line);
|
|
1528
|
+
}
|
|
1529
|
+
renderChildren(node, nodeMap, indentLevel, filter, lines, visualMap) {
|
|
1530
|
+
if (!node.childIds)
|
|
1531
|
+
return;
|
|
1532
|
+
for (const childId of node.childIds) {
|
|
1533
|
+
const child = nodeMap.get(childId);
|
|
1534
|
+
if (child) {
|
|
1535
|
+
this.renderNode(child, nodeMap, indentLevel, filter, lines, visualMap);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
/** FR-022: Count content nodes with visible text that would be hidden by filter:interactive.
|
|
1540
|
+
* Used to append a hint in read-page.ts that points the LLM at filter:'all' instead of evaluate. */
|
|
1541
|
+
countHiddenContentNodes(root, nodeMap, oopifSections) {
|
|
1542
|
+
let count = 0;
|
|
1543
|
+
const walk = (node, map) => {
|
|
1544
|
+
if (node.ignored) {
|
|
1545
|
+
// Still walk ignored children — they may wrap visible content
|
|
1546
|
+
}
|
|
1547
|
+
else {
|
|
1548
|
+
const role = node.role?.value ?? "";
|
|
1549
|
+
if (CONTENT_ROLES.has(role)) {
|
|
1550
|
+
const name = node.name?.value ?? "";
|
|
1551
|
+
// Only count nodes with actual visible text content
|
|
1552
|
+
if (name && name.trim().length > 0) {
|
|
1553
|
+
count++;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (node.childIds) {
|
|
1558
|
+
for (const childId of node.childIds) {
|
|
1559
|
+
const child = map.get(childId);
|
|
1560
|
+
if (child)
|
|
1561
|
+
walk(child, map);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
walk(root, nodeMap);
|
|
1566
|
+
for (const section of oopifSections) {
|
|
1567
|
+
const oopifNodeMap = new Map();
|
|
1568
|
+
for (const n of section.nodes)
|
|
1569
|
+
oopifNodeMap.set(n.nodeId, n);
|
|
1570
|
+
if (section.nodes.length > 0)
|
|
1571
|
+
walk(section.nodes[0], oopifNodeMap);
|
|
1572
|
+
}
|
|
1573
|
+
return count;
|
|
1574
|
+
}
|
|
1575
|
+
getRole(node) {
|
|
1576
|
+
return node.role?.value ?? "";
|
|
1577
|
+
}
|
|
1578
|
+
passesFilter(node, role, filter) {
|
|
1579
|
+
if (filter === "all")
|
|
1580
|
+
return true;
|
|
1581
|
+
if (filter === "landmark")
|
|
1582
|
+
return LANDMARK_ROLES.has(role);
|
|
1583
|
+
// interactive and visual filters use the same element selection
|
|
1584
|
+
if (INTERACTIVE_ROLES.has(role))
|
|
1585
|
+
return true;
|
|
1586
|
+
// FR-005: Elements with onclick handlers (e.g. sortable table headers)
|
|
1587
|
+
if (node.backendDOMNodeId !== undefined) {
|
|
1588
|
+
const info = this.nodeInfoMap.get(node.backendDOMNodeId);
|
|
1589
|
+
if (info?.isClickable)
|
|
1590
|
+
return true;
|
|
1591
|
+
}
|
|
1592
|
+
// Check focusable property
|
|
1593
|
+
if (node.properties) {
|
|
1594
|
+
for (const prop of node.properties) {
|
|
1595
|
+
if (prop.name === "focusable" && prop.value.value === true)
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
formatLine(indent, refNum, role, node, nodeMap) {
|
|
1602
|
+
// FR-004: Append HTML id if available
|
|
1603
|
+
const backendNodeId = node.backendDOMNodeId;
|
|
1604
|
+
const htmlId = backendNodeId !== undefined ? this.nodeInfoMap.get(backendNodeId)?.htmlId : undefined;
|
|
1605
|
+
const idSuffix = htmlId ? `#${htmlId}` : "";
|
|
1606
|
+
let line = `${indent}[e${refNum}] ${role}${idSuffix}`;
|
|
1607
|
+
// FR-H5: Prefer AXNode name, fall back to nodeInfoMap (enriched by Phase 3 for clickable generics)
|
|
1608
|
+
const name = node.name?.value
|
|
1609
|
+
|| (backendNodeId !== undefined ? this.nodeInfoMap.get(backendNodeId)?.name : undefined);
|
|
1610
|
+
if (name) {
|
|
1611
|
+
line += ` "${name}"`;
|
|
1612
|
+
// FR-021: Signal truncation so the LLM knows hidden text exists (reached for evaluate otherwise)
|
|
1613
|
+
if (backendNodeId !== undefined) {
|
|
1614
|
+
const fullLen = this.nodeInfoMap.get(backendNodeId)?.nameFullLength;
|
|
1615
|
+
if (fullLen && fullLen > name.length) {
|
|
1616
|
+
const extra = fullLen - name.length;
|
|
1617
|
+
line += ` …[+${extra} chars; use filter:"all" with ref to read subtree]`;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const value = node.value?.value;
|
|
1622
|
+
if (value !== undefined && value !== "") {
|
|
1623
|
+
line += ` value="${value}"`;
|
|
1624
|
+
}
|
|
1625
|
+
// BUG-001: Annotate tables with nearest heading for disambiguation
|
|
1626
|
+
if (role === "table" && nodeMap) {
|
|
1627
|
+
const sectionHeading = this.findSectionHeading(node, nodeMap);
|
|
1628
|
+
if (sectionHeading) {
|
|
1629
|
+
line += ` (section: "${sectionHeading}")`;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
// FR-003: iframe content hint
|
|
1633
|
+
if (role === "Iframe") {
|
|
1634
|
+
line += " (use evaluate to access iframe content)";
|
|
1635
|
+
}
|
|
1636
|
+
// FR-008: Canvas is opaque — direct LLM to use screenshot(som: true)
|
|
1637
|
+
if (role === "canvas" || role === "Canvas") {
|
|
1638
|
+
line += " ⚠ Canvas content is pixels, not DOM. Use screenshot(som: true) to see what's inside.";
|
|
1639
|
+
}
|
|
1640
|
+
// URL for links — shorten to path to save tokens
|
|
1641
|
+
if (role === "link" && node.properties) {
|
|
1642
|
+
for (const prop of node.properties) {
|
|
1643
|
+
if (prop.name === "url" && prop.value.value) {
|
|
1644
|
+
line += ` → ${shortenUrl(String(prop.value.value))}`;
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
// FR-002: target=_blank annotation
|
|
1649
|
+
if (backendNodeId !== undefined) {
|
|
1650
|
+
const linkInfo = this.nodeInfoMap.get(backendNodeId);
|
|
1651
|
+
if (linkInfo?.linkTarget === "_blank") {
|
|
1652
|
+
line += " (opens new tab)";
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
// Disabled marker
|
|
1657
|
+
if (node.properties) {
|
|
1658
|
+
for (const prop of node.properties) {
|
|
1659
|
+
if (prop.name === "disabled" && prop.value.value === true) {
|
|
1660
|
+
line += " (disabled)";
|
|
1661
|
+
break;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
// FR-H6: Tab selected state — helps LLM understand which tab panel is visible
|
|
1666
|
+
if (role === "tab" && node.properties) {
|
|
1667
|
+
for (const prop of node.properties) {
|
|
1668
|
+
if (prop.name === "selected" && prop.value.value === true) {
|
|
1669
|
+
line += " (selected)";
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
// FR-006: contenteditable annotation
|
|
1675
|
+
if (node.properties) {
|
|
1676
|
+
for (const prop of node.properties) {
|
|
1677
|
+
if (prop.name === "editable" && prop.value.value && prop.value.value !== "inherit") {
|
|
1678
|
+
line += " (editable)";
|
|
1679
|
+
break;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
// FR-001: scrollable container annotation
|
|
1684
|
+
if (backendNodeId !== undefined) {
|
|
1685
|
+
const scrollInfo = this.nodeInfoMap.get(backendNodeId);
|
|
1686
|
+
if (scrollInfo?.isScrollable) {
|
|
1687
|
+
line += " (scrollable)";
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return line;
|
|
1691
|
+
}
|
|
1692
|
+
formatHeader(title, count, filter, depth) {
|
|
1693
|
+
const titlePart = title ? `Page: ${title}` : "Page";
|
|
1694
|
+
return `${titlePart} — ${count} ${filter} elements (depth ${depth})`;
|
|
1695
|
+
}
|
|
1696
|
+
getPageTitle(nodes) {
|
|
1697
|
+
// Root node (WebArea) usually has the page title as name
|
|
1698
|
+
if (nodes.length > 0) {
|
|
1699
|
+
const root = nodes[0];
|
|
1700
|
+
if (!root.ignored) {
|
|
1701
|
+
const name = root.name?.value;
|
|
1702
|
+
if (name)
|
|
1703
|
+
return name;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
return "";
|
|
1707
|
+
}
|
|
1708
|
+
getAvailableRefsRange() {
|
|
1709
|
+
if (this.reverseMap.size === 0)
|
|
1710
|
+
return null;
|
|
1711
|
+
const refs = [...this.reverseMap.keys()].sort((a, b) => a - b);
|
|
1712
|
+
return `e${refs[0]}-e${refs[refs.length - 1]}`;
|
|
1713
|
+
}
|
|
1714
|
+
suggestClosestRef(ref) {
|
|
1715
|
+
const match = ref.match(/^e(\d+)$/);
|
|
1716
|
+
if (!match || this.reverseMap.size === 0)
|
|
1717
|
+
return null;
|
|
1718
|
+
const requested = parseInt(match[1], 10);
|
|
1719
|
+
const refs = [...this.reverseMap.keys()].sort((a, b) => a - b);
|
|
1720
|
+
let closest = refs[0];
|
|
1721
|
+
let minDist = Math.abs(requested - closest);
|
|
1722
|
+
for (const r of refs) {
|
|
1723
|
+
const dist = Math.abs(requested - r);
|
|
1724
|
+
if (dist < minDist) {
|
|
1725
|
+
closest = r;
|
|
1726
|
+
minDist = dist;
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return `e${closest}`;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Story 13a.2: Classify a ref for pre-click ambient context decision.
|
|
1733
|
+
* Returns classification based on cached AXNode properties (0 CDP calls).
|
|
1734
|
+
*/
|
|
1735
|
+
classifyRef(ref) {
|
|
1736
|
+
const match = ref.match(/^e?(\d+)$/);
|
|
1737
|
+
if (!match)
|
|
1738
|
+
return "static";
|
|
1739
|
+
const refNum = parseInt(match[1], 10);
|
|
1740
|
+
const backendNodeId = this.reverseMap.get(refNum);
|
|
1741
|
+
if (backendNodeId === undefined)
|
|
1742
|
+
return "static";
|
|
1743
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
1744
|
+
if (!info)
|
|
1745
|
+
return "static";
|
|
1746
|
+
if (info.disabled)
|
|
1747
|
+
return "disabled";
|
|
1748
|
+
// hasPopup can be "false" (string) — only classify as widget-state for truthy popup types
|
|
1749
|
+
if (info.expanded !== undefined
|
|
1750
|
+
|| (info.hasPopup !== undefined && info.hasPopup !== "false")
|
|
1751
|
+
|| info.checked !== undefined
|
|
1752
|
+
|| info.pressed !== undefined)
|
|
1753
|
+
return "widget-state";
|
|
1754
|
+
if (INTERACTIVE_ROLES.has(info.role) || info.isClickable)
|
|
1755
|
+
return "clickable";
|
|
1756
|
+
return "static";
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* FR-008: Return a compact list of known interactive elements for error hints.
|
|
1760
|
+
* Used when a CSS selector fails to provide the LLM with actionable alternatives.
|
|
1761
|
+
* ZERO CDP calls — purely in-memory from cached nodeInfoMap.
|
|
1762
|
+
*/
|
|
1763
|
+
getInteractiveElements(limit = 8) {
|
|
1764
|
+
if (this.reverseMap.size === 0)
|
|
1765
|
+
return [];
|
|
1766
|
+
const lines = [];
|
|
1767
|
+
const sortedRefs = [...this.reverseMap.entries()].sort((a, b) => a[0] - b[0]);
|
|
1768
|
+
for (const [refNum, backendNodeId] of sortedRefs) {
|
|
1769
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
1770
|
+
if (!info || !(INTERACTIVE_ROLES.has(info.role) || info.isClickable))
|
|
1771
|
+
continue;
|
|
1772
|
+
const name = info.name ? ` '${info.name}'` : "";
|
|
1773
|
+
const idSuffix = info.htmlId ? `#${info.htmlId}` : "";
|
|
1774
|
+
lines.push(`[e${refNum}] ${info.role}${idSuffix}${name}`);
|
|
1775
|
+
if (lines.length >= limit)
|
|
1776
|
+
break;
|
|
1777
|
+
}
|
|
1778
|
+
return lines;
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* FR-002: Lightweight snapshot map for DOM-Diff.
|
|
1782
|
+
* Returns Map<refNum, "role\0name"> for all nodes with a name.
|
|
1783
|
+
* ZERO CDP calls — purely in-memory.
|
|
1784
|
+
*/
|
|
1785
|
+
getSnapshotMap() {
|
|
1786
|
+
const map = new Map();
|
|
1787
|
+
for (const [refNum, backendNodeId] of this.reverseMap) {
|
|
1788
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
1789
|
+
if (!info || (!info.name && !CONTEXT_ROLES.has(info.role) && !INTERACTIVE_ROLES.has(info.role) && !info.isClickable))
|
|
1790
|
+
continue;
|
|
1791
|
+
map.set(refNum, `${info.role}\0${info.name ?? ""}`);
|
|
1792
|
+
}
|
|
1793
|
+
return map;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* FR-002: Compute diff between two snapshot maps.
|
|
1797
|
+
* Returns only meaningful changes (role+name), ignoring nodes without names.
|
|
1798
|
+
*/
|
|
1799
|
+
static diffSnapshots(before, after) {
|
|
1800
|
+
const changes = [];
|
|
1801
|
+
// Changed or removed
|
|
1802
|
+
for (const [refNum, beforeVal] of before) {
|
|
1803
|
+
const afterVal = after.get(refNum);
|
|
1804
|
+
if (afterVal === undefined) {
|
|
1805
|
+
// Node removed
|
|
1806
|
+
const [role, name] = beforeVal.split("\0");
|
|
1807
|
+
if (name) { // Only report if it had visible content
|
|
1808
|
+
changes.push({ type: "removed", ref: `e${refNum}`, role, after: "", before: name });
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
else if (afterVal !== beforeVal) {
|
|
1812
|
+
// Node changed
|
|
1813
|
+
const [roleBefore, nameBefore] = beforeVal.split("\0");
|
|
1814
|
+
const [roleAfter, nameAfter] = afterVal.split("\0");
|
|
1815
|
+
// Only report if the *name* (visible text) actually changed
|
|
1816
|
+
if (nameBefore !== nameAfter) {
|
|
1817
|
+
changes.push({
|
|
1818
|
+
type: "changed",
|
|
1819
|
+
ref: `e${refNum}`,
|
|
1820
|
+
role: roleAfter || roleBefore,
|
|
1821
|
+
before: nameBefore,
|
|
1822
|
+
after: nameAfter,
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
// Added
|
|
1828
|
+
for (const [refNum, afterVal] of after) {
|
|
1829
|
+
if (!before.has(refNum)) {
|
|
1830
|
+
const [role, name] = afterVal.split("\0");
|
|
1831
|
+
if (name) { // Only report if it has visible content
|
|
1832
|
+
changes.push({ type: "added", ref: `e${refNum}`, role, after: name });
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
return changes;
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* FR-002: Format DOM changes as compact context string for LLM.
|
|
1840
|
+
* Prioritizes alerts/status, then shows changes near the action, caps at ~30 lines.
|
|
1841
|
+
*/
|
|
1842
|
+
static formatDomDiff(changes, url) {
|
|
1843
|
+
if (changes.length === 0)
|
|
1844
|
+
return null;
|
|
1845
|
+
// Sort: alerts/status first, then added, then changed, then removed
|
|
1846
|
+
const priority = (c) => {
|
|
1847
|
+
if (c.role === "alert" || c.role === "status")
|
|
1848
|
+
return 0;
|
|
1849
|
+
if (c.type === "added")
|
|
1850
|
+
return 1;
|
|
1851
|
+
if (c.type === "changed")
|
|
1852
|
+
return 2;
|
|
1853
|
+
return 3;
|
|
1854
|
+
};
|
|
1855
|
+
changes.sort((a, b) => priority(a) - priority(b));
|
|
1856
|
+
const lines = [];
|
|
1857
|
+
const urlSuffix = url ? ` — ${shortenUrl(url)}` : "";
|
|
1858
|
+
lines.push(`--- Action Result (${changes.length} changes)${urlSuffix} ---`);
|
|
1859
|
+
const maxLines = 30;
|
|
1860
|
+
for (const c of changes.slice(0, maxLines)) {
|
|
1861
|
+
const refTag = INTERACTIVE_ROLES.has(c.role) || CONTEXT_ROLES.has(c.role)
|
|
1862
|
+
? `[${c.ref}] ` : "";
|
|
1863
|
+
if (c.type === "added") {
|
|
1864
|
+
const roleLabel = c.role === "alert" || c.role === "status" ? c.role : c.role;
|
|
1865
|
+
lines.push(` NEW ${refTag}${roleLabel} "${c.after}"`);
|
|
1866
|
+
}
|
|
1867
|
+
else if (c.type === "changed") {
|
|
1868
|
+
lines.push(` CHANGED ${refTag}${c.role} "${c.before}" → "${c.after}"`);
|
|
1869
|
+
}
|
|
1870
|
+
else {
|
|
1871
|
+
lines.push(` REMOVED ${refTag}${c.role} "${c.before}"`);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
if (changes.length > maxLines) {
|
|
1875
|
+
lines.push(`... (${changes.length - maxLines} more changes)`);
|
|
1876
|
+
}
|
|
1877
|
+
return lines.join("\n");
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Story 13a.2: Enriched compact snapshot with headings, alerts, status
|
|
1881
|
+
* plus interactive elements. ZERO CDP calls — purely in-memory.
|
|
1882
|
+
*/
|
|
1883
|
+
getCompactSnapshot(maxTokens = 2000) {
|
|
1884
|
+
if (this.reverseMap.size === 0)
|
|
1885
|
+
return null;
|
|
1886
|
+
const contextLines = [];
|
|
1887
|
+
const interactiveLines = [];
|
|
1888
|
+
let tokensSoFar = 0;
|
|
1889
|
+
const sortedRefs = [...this.reverseMap.entries()].sort((a, b) => a[0] - b[0]);
|
|
1890
|
+
for (const [refNum, backendNodeId] of sortedRefs) {
|
|
1891
|
+
const info = this.nodeInfoMap.get(backendNodeId);
|
|
1892
|
+
if (!info)
|
|
1893
|
+
continue;
|
|
1894
|
+
let line = null;
|
|
1895
|
+
// Story 13a.2: Context roles (headings, alerts, status) for orientation
|
|
1896
|
+
if (CONTEXT_ROLES.has(info.role)) {
|
|
1897
|
+
if (info.role === "heading" && info.name) {
|
|
1898
|
+
const lvl = info.level ?? 1;
|
|
1899
|
+
line = `[h${lvl}] "${info.name}"`;
|
|
1900
|
+
}
|
|
1901
|
+
else if ((info.role === "alert" || info.role === "status") && info.name) {
|
|
1902
|
+
line = `[${info.role}] "${info.name}"`;
|
|
1903
|
+
}
|
|
1904
|
+
if (line) {
|
|
1905
|
+
const lineTokens = Math.ceil(line.length / 4);
|
|
1906
|
+
if (tokensSoFar + lineTokens <= maxTokens) {
|
|
1907
|
+
contextLines.push(line);
|
|
1908
|
+
tokensSoFar += lineTokens;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
// Interactive elements (existing behavior) + FR-005: onclick-clickable elements
|
|
1914
|
+
if (!INTERACTIVE_ROLES.has(info.role) && !info.isClickable)
|
|
1915
|
+
continue;
|
|
1916
|
+
const name = info.name ? ` '${info.name}'` : "";
|
|
1917
|
+
const idSuffix = info.htmlId ? `#${info.htmlId}` : "";
|
|
1918
|
+
line = `[e${refNum}] ${info.role}${idSuffix}${name}`;
|
|
1919
|
+
const lineTokens = Math.ceil(line.length / 4);
|
|
1920
|
+
if (tokensSoFar + lineTokens > maxTokens) {
|
|
1921
|
+
const remaining = sortedRefs.filter(([, id]) => INTERACTIVE_ROLES.has(this.nodeInfoMap.get(id)?.role ?? "")).length - interactiveLines.length;
|
|
1922
|
+
interactiveLines.push(`... (${remaining} more)`);
|
|
1923
|
+
break;
|
|
1924
|
+
}
|
|
1925
|
+
interactiveLines.push(line);
|
|
1926
|
+
tokensSoFar += lineTokens;
|
|
1927
|
+
}
|
|
1928
|
+
if (contextLines.length === 0 && interactiveLines.length === 0)
|
|
1929
|
+
return null;
|
|
1930
|
+
const url = this.lastUrl ? ` — ${shortenUrl(this.lastUrl)}` : "";
|
|
1931
|
+
const parts = [];
|
|
1932
|
+
const counts = interactiveLines.length > 0 ? `${interactiveLines.length} interactive` : `${contextLines.length} context`;
|
|
1933
|
+
parts.push(`--- Page Context (${counts})${url} ---`);
|
|
1934
|
+
if (contextLines.length > 0)
|
|
1935
|
+
parts.push(...contextLines);
|
|
1936
|
+
if (interactiveLines.length > 0)
|
|
1937
|
+
parts.push(...interactiveLines);
|
|
1938
|
+
return parts.join("\n");
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
function shortenUrl(url) {
|
|
1942
|
+
try {
|
|
1943
|
+
const parsed = new URL(url);
|
|
1944
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
1945
|
+
}
|
|
1946
|
+
catch {
|
|
1947
|
+
return url;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
export class RefNotFoundError extends Error {
|
|
1951
|
+
constructor(message) {
|
|
1952
|
+
super(message);
|
|
1953
|
+
this.name = "RefNotFoundError";
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
export const a11yTree = new A11yTreeProcessor();
|