@@ -13,7 +13,9 @@ export function formatSimplifiedTree(
13
13
level = 0 ,
14
14
) : string {
15
15
const indent = " " . repeat ( level ) ;
16
- let result = `${ indent } [${ node . nodeId } ] ${ node . role } ${ node . name ? `: ${ node . name } ` : "" } \n` ;
16
+ let result = `${ indent } [${ node . nodeId } ] ${ node . role } ${
17
+ node . name ? `: ${ node . name } ` : ""
18
+ } \n`;
17
19
18
20
if ( node . children ?. length ) {
19
21
result += node . children
@@ -29,39 +31,113 @@ export function formatSimplifiedTree(
29
31
* 1. Removes generic/none nodes with no children
30
32
* 2. Collapses generic/none nodes with single child
31
33
* 3. Keeps generic/none nodes with multiple children but cleans their subtrees
34
+ * and attempts to resolve their role to a DOM tag name
32
35
*/
33
- function cleanStructuralNodes (
36
+ async function cleanStructuralNodes (
34
37
node : AccessibilityNode ,
35
- ) : AccessibilityNode | null {
36
- // Filter out nodes with negative IDs
38
+ page ?: StagehandPage ,
39
+ logger ?: ( logLine : LogLine ) => void ,
40
+ ) : Promise < AccessibilityNode | null > {
41
+ // 1) Filter out nodes with negative IDs
37
42
if ( node . nodeId && parseInt ( node . nodeId ) < 0 ) {
38
43
return null ;
39
44
}
40
45
41
- // Base case: leaf node
42
- if ( ! node . children ) {
46
+ // 2) Base case: if no children exist, this is effectively a leaf.
47
+ // If it's "generic" or "none", we remove it; otherwise, keep it.
48
+ if ( ! node . children || node . children . length === 0 ) {
43
49
return node . role === "generic" || node . role === "none" ? null : node ;
44
50
}
45
51
46
- // Recursively clean children
47
- const cleanedChildren = node . children
48
- . map ( ( child ) => cleanStructuralNodes ( child ) )
49
- . filter ( Boolean ) as AccessibilityNode [ ] ;
50
-
51
- // Handle generic/none nodes specially
52
+ // 3) Recursively clean children
53
+ const cleanedChildrenPromises = node . children . map ( ( child ) =>
54
+ cleanStructuralNodes ( child , page , logger ) ,
55
+ ) ;
56
+ const resolvedChildren = await Promise . all ( cleanedChildrenPromises ) ;
57
+ const cleanedChildren = resolvedChildren . filter (
58
+ ( child ) : child is AccessibilityNode => child !== null ,
59
+ ) ;
60
+
61
+ // 4) **Prune** "generic" or "none" nodes first,
62
+ // before resolving them to their tag names.
52
63
if ( node . role === "generic" || node . role === "none" ) {
53
64
if ( cleanedChildren . length === 1 ) {
54
- // Collapse single-child generic nodes
65
+ // Collapse single-child structural node
55
66
return cleanedChildren [ 0 ] ;
56
- } else if ( cleanedChildren . length > 1 ) {
57
- // Keep generic nodes with multiple children
58
- return { ...node , children : cleanedChildren } ;
67
+ } else if ( cleanedChildren . length === 0 ) {
68
+ // Remove empty structural node
69
+ return null ;
70
+ }
71
+ // If we have multiple children, we keep this node as a container.
72
+ // We'll update role below if needed.
73
+ }
74
+
75
+ // 5) If we still have a "generic"/"none" node after pruning
76
+ // (i.e., because it had multiple children), now we try
77
+ // to resolve and replace its role with the DOM tag name.
78
+ if (
79
+ page &&
80
+ logger &&
81
+ node . backendDOMNodeId !== undefined &&
82
+ ( node . role === "generic" || node . role === "none" )
83
+ ) {
84
+ try {
85
+ const { object } = await page . sendCDP < {
86
+ object : { objectId ?: string } ;
87
+ } > ( "DOM.resolveNode" , {
88
+ backendNodeId : node . backendDOMNodeId ,
89
+ } ) ;
90
+
91
+ if ( object && object . objectId ) {
92
+ try {
93
+ // Get the tagName for the node
94
+ const { result } = await page . sendCDP < {
95
+ result : { type : string ; value ?: string } ;
96
+ } > ( "Runtime.callFunctionOn" , {
97
+ objectId : object . objectId ,
98
+ functionDeclaration : `
99
+ function() {
100
+ return this.tagName ? this.tagName.toLowerCase() : "";
101
+ }
102
+ ` ,
103
+ returnByValue : true ,
104
+ } ) ;
105
+
106
+ // If we got a tagName, update the node's role
107
+ if ( result ?. value ) {
108
+ node . role = result . value ;
109
+ }
110
+ } catch ( tagNameError ) {
111
+ logger ( {
112
+ category : "observation" ,
113
+ message : `Could not fetch tagName for node ${ node . backendDOMNodeId } ` ,
114
+ level : 2 ,
115
+ auxiliary : {
116
+ error : {
117
+ value : tagNameError . message ,
118
+ type : "string" ,
119
+ } ,
120
+ } ,
121
+ } ) ;
122
+ }
123
+ }
124
+ } catch ( resolveError ) {
125
+ logger ( {
126
+ category : "observation" ,
127
+ message : `Could not resolve DOM node ID ${ node . backendDOMNodeId } ` ,
128
+ level : 2 ,
129
+ auxiliary : {
130
+ error : {
131
+ value : resolveError . message ,
132
+ type : "string" ,
133
+ } ,
134
+ } ,
135
+ } ) ;
59
136
}
60
- // Remove generic nodes with no children
61
- return null ;
62
137
}
63
138
64
- // For non-generic nodes, keep them if they have children after cleaning
139
+ // 6) Return the updated node.
140
+ // If it has children, update them; otherwise keep it as-is.
65
141
return cleanedChildren . length > 0
66
142
? { ...node , children : cleanedChildren }
67
143
: node ;
@@ -73,13 +149,23 @@ function cleanStructuralNodes(
73
149
* @param nodes - Flat array of accessibility nodes from the CDP
74
150
* @returns Object containing both the tree structure and a simplified string representation
75
151
*/
76
- export function buildHierarchicalTree ( nodes : AccessibilityNode [ ] ) : TreeResult {
152
+ export async function buildHierarchicalTree (
153
+ nodes : AccessibilityNode [ ] ,
154
+ page ?: StagehandPage ,
155
+ logger ?: ( logLine : LogLine ) => void ,
156
+ ) : Promise < TreeResult > {
77
157
// Map to store processed nodes for quick lookup
78
158
const nodeMap = new Map < string , AccessibilityNode > ( ) ;
79
159
80
160
// First pass: Create nodes that are meaningful
81
161
// We only keep nodes that either have a name or children to avoid cluttering the tree
82
162
nodes . forEach ( ( node ) => {
163
+ // Skip node if its ID is negative (e.g., "-1000002014")
164
+ const nodeIdValue = parseInt ( node . nodeId , 10 ) ;
165
+ if ( nodeIdValue < 0 ) {
166
+ return ;
167
+ }
168
+
83
169
const hasChildren = node . childIds && node . childIds . length > 0 ;
84
170
const hasValidName = node . name && node . name . trim ( ) !== "" ;
85
171
const isInteractive =
@@ -99,6 +185,9 @@ export function buildHierarchicalTree(nodes: AccessibilityNode[]): TreeResult {
99
185
...( hasValidName && { name : node . name } ) , // Only include name if it exists and isn't empty
100
186
...( node . description && { description : node . description } ) ,
101
187
...( node . value && { value : node . value } ) ,
188
+ ...( node . backendDOMNodeId !== undefined && {
189
+ backendDOMNodeId : node . backendDOMNodeId ,
190
+ } ) ,
102
191
} ) ;
103
192
} ) ;
104
193
@@ -119,13 +208,18 @@ export function buildHierarchicalTree(nodes: AccessibilityNode[]): TreeResult {
119
208
} ) ;
120
209
121
210
// Final pass: Build the root-level tree and clean up structural nodes
122
- const finalTree = nodes
211
+ const rootNodes = nodes
123
212
. filter ( ( node ) => ! node . parentId && nodeMap . has ( node . nodeId ) ) // Get root nodes
124
213
. map ( ( node ) => nodeMap . get ( node . nodeId ) )
125
- . filter ( Boolean )
126
- . map ( ( node ) => cleanStructuralNodes ( node ) )
127
214
. filter ( Boolean ) as AccessibilityNode [ ] ;
128
215
216
+ const cleanedTreePromises = rootNodes . map ( ( node ) =>
217
+ cleanStructuralNodes ( node , page , logger ) ,
218
+ ) ;
219
+ const finalTree = ( await Promise . all ( cleanedTreePromises ) ) . filter (
220
+ Boolean ,
221
+ ) as AccessibilityNode [ ] ;
222
+
129
223
// Generate a simplified string representation of the tree
130
224
const simplifiedFormat = finalTree
131
225
. map ( ( node ) => formatSimplifiedTree ( node ) )
@@ -137,29 +231,43 @@ export function buildHierarchicalTree(nodes: AccessibilityNode[]): TreeResult {
137
231
} ;
138
232
}
139
233
234
+ /**
235
+ * Retrieves the full accessibility tree via CDP and transforms it into a hierarchical structure.
236
+ */
140
237
export async function getAccessibilityTree (
141
238
page : StagehandPage ,
142
239
logger : ( logLine : LogLine ) => void ,
143
- ) {
240
+ ) : Promise < TreeResult > {
144
241
await page . enableCDP ( "Accessibility" ) ;
145
242
146
243
try {
244
+ // Fetch the full accessibility tree from Chrome DevTools Protocol
147
245
const { nodes } = await page . sendCDP < { nodes : AXNode [ ] } > (
148
246
"Accessibility.getFullAXTree" ,
149
247
) ;
248
+ const startTime = Date . now ( ) ;
150
249
151
- // Extract specific sources
152
- const sources = nodes . map ( ( node ) => ( {
153
- role : node . role ?. value ,
154
- name : node . name ?. value ,
155
- description : node . description ?. value ,
156
- value : node . value ?. value ,
157
- nodeId : node . nodeId ,
158
- parentId : node . parentId ,
159
- childIds : node . childIds ,
160
- } ) ) ;
161
250
// Transform into hierarchical structure
162
- const hierarchicalTree = buildHierarchicalTree ( sources ) ;
251
+ const hierarchicalTree = await buildHierarchicalTree (
252
+ nodes . map ( ( node ) => ( {
253
+ role : node . role ?. value ,
254
+ name : node . name ?. value ,
255
+ description : node . description ?. value ,
256
+ value : node . value ?. value ,
257
+ nodeId : node . nodeId ,
258
+ backendDOMNodeId : node . backendDOMNodeId ,
259
+ parentId : node . parentId ,
260
+ childIds : node . childIds ,
261
+ } ) ) ,
262
+ page ,
263
+ logger ,
264
+ ) ;
265
+
266
+ logger ( {
267
+ category : "observation" ,
268
+ message : `got accessibility tree in ${ Date . now ( ) - startTime } ms` ,
269
+ level : 1 ,
270
+ } ) ;
163
271
164
272
return hierarchicalTree ;
165
273
} catch ( error ) {
@@ -258,7 +366,6 @@ export async function performPlaywrightMethod(
258
366
method : string ,
259
367
args : unknown [ ] ,
260
368
xpath : string ,
261
- // domSettleTimeoutMs?: number,
262
369
) {
263
370
const locator = stagehandPage . locator ( `xpath=${ xpath } ` ) . first ( ) ;
264
371
const initialUrl = stagehandPage . url ( ) ;
@@ -503,7 +610,6 @@ export async function performPlaywrightMethod(
503
610
await newOpenedTab . close ( ) ;
504
611
await stagehandPage . goto ( newOpenedTab . url ( ) ) ;
505
612
await stagehandPage . waitForLoadState ( "domcontentloaded" ) ;
506
- // await stagehandPage._waitForSettledDom(domSettleTimeoutMs);
507
613
}
508
614
509
615
await Promise . race ( [
@@ -564,6 +670,4 @@ export async function performPlaywrightMethod(
564
670
`Method ${ method } not supported` ,
565
671
) ;
566
672
}
567
-
568
- // await stagehandPage._waitForSettledDom(domSettleTimeoutMs);
569
673
}
0 commit comments