Skip to content

Commit 628e534

Browse files
optimize CDP calls when building hybrid tree (#757)
1 parent ac07a05 commit 628e534

File tree

3 files changed

+68
-65
lines changed

3 files changed

+68
-65
lines changed

.changeset/deep-apes-hug.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand-lib": patch
3+
---
4+
5+
optimize CDP calls when building hybrid tree

lib/a11y/utils.ts

Lines changed: 55 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { AccessibilityNode, TreeResult, AXNode } from "../../types/context";
1+
import {
2+
AccessibilityNode,
3+
TreeResult,
4+
AXNode,
5+
DOMNode,
6+
} from "../../types/context";
27
import { StagehandPage } from "../StagehandPage";
38
import { LogLine } from "../../types/log";
49
import { CDPSession, Page, Locator } from "playwright";
@@ -46,6 +51,41 @@ export function formatSimplifiedTree(
4651
return result;
4752
}
4853

54+
/**
55+
* Builds a map of `backendNodeId to tagName` with a single CDP round-trip.
56+
*/
57+
async function buildBackendIdTagNameMap(
58+
page: StagehandPage,
59+
): Promise<Map<number, string>> {
60+
const map = new Map<number, string>();
61+
62+
await page.enableCDP("DOM");
63+
try {
64+
const { root } = await page.sendCDP<{ root: DOMNode }>("DOM.getDocument", {
65+
depth: -1,
66+
pierce: true,
67+
});
68+
69+
/* Recursively walk the DOM tree */
70+
const walk = (node: DOMNode): void => {
71+
if (node.backendNodeId && node.nodeName) {
72+
map.set(node.backendNodeId, String(node.nodeName).toLowerCase());
73+
}
74+
75+
// Children, shadow roots, template content, etc.
76+
if (node.children) node.children.forEach(walk);
77+
if (node.shadowRoots) node.shadowRoots.forEach(walk);
78+
if (node.contentDocument) walk(node.contentDocument);
79+
};
80+
81+
walk(root);
82+
} finally {
83+
await page.disableCDP("DOM");
84+
}
85+
86+
return map;
87+
}
88+
4989
/**
5090
* Helper function to remove or collapse unnecessary structural nodes
5191
* Handles three cases:
@@ -56,7 +96,7 @@ export function formatSimplifiedTree(
5696
*/
5797
async function cleanStructuralNodes(
5898
node: AccessibilityNode,
59-
page?: StagehandPage,
99+
tagNameMap: Map<number, string>,
60100
logger?: (logLine: LogLine) => void,
61101
): Promise<AccessibilityNode | null> {
62102
// 1) Filter out nodes with negative IDs
@@ -72,7 +112,7 @@ async function cleanStructuralNodes(
72112

73113
// 3) Recursively clean children
74114
const cleanedChildrenPromises = node.children.map((child) =>
75-
cleanStructuralNodes(child, page, logger),
115+
cleanStructuralNodes(child, tagNameMap, logger),
76116
);
77117
const resolvedChildren = await Promise.all(cleanedChildrenPromises);
78118
let cleanedChildren = resolvedChildren.filter(
@@ -94,67 +134,14 @@ async function cleanStructuralNodes(
94134
}
95135

96136
// 5) If we still have a "generic"/"none" node after pruning
97-
// (i.e., because it had multiple children), now we try
98-
// to resolve and replace its role with the DOM tag name.
137+
// (i.e., because it had multiple children), replace the role
138+
// with the DOM tag name.
99139
if (
100-
page &&
101-
logger &&
102-
node.backendDOMNodeId !== undefined &&
103-
(node.role === "generic" || node.role === "none")
140+
(node.role === "generic" || node.role === "none") &&
141+
node.backendDOMNodeId !== undefined
104142
) {
105-
try {
106-
const { object } = await page.sendCDP<{
107-
object: { objectId?: string };
108-
}>("DOM.resolveNode", {
109-
backendNodeId: node.backendDOMNodeId,
110-
});
111-
112-
if (object && object.objectId) {
113-
try {
114-
// Get the tagName for the node
115-
const { result } = await page.sendCDP<{
116-
result: { type: string; value?: string };
117-
}>("Runtime.callFunctionOn", {
118-
objectId: object.objectId,
119-
functionDeclaration: `
120-
function() {
121-
return this.tagName ? this.tagName.toLowerCase() : "";
122-
}
123-
`,
124-
returnByValue: true,
125-
});
126-
127-
// If we got a tagName, update the node's role
128-
if (result?.value) {
129-
node.role = result.value;
130-
}
131-
} catch (tagNameError) {
132-
logger({
133-
category: "observation",
134-
message: `Could not fetch tagName for node ${node.backendDOMNodeId}`,
135-
level: 2,
136-
auxiliary: {
137-
error: {
138-
value: tagNameError.message,
139-
type: "string",
140-
},
141-
},
142-
});
143-
}
144-
}
145-
} catch (resolveError) {
146-
logger({
147-
category: "observation",
148-
message: `Could not resolve DOM node ID ${node.backendDOMNodeId}`,
149-
level: 2,
150-
auxiliary: {
151-
error: {
152-
value: resolveError.message,
153-
type: "string",
154-
},
155-
},
156-
});
157-
}
143+
const tagName = tagNameMap.get(node.backendDOMNodeId);
144+
if (tagName) node.role = tagName;
158145
}
159146

160147
// rm redundant StaticText children
@@ -183,7 +170,7 @@ async function cleanStructuralNodes(
183170
*/
184171
export async function buildHierarchicalTree(
185172
nodes: AccessibilityNode[],
186-
page?: StagehandPage,
173+
tagNameMap: Map<number, string>,
187174
logger?: (logLine: LogLine) => void,
188175
): Promise<TreeResult> {
189176
// Map to store nodeId -> URL for only those nodes that do have a URL.
@@ -264,7 +251,7 @@ export async function buildHierarchicalTree(
264251
.filter(Boolean) as AccessibilityNode[];
265252

266253
const cleanedTreePromises = rootNodes.map((node) =>
267-
cleanStructuralNodes(node, page, logger),
254+
cleanStructuralNodes(node, tagNameMap, logger),
268255
);
269256
const finalTree = (await Promise.all(cleanedTreePromises)).filter(
270257
Boolean,
@@ -291,6 +278,9 @@ export async function getAccessibilityTree(
291278
logger: (logLine: LogLine) => void,
292279
selector?: string,
293280
): Promise<TreeResult> {
281+
/* Build tag-name map once, reuse everywhere */
282+
const tagNameMap = await buildBackendIdTagNameMap(page);
283+
294284
await page.enableCDP("Accessibility");
295285

296286
try {
@@ -371,7 +361,7 @@ export async function getAccessibilityTree(
371361
properties: node.properties,
372362
};
373363
}),
374-
page,
364+
tagNameMap,
375365
logger,
376366
);
377367

types/context.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ export interface TreeResult {
4545
idToUrl: Record<string, string>;
4646
}
4747

48+
export type DOMNode = {
49+
backendNodeId?: number;
50+
nodeName?: string;
51+
children?: DOMNode[];
52+
shadowRoots?: DOMNode[];
53+
contentDocument?: DOMNode;
54+
};
55+
4856
export interface EnhancedContext
4957
extends Omit<PlaywrightContext, "newPage" | "pages"> {
5058
newPage(): Promise<Page>;

0 commit comments

Comments
 (0)