Skip to content

Commit da2e5d1

Browse files
authored
A11y empty xpath fix (#458)
* js function declared as string * fixed a11y missing roles, improved xpath gen for text nodes * added changeset
1 parent c062ab8 commit da2e5d1

File tree

3 files changed

+83
-21
lines changed

3 files changed

+83
-21
lines changed

Diff for: .changeset/gentle-pans-mix.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
Updated getAccessibilityTree() to make sure it doesn't skip useful nodes. Improved getXPathByResolvedObjectId() to account for text nodes and not skip generation

Diff for: lib/a11y/utils.ts

+44-18
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ export function formatSimplifiedTree(
3333
function cleanStructuralNodes(
3434
node: AccessibilityNode,
3535
): AccessibilityNode | null {
36+
// Filter out nodes with negative IDs
37+
if (node.nodeId && parseInt(node.nodeId) < 0) {
38+
return null;
39+
}
40+
3641
// Base case: leaf node
3742
if (!node.children) {
3843
return node.role === "generic" || node.role === "none" ? null : node;
@@ -181,33 +186,54 @@ export async function getAccessibilityTree(
181186

182187
// This function is wrapped into a string and sent as a CDP command
183188
// It is not meant to be actually executed here
184-
const functionString = `function getNodePath(el) {
185-
if (!el || el.nodeType !== Node.ELEMENT_NODE) return "";
186-
const pathSegments = [];
189+
const functionString = `
190+
function getNodePath(el) {
191+
if (!el || (el.nodeType !== Node.ELEMENT_NODE && el.nodeType !== Node.TEXT_NODE)) {
192+
console.log("el is not a valid node type");
193+
return "";
194+
}
195+
196+
const parts = [];
187197
let current = el;
188-
while (current && current.nodeType === Node.ELEMENT_NODE) {
189-
const tagName = current.nodeName.toLowerCase();
190-
let index = 1;
191-
let sibling = current.previousSibling;
192-
while (sibling) {
198+
199+
while (current && (current.nodeType === Node.ELEMENT_NODE || current.nodeType === Node.TEXT_NODE)) {
200+
let index = 0;
201+
let hasSameTypeSiblings = false;
202+
const siblings = current.parentElement
203+
? Array.from(current.parentElement.childNodes)
204+
: [];
205+
206+
for (let i = 0; i < siblings.length; i++) {
207+
const sibling = siblings[i];
193208
if (
194-
sibling.nodeType === Node.ELEMENT_NODE &&
195-
sibling.nodeName.toLowerCase() === tagName
209+
sibling.nodeType === current.nodeType &&
210+
sibling.nodeName === current.nodeName
196211
) {
197-
index++;
212+
index = index + 1;
213+
hasSameTypeSiblings = true;
214+
if (sibling.isSameNode(current)) {
215+
break;
216+
}
198217
}
199-
sibling = sibling.previousSibling;
200218
}
201-
const segment = index > 1 ? tagName + "[" + index + "]" : tagName;
202-
pathSegments.unshift(segment);
203-
current = current.parentNode;
219+
204220
if (!current || !current.parentNode) break;
205-
if (current.nodeName.toLowerCase() === "html") {
206-
pathSegments.unshift("html");
221+
if (current.nodeName.toLowerCase() === "html"){
222+
parts.unshift("html");
207223
break;
208224
}
225+
226+
// text nodes are handled differently in XPath
227+
if (current.nodeName !== "#text") {
228+
const tagName = current.nodeName.toLowerCase();
229+
const pathIndex = hasSameTypeSiblings ? \`[\${index}]\` : "";
230+
parts.unshift(\`\${tagName}\${pathIndex}\`);
231+
}
232+
233+
current = current.parentElement;
209234
}
210-
return "/" + pathSegments.join("/");
235+
236+
return parts.length ? \`/\${parts.join("/")}\` : "";
211237
}`;
212238

213239
export async function getXPathByResolvedObjectId(

Diff for: lib/handlers/observeHandler.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,46 @@ export class StagehandObserveHandler {
114114

115115
if (useAccessibilityTree) {
116116
// Generate xpath for the given element if not found in selectorMap
117+
this.logger({
118+
category: "observation",
119+
message: "Getting xpath for element",
120+
level: 1,
121+
auxiliary: {
122+
elementId: {
123+
value: elementId.toString(),
124+
type: "string",
125+
},
126+
},
127+
});
128+
129+
const args = { backendNodeId: elementId };
117130
const { object } = await this.stagehandPage.sendCDP<{
118131
object: { objectId: string };
119-
}>("DOM.resolveNode", {
120-
backendNodeId: elementId,
121-
});
132+
}>("DOM.resolveNode", args);
133+
134+
if (!object || !object.objectId) {
135+
this.logger({
136+
category: "observation",
137+
message: `Invalid object ID returned for element: ${elementId}`,
138+
level: 1,
139+
});
140+
return null;
141+
}
142+
122143
const xpath = await getXPathByResolvedObjectId(
123144
await this.stagehandPage.getCDPClient(),
124145
object.objectId,
125146
);
147+
148+
if (!xpath || xpath === "") {
149+
this.logger({
150+
category: "observation",
151+
message: `Empty xpath returned for element: ${elementId}`,
152+
level: 1,
153+
});
154+
return null;
155+
}
156+
126157
return {
127158
...rest,
128159
selector: `xpath=${xpath}`,

0 commit comments

Comments
 (0)