@browserbasehq/stagehand 1.4.0 → 1.5.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.
@@ -0,0 +1,246 @@
1
+ import { isTextNode } from "./process";
2
+ import { isElementNode } from "./process";
3
+
4
+ function getParentElement(node: ChildNode): Element | null {
5
+ return isElementNode(node)
6
+ ? node.parentElement
7
+ : (node.parentNode as Element);
8
+ }
9
+
10
+ /**
11
+ * Generates all possible combinations of a given array of attributes.
12
+ * @param attributes Array of attributes.
13
+ * @param size The size of each combination.
14
+ * @returns An array of attribute combinations.
15
+ */
16
+ function getCombinations(
17
+ attributes: { attr: string; value: string }[],
18
+ size: number,
19
+ ): { attr: string; value: string }[][] {
20
+ const results: { attr: string; value: string }[][] = [];
21
+
22
+ function helper(start: number, combo: { attr: string; value: string }[]) {
23
+ if (combo.length === size) {
24
+ results.push([...combo]);
25
+ return;
26
+ }
27
+ for (let i = start; i < attributes.length; i++) {
28
+ combo.push(attributes[i]);
29
+ helper(i + 1, combo);
30
+ combo.pop();
31
+ }
32
+ }
33
+
34
+ helper(0, []);
35
+ return results;
36
+ }
37
+
38
+ /**
39
+ * Checks if the generated XPath uniquely identifies the target element.
40
+ * @param xpath The XPath string to test.
41
+ * @param target The target DOM element.
42
+ * @returns True if unique, else false.
43
+ */
44
+ function isXPathFirstResultElement(xpath: string, target: Element): boolean {
45
+ try {
46
+ const result = document.evaluate(
47
+ xpath,
48
+ document.documentElement,
49
+ null,
50
+ XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
51
+ null,
52
+ );
53
+ return result.snapshotItem(0) === target;
54
+ } catch (error) {
55
+ // If there's an error evaluating the XPath, consider it not unique
56
+ console.warn(`Invalid XPath expression: ${xpath}`, error);
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Escapes a string for use in an XPath expression.
63
+ * Handles special characters, including single and double quotes.
64
+ * @param value - The string to escape.
65
+ * @returns The escaped string safe for XPath.
66
+ */
67
+ export function escapeXPathString(value: string): string {
68
+ if (value.includes("'")) {
69
+ if (value.includes('"')) {
70
+ // If the value contains both single and double quotes, split into parts
71
+ return (
72
+ "concat(" +
73
+ value
74
+ .split(/('+)/)
75
+ .map((part) => {
76
+ if (part === "'") {
77
+ return `"'"`;
78
+ } else if (part.startsWith("'") && part.endsWith("'")) {
79
+ return `"${part}"`;
80
+ } else {
81
+ return `'${part}'`;
82
+ }
83
+ })
84
+ .join(",") +
85
+ ")"
86
+ );
87
+ } else {
88
+ // Contains single quotes but not double quotes; use double quotes
89
+ return `"${value}"`;
90
+ }
91
+ } else {
92
+ // Does not contain single quotes; use single quotes
93
+ return `'${value}'`;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Generates both a complicated XPath and a standard XPath for a given DOM element.
99
+ * @param element - The target DOM element.
100
+ * @param documentOverride - Optional document override.
101
+ * @returns An object containing both XPaths.
102
+ */
103
+ export async function generateXPathsForElement(
104
+ element: ChildNode,
105
+ ): Promise<string[]> {
106
+ // Generate the standard XPath
107
+ if (!element) return [];
108
+ const [complexXPath, standardXPath, idBasedXPath] = await Promise.all([
109
+ generateComplexXPath(element),
110
+ generateStandardXPath(element),
111
+ generatedIdBasedXPath(element),
112
+ ]);
113
+
114
+ // This should return in order from most accurate on current page to most cachable.
115
+ // Do not change the order if you are not sure what you are doing.
116
+ // Contact Navid if you need help understanding it.
117
+ return [standardXPath, ...(idBasedXPath ? [idBasedXPath] : []), complexXPath];
118
+ }
119
+
120
+ async function generateComplexXPath(element: ChildNode): Promise<string> {
121
+ // Generate the complicated XPath
122
+ const parts: string[] = [];
123
+ let currentElement: ChildNode | null = element;
124
+
125
+ while (
126
+ currentElement &&
127
+ (isTextNode(currentElement) || isElementNode(currentElement))
128
+ ) {
129
+ if (isElementNode(currentElement)) {
130
+ const el = currentElement as Element;
131
+ let selector = el.tagName.toLowerCase();
132
+
133
+ // List of attributes to consider for uniqueness
134
+ const attributePriority = [
135
+ "data-qa",
136
+ "data-component",
137
+ "data-role",
138
+ "role",
139
+ "aria-role",
140
+ "type",
141
+ "name",
142
+ "aria-label",
143
+ "placeholder",
144
+ "title",
145
+ "alt",
146
+ ];
147
+
148
+ // Collect attributes present on the element
149
+ const attributes = attributePriority
150
+ .map((attr) => {
151
+ let value = el.getAttribute(attr);
152
+ if (attr === "href-full" && value) {
153
+ value = el.getAttribute("href");
154
+ }
155
+ return value
156
+ ? { attr: attr === "href-full" ? "href" : attr, value }
157
+ : null;
158
+ })
159
+ .filter((attr) => attr !== null) as { attr: string; value: string }[];
160
+
161
+ // Attempt to find a combination of attributes that uniquely identifies the element
162
+ let uniqueSelector = "";
163
+ for (let i = 1; i <= attributes.length; i++) {
164
+ const combinations = getCombinations(attributes, i);
165
+ for (const combo of combinations) {
166
+ const conditions = combo
167
+ .map((a) => `@${a.attr}=${escapeXPathString(a.value)}`)
168
+ .join(" and ");
169
+ const xpath = `//${selector}[${conditions}]`;
170
+ if (isXPathFirstResultElement(xpath, el)) {
171
+ uniqueSelector = xpath;
172
+ break;
173
+ }
174
+ }
175
+ if (uniqueSelector) break;
176
+ }
177
+
178
+ if (uniqueSelector) {
179
+ parts.unshift(uniqueSelector.replace("//", ""));
180
+ break;
181
+ } else {
182
+ // Fallback to positional selector
183
+ const parent = getParentElement(el);
184
+ if (parent) {
185
+ const siblings = Array.from(parent.children).filter(
186
+ (sibling) => sibling.tagName === el.tagName,
187
+ );
188
+ const index = siblings.indexOf(el as HTMLElement) + 1;
189
+ selector += siblings.length > 1 ? `[${index}]` : "";
190
+ }
191
+ parts.unshift(selector);
192
+ }
193
+ }
194
+
195
+ currentElement = getParentElement(currentElement);
196
+ }
197
+
198
+ const xpath = "//" + parts.join("/");
199
+ return xpath;
200
+ }
201
+
202
+ /**
203
+ * Generates a standard XPath for a given DOM element.
204
+ * @param element - The target DOM element.
205
+ * @returns A standard XPath string.
206
+ */
207
+ async function generateStandardXPath(element: ChildNode): Promise<string> {
208
+ const parts: string[] = [];
209
+ while (element && (isTextNode(element) || isElementNode(element))) {
210
+ let index = 0;
211
+ let hasSameTypeSiblings = false;
212
+ const siblings = element.parentElement
213
+ ? Array.from(element.parentElement.childNodes)
214
+ : [];
215
+ for (let i = 0; i < siblings.length; i++) {
216
+ const sibling = siblings[i];
217
+ if (
218
+ sibling.nodeType === element.nodeType &&
219
+ sibling.nodeName === element.nodeName
220
+ ) {
221
+ index = index + 1;
222
+ hasSameTypeSiblings = true;
223
+ if (sibling.isSameNode(element)) {
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ // text "nodes" are selected differently than elements with xPaths
229
+ if (element.nodeName !== "#text") {
230
+ const tagName = element.nodeName.toLowerCase();
231
+ const pathIndex = hasSameTypeSiblings ? `[${index}]` : "";
232
+ parts.unshift(`${tagName}${pathIndex}`);
233
+ }
234
+ element = element.parentElement as HTMLElement;
235
+ }
236
+ return parts.length ? `/${parts.join("/")}` : "";
237
+ }
238
+
239
+ async function generatedIdBasedXPath(
240
+ element: ChildNode,
241
+ ): Promise<string | null> {
242
+ if (isElementNode(element) && element.id) {
243
+ return `//*[@id='${element.id}']`;
244
+ }
245
+ return null;
246
+ }