Skip to content
5 changes: 5 additions & 0 deletions .changeset/tidy-ghosts-wear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

fix issue where locator.count() was not working with xpaths that have attribute predicates
2 changes: 1 addition & 1 deletion packages/core/lib/v3/dom/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface StagehandV3Backdoor {
open: number;
closed: number;
};
/** Simple composed-tree resolver (no predicates; does not cross iframes) */
/** Composed-tree XPath resolver (does not cross iframes) */
resolveSimpleXPath(xp: string): Element | null;
}

Expand Down
52 changes: 7 additions & 45 deletions packages/core/lib/v3/dom/locatorScripts/counts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { elementMatchesStep, parseXPathSteps } from "./xpathParser";

export interface TextMatchSample {
tag: string;
id: string;
Expand Down Expand Up @@ -302,46 +304,7 @@ export function countXPathMatchesMainWorld(rawXp: string): number {
const xp = String(rawXp ?? "").trim();
if (!xp) return 0;

const parseSteps = (input: string) => {
const path = String(input || "")
.trim()
.replace(/^xpath=/i, "");
if (!path)
return [] as Array<{
axis: "child" | "desc";
tag: string;
index: number | null;
}>;

const steps: Array<{
axis: "child" | "desc";
tag: string;
index: number | null;
}> = [];
let i = 0;
while (i < path.length) {
let axis: "child" | "desc" = "child";
if (path.startsWith("//", i)) {
axis = "desc";
i += 2;
} else if (path[i] === "/") {
axis = "child";
i += 1;
}
const start = i;
while (i < path.length && path[i] !== "/") i += 1;
const rawStep = path.slice(start, i).trim();
if (!rawStep) continue;
const match = rawStep.match(/^(.*?)(\[(\d+)\])?$/u);
const base = (match?.[1] ?? rawStep).trim();
const index = match?.[3] ? Math.max(1, Number(match[3])) : null;
const tag = base === "" ? "*" : base.toLowerCase();
steps.push({ axis, tag, index });
}
return steps;
};

const steps = parseSteps(xp);
const steps = parseXPathSteps(xp);
if (!steps.length) return 0;

const backdoor = window.__stagehandV3__;
Expand Down Expand Up @@ -415,11 +378,10 @@ export function countXPathMatchesMainWorld(rawXp: string): number {
: composedDescendants(root);
if (!pool.length) continue;

const matches = pool.filter((candidate) => {
if (!(candidate instanceof Element)) return false;
if (step.tag === "*") return true;
return candidate.localName === step.tag;
});
const matches = pool.filter(
(candidate) =>
candidate instanceof Element && elementMatchesStep(candidate, step),
);

if (step.index != null) {
const idx = step.index - 1;
Expand Down
50 changes: 7 additions & 43 deletions packages/core/lib/v3/dom/locatorScripts/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { elementMatchesStep, parseXPathSteps } from "./xpathParser";

const parseTargetIndex = (value: unknown): number => {
const num = Number(value ?? 0);
if (!Number.isFinite(num) || num < 0) return 0;
Expand Down Expand Up @@ -334,44 +336,7 @@ export function resolveXPathMainWorld(
}
}

const parseSteps = (input: string) => {
const s = String(input || "").trim();
if (!s)
return [] as Array<{
axis: "child" | "desc";
tag: string;
index: number | null;
}>;
const path = s.replace(/^xpath=/i, "");
const steps: Array<{
axis: "child" | "desc";
tag: string;
index: number | null;
}> = [];
let i = 0;
while (i < path.length) {
let axis: "child" | "desc" = "child";
if (path.startsWith("//", i)) {
axis = "desc";
i += 2;
} else if (path[i] === "/") {
axis = "child";
i += 1;
}
const start = i;
while (i < path.length && path[i] !== "/") i += 1;
const rawStep = path.slice(start, i).trim();
if (!rawStep) continue;
const match = rawStep.match(/^(.*?)(\[(\d+)\])?$/u);
const base = (match?.[1] ?? rawStep).trim();
const index = match?.[3] ? Math.max(1, Number(match[3])) : null;
const tag = base === "" ? "*" : base.toLowerCase();
steps.push({ axis, tag, index });
}
return steps;
};

const steps = parseSteps(xp);
const steps = parseXPathSteps(xp);
if (!steps.length) return null;

const getClosedRoot: (host: Element) => ShadowRoot | null =
Expand Down Expand Up @@ -444,11 +409,10 @@ export function resolveXPathMainWorld(
: composedDescendants(root);
if (!pool.length) continue;

const matches = pool.filter((candidate) => {
if (!(candidate instanceof Element)) return false;
if (step.tag === "*") return true;
return candidate.localName === step.tag;
});
const matches = pool.filter(
(candidate) =>
candidate instanceof Element && elementMatchesStep(candidate, step),
);

if (step.index != null) {
const idx = step.index - 1;
Expand Down
81 changes: 6 additions & 75 deletions packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* and resilient to exceptions.
*/

import { elementMatchesStep, parseXPathSteps } from "./xpathParser";

type WaitForSelectorState = "attached" | "detached" | "visible" | "hidden";

/**
Expand Down Expand Up @@ -112,67 +114,6 @@ const deepQuerySelector = (
return null;
};

/**
* Parse XPath into steps for composed tree traversal.
*/
type XPathStep = {
axis: "child" | "desc";
tag: string;
index: number | null;
attrName: string | null;
attrValue: string | null;
};

const parseXPathSteps = (xpath: string): XPathStep[] => {
const path = xpath.replace(/^xpath=/i, "");
const steps: XPathStep[] = [];
let i = 0;

while (i < path.length) {
let axis: "child" | "desc" = "child";
if (path.startsWith("//", i)) {
axis = "desc";
i += 2;
} else if (path[i] === "/") {
axis = "child";
i += 1;
}

const start = i;
// Handle brackets to avoid splitting on `/` inside predicates
let bracketDepth = 0;
while (i < path.length) {
if (path[i] === "[") bracketDepth++;
else if (path[i] === "]") bracketDepth--;
else if (path[i] === "/" && bracketDepth === 0) break;
i += 1;
}
const rawStep = path.slice(start, i).trim();
if (!rawStep) continue;

// Parse step: tagName[@attr='value'][index]
// Match tag name (everything before first [)
const tagMatch = rawStep.match(/^([^[]+)/);
const tagRaw = (tagMatch?.[1] ?? "*").trim();
const tag = tagRaw === "" ? "*" : tagRaw.toLowerCase();

// Match index predicate [N]
const indexMatch = rawStep.match(/\[(\d+)\]/);
const index = indexMatch ? Math.max(1, Number(indexMatch[1])) : null;

// Match attribute predicate [@attr='value'] or [@attr="value"]
const attrMatch = rawStep.match(
/\[@([a-zA-Z_][\w-]*)\s*=\s*['"]([^'"]*)['"]\]/,
);
const attrName = attrMatch ? attrMatch[1] : null;
const attrValue = attrMatch ? attrMatch[2] : null;

steps.push({ axis, tag, index, attrName, attrValue });
}

return steps;
};

/**
* Get composed children of a node (including shadow root children).
*/
Expand Down Expand Up @@ -287,20 +228,10 @@ const deepXPathQuery = (
: composedDescendants(root);
if (!pool.length) continue;

// Filter by tag name
let matches = pool.filter((candidate) => {
if (!(candidate instanceof Element)) return false;
if (step.tag === "*") return true;
return candidate.localName === step.tag;
});

// Filter by attribute predicate if present
if (step.attrName != null && step.attrValue != null) {
matches = matches.filter((candidate) => {
const attrVal = candidate.getAttribute(step.attrName!);
return attrVal === step.attrValue;
});
}
const matches = pool.filter(
(candidate) =>
candidate instanceof Element && elementMatchesStep(candidate, step),
);

if (step.index != null) {
const idx = step.index - 1;
Expand Down
Loading
Loading