From c7b68d0942871b5a5cdd9678ab68814d3639fdf0 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 11 Feb 2026 00:13:44 -0800 Subject: [PATCH 1/9] fix: use native document.evaluate() for XPath count to support attribute predicates countXPathMatchesMainWorld() used a custom XPath parser that only handled simple tag/index steps, failing on attribute predicates like `//img[@alt='Stagehand']`. resolveXPathMainWorld() already had a native document.evaluate() fallback that handled these correctly, which is why isVisible() worked while count() returned 0. Add the same native XPath fallback to countXPathMatchesMainWorld(), falling through to the composed DOM traversal only when native evaluation fails (e.g. shadow DOM). Fixes #1668 Co-Authored-By: Claude Opus 4.6 --- packages/core/lib/v3/dom/locatorScripts/counts.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/core/lib/v3/dom/locatorScripts/counts.ts b/packages/core/lib/v3/dom/locatorScripts/counts.ts index f70c754a1..2fd3a06e4 100644 --- a/packages/core/lib/v3/dom/locatorScripts/counts.ts +++ b/packages/core/lib/v3/dom/locatorScripts/counts.ts @@ -302,6 +302,19 @@ export function countXPathMatchesMainWorld(rawXp: string): number { const xp = String(rawXp ?? "").trim(); if (!xp) return 0; + try { + const result = document.evaluate( + xp, + document, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + return result.snapshotLength; + } catch { + // native XPath failed (e.g. shadow DOM); fall through to composed traversal + } + const parseSteps = (input: string) => { const path = String(input || "") .trim() From 5dc1e0402be7c18e7bf9454c3500d5b1e849e4a1 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 11 Feb 2026 00:33:39 -0800 Subject: [PATCH 2/9] fix: move native XPath to fallback to preserve shadow DOM counting Native document.evaluate() silently ignores shadow DOM elements rather than throwing, so using it as the primary path undercounts on pages with shadow roots. Move it to a fallback that only triggers when the custom composed traversal returns 0 matches at a step (indicating the custom parser likely can't handle the XPath syntax, e.g. attribute predicates). Co-Authored-By: Claude Opus 4.6 --- .../core/lib/v3/dom/locatorScripts/counts.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/core/lib/v3/dom/locatorScripts/counts.ts b/packages/core/lib/v3/dom/locatorScripts/counts.ts index 2fd3a06e4..14eea44a6 100644 --- a/packages/core/lib/v3/dom/locatorScripts/counts.ts +++ b/packages/core/lib/v3/dom/locatorScripts/counts.ts @@ -302,19 +302,6 @@ export function countXPathMatchesMainWorld(rawXp: string): number { const xp = String(rawXp ?? "").trim(); if (!xp) return 0; - try { - const result = document.evaluate( - xp, - document, - null, - XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, - null, - ); - return result.snapshotLength; - } catch { - // native XPath failed (e.g. shadow DOM); fall through to composed traversal - } - const parseSteps = (input: string) => { const path = String(input || "") .trim() @@ -451,7 +438,23 @@ export function countXPathMatchesMainWorld(rawXp: string): number { } } - if (!next.length) return 0; + if (!next.length) { + // The custom parser may not support this XPath syntax (e.g. attribute + // predicates like [@alt='Stagehand']). Fall back to native XPath which + // handles the full spec, though it cannot see into shadow roots. + try { + const result = document.evaluate( + xp, + document, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null, + ); + return result.snapshotLength; + } catch { + return 0; + } + } current = next; } From dd16dba4e71017ae9eb085a92b10f8e908bf88b7 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 11 Feb 2026 00:40:42 -0800 Subject: [PATCH 3/9] refactor: extract XPath parser, add attribute predicate support and tests Extract the duplicated parseSteps function from counts.ts and selectors.ts into a shared xpathParser module with proper attribute predicate parsing. The custom parser now handles [@attr='value'] predicates natively in the composed DOM traversal, so attribute predicate XPaths work correctly even on pages with shadow DOM. This removes the need for the native document.evaluate() fallback that was added in the previous commit. Adds 23 vitest unit tests covering: - Basic tag parsing and case normalization - Child vs descendant axes - Positional indices - Attribute predicates (single/double quotes, multiple, combined with index) - Edge cases (empty input, xpath= prefix, whitespace) - Element matching logic Co-Authored-By: Claude Opus 4.6 --- .../core/lib/v3/dom/locatorScripts/counts.ts | 70 +----- .../lib/v3/dom/locatorScripts/selectors.ts | 50 +--- .../lib/v3/dom/locatorScripts/xpathParser.ts | 108 ++++++++ packages/core/tests/xpath-parser.test.ts | 234 ++++++++++++++++++ 4 files changed, 357 insertions(+), 105 deletions(-) create mode 100644 packages/core/lib/v3/dom/locatorScripts/xpathParser.ts create mode 100644 packages/core/tests/xpath-parser.test.ts diff --git a/packages/core/lib/v3/dom/locatorScripts/counts.ts b/packages/core/lib/v3/dom/locatorScripts/counts.ts index 14eea44a6..fe4d90015 100644 --- a/packages/core/lib/v3/dom/locatorScripts/counts.ts +++ b/packages/core/lib/v3/dom/locatorScripts/counts.ts @@ -1,3 +1,5 @@ +import { elementMatchesStep, parseXPathSteps } from "./xpathParser"; + export interface TextMatchSample { tag: string; id: string; @@ -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__; @@ -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; @@ -438,23 +400,7 @@ export function countXPathMatchesMainWorld(rawXp: string): number { } } - if (!next.length) { - // The custom parser may not support this XPath syntax (e.g. attribute - // predicates like [@alt='Stagehand']). Fall back to native XPath which - // handles the full spec, though it cannot see into shadow roots. - try { - const result = document.evaluate( - xp, - document, - null, - XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, - null, - ); - return result.snapshotLength; - } catch { - return 0; - } - } + if (!next.length) return 0; current = next; } diff --git a/packages/core/lib/v3/dom/locatorScripts/selectors.ts b/packages/core/lib/v3/dom/locatorScripts/selectors.ts index 39141c8c0..5569ecc12 100644 --- a/packages/core/lib/v3/dom/locatorScripts/selectors.ts +++ b/packages/core/lib/v3/dom/locatorScripts/selectors.ts @@ -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; @@ -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 = @@ -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; diff --git a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts new file mode 100644 index 000000000..7083f1fd9 --- /dev/null +++ b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts @@ -0,0 +1,108 @@ +export interface XPathAttrPredicate { + name: string; + value: string; +} + +export interface XPathStep { + axis: "child" | "desc"; + tag: string; + index: number | null; + attrs: XPathAttrPredicate[]; +} + +/** + * Parse an XPath expression into a list of traversal steps. + * + * Supports: + * - Child (`/`) and descendant (`//`) axes + * - Tag names and wildcard (`*`) + * - Positional indices (`[n]`) + * - Attribute predicates (`[@attr='value']`) + * - Optional `xpath=` prefix + */ +export function parseXPathSteps(input: string): XPathStep[] { + const path = String(input || "") + .trim() + .replace(/^xpath=/i, ""); + if (!path) return []; + + 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; + while (i < path.length && path[i] !== "/") i += 1; + const rawStep = path.slice(start, i).trim(); + if (!rawStep) continue; + + const { tag, index, attrs } = parseStep(rawStep); + steps.push({ axis, tag, index, attrs }); + } + + return steps; +} + +function parseStep(raw: string): { + tag: string; + index: number | null; + attrs: XPathAttrPredicate[]; +} { + const bracketPos = raw.indexOf("["); + if (bracketPos === -1) { + const tag = raw === "" ? "*" : raw.toLowerCase(); + return { tag, index: null, attrs: [] }; + } + + const tagPart = raw.slice(0, bracketPos).trim(); + const tag = tagPart === "" ? "*" : tagPart.toLowerCase(); + const predicateStr = raw.slice(bracketPos); + + let index: number | null = null; + const attrs: XPathAttrPredicate[] = []; + + const predicateRe = /\[([^\]]*)\]/g; + let m: RegExpExecArray | null; + while ((m = predicateRe.exec(predicateStr)) !== null) { + const inner = m[1].trim(); + + // Positional index: [n] + if (/^\d+$/.test(inner)) { + index = Math.max(1, Number(inner)); + continue; + } + + // Attribute predicate: [@attr='value'] or [@attr="value"] + const attrMatch = inner.match( + /^@([a-zA-Z_][\w.-]*)\s*=\s*(?:'([^']*)'|"([^"]*)")$/, + ); + if (attrMatch) { + attrs.push({ + name: attrMatch[1], + value: attrMatch[2] ?? attrMatch[3], + }); + } + } + + return { tag, index, attrs }; +} + +/** + * Test whether an element matches the tag and attribute predicates of a step. + * This is separated from the DOM traversal to keep the parser testable. + */ +export function elementMatchesStep(element: Element, step: XPathStep): boolean { + if (step.tag !== "*" && element.localName !== step.tag) return false; + for (const attr of step.attrs) { + if (element.getAttribute(attr.name) !== attr.value) return false; + } + return true; +} diff --git a/packages/core/tests/xpath-parser.test.ts b/packages/core/tests/xpath-parser.test.ts new file mode 100644 index 000000000..76ffa3741 --- /dev/null +++ b/packages/core/tests/xpath-parser.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import { + parseXPathSteps, + elementMatchesStep, + type XPathStep, +} from "../lib/v3/dom/locatorScripts/xpathParser"; + +describe("parseXPathSteps", () => { + describe("basic tag parsing", () => { + it("parses a simple absolute path", () => { + expect(parseXPathSteps("/html/body/div")).toEqual([ + { axis: "child", tag: "html", index: null, attrs: [] }, + { axis: "child", tag: "body", index: null, attrs: [] }, + { axis: "child", tag: "div", index: null, attrs: [] }, + ]); + }); + + it("lowercases tag names", () => { + const steps = parseXPathSteps("/HTML/BODY"); + expect(steps[0].tag).toBe("html"); + expect(steps[1].tag).toBe("body"); + }); + + it("treats wildcard correctly", () => { + const steps = parseXPathSteps("//*"); + expect(steps).toEqual([ + { axis: "desc", tag: "*", index: null, attrs: [] }, + ]); + }); + }); + + describe("axes", () => { + it("distinguishes child (/) from descendant (//)", () => { + const steps = parseXPathSteps("/html//div/span"); + expect(steps).toEqual([ + { axis: "child", tag: "html", index: null, attrs: [] }, + { axis: "desc", tag: "div", index: null, attrs: [] }, + { axis: "child", tag: "span", index: null, attrs: [] }, + ]); + }); + + it("handles leading //", () => { + const steps = parseXPathSteps("//div"); + expect(steps[0].axis).toBe("desc"); + }); + }); + + describe("positional indices", () => { + it("parses positional index", () => { + const steps = parseXPathSteps("/div[1]/span[3]"); + expect(steps[0]).toMatchObject({ tag: "div", index: 1 }); + expect(steps[1]).toMatchObject({ tag: "span", index: 3 }); + }); + + it("clamps index to minimum 1", () => { + const steps = parseXPathSteps("/div[0]"); + expect(steps[0].index).toBe(1); + }); + }); + + describe("attribute predicates", () => { + it("parses single attribute predicate with single quotes", () => { + const steps = parseXPathSteps("//img[@alt='Stagehand']"); + expect(steps).toEqual([ + { + axis: "desc", + tag: "img", + index: null, + attrs: [{ name: "alt", value: "Stagehand" }], + }, + ]); + }); + + it("parses single attribute predicate with double quotes", () => { + const steps = parseXPathSteps('//img[@alt="Stagehand"]'); + expect(steps[0].attrs).toEqual([{ name: "alt", value: "Stagehand" }]); + }); + + it("parses multiple attribute predicates", () => { + const steps = parseXPathSteps("//div[@class='foo'][@id='bar']"); + expect(steps[0].attrs).toEqual([ + { name: "class", value: "foo" }, + { name: "id", value: "bar" }, + ]); + }); + + it("parses attribute predicate combined with positional index", () => { + const steps = parseXPathSteps("//div[@class='item'][2]"); + expect(steps[0]).toMatchObject({ + tag: "div", + index: 2, + attrs: [{ name: "class", value: "item" }], + }); + }); + + it("parses attribute with hyphenated name", () => { + const steps = parseXPathSteps("//div[@data-testid='submit']"); + expect(steps[0].attrs).toEqual([ + { name: "data-testid", value: "submit" }, + ]); + }); + + it("parses attribute with empty value", () => { + const steps = parseXPathSteps("//input[@value='']"); + expect(steps[0].attrs).toEqual([{ name: "value", value: "" }]); + }); + }); + + describe("multi-step with predicates", () => { + it("parses complex path with mixed predicates", () => { + const steps = parseXPathSteps( + "/html/body//div[@class='container']/ul/li[3]", + ); + expect(steps).toEqual([ + { axis: "child", tag: "html", index: null, attrs: [] }, + { axis: "child", tag: "body", index: null, attrs: [] }, + { + axis: "desc", + tag: "div", + index: null, + attrs: [{ name: "class", value: "container" }], + }, + { axis: "child", tag: "ul", index: null, attrs: [] }, + { axis: "child", tag: "li", index: 3, attrs: [] }, + ]); + }); + }); + + describe("edge cases", () => { + it("returns empty array for empty string", () => { + expect(parseXPathSteps("")).toEqual([]); + }); + + it("strips xpath= prefix", () => { + const steps = parseXPathSteps("xpath=//div"); + expect(steps).toEqual([ + { axis: "desc", tag: "div", index: null, attrs: [] }, + ]); + }); + + it("strips XPATH= prefix (case-insensitive)", () => { + const steps = parseXPathSteps("XPATH=//div"); + expect(steps).toEqual([ + { axis: "desc", tag: "div", index: null, attrs: [] }, + ]); + }); + + it("handles whitespace", () => { + const steps = parseXPathSteps(" //div "); + expect(steps.length).toBe(1); + expect(steps[0].tag).toBe("div"); + }); + }); +}); + +describe("elementMatchesStep", () => { + const makeElement = ( + localName: string, + attributes: Record = {}, + ): Element => { + return { + localName, + getAttribute: (name: string) => attributes[name] ?? null, + } as unknown as Element; + }; + + it("matches by tag name", () => { + const step: XPathStep = { + axis: "desc", + tag: "div", + index: null, + attrs: [], + }; + expect(elementMatchesStep(makeElement("div"), step)).toBe(true); + expect(elementMatchesStep(makeElement("span"), step)).toBe(false); + }); + + it("wildcard matches any element", () => { + const step: XPathStep = { + axis: "desc", + tag: "*", + index: null, + attrs: [], + }; + expect(elementMatchesStep(makeElement("div"), step)).toBe(true); + expect(elementMatchesStep(makeElement("span"), step)).toBe(true); + }); + + it("matches attribute predicates", () => { + const step: XPathStep = { + axis: "desc", + tag: "img", + index: null, + attrs: [{ name: "alt", value: "Stagehand" }], + }; + expect( + elementMatchesStep(makeElement("img", { alt: "Stagehand" }), step), + ).toBe(true); + expect(elementMatchesStep(makeElement("img", { alt: "Other" }), step)).toBe( + false, + ); + expect(elementMatchesStep(makeElement("img"), step)).toBe(false); + }); + + it("requires all attribute predicates to match", () => { + const step: XPathStep = { + axis: "desc", + tag: "div", + index: null, + attrs: [ + { name: "class", value: "foo" }, + { name: "id", value: "bar" }, + ], + }; + expect( + elementMatchesStep(makeElement("div", { class: "foo", id: "bar" }), step), + ).toBe(true); + expect(elementMatchesStep(makeElement("div", { class: "foo" }), step)).toBe( + false, + ); + }); + + it("checks tag name before attributes", () => { + const step: XPathStep = { + axis: "desc", + tag: "img", + index: null, + attrs: [{ name: "alt", value: "Stagehand" }], + }; + expect( + elementMatchesStep(makeElement("div", { alt: "Stagehand" }), step), + ).toBe(false); + }); +}); From 3957acbb0f8f8c142bc422b5f12779da0e118e05 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 11 Feb 2026 00:43:22 -0800 Subject: [PATCH 4/9] docs: document supported and unsupported XPath subset in parser Co-Authored-By: Claude Opus 4.6 --- .../lib/v3/dom/locatorScripts/xpathParser.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts index 7083f1fd9..ef4a0a5d7 100644 --- a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts +++ b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts @@ -13,12 +13,31 @@ export interface XPathStep { /** * Parse an XPath expression into a list of traversal steps. * - * Supports: + * This is a subset parser designed for composed DOM traversal (including + * shadow roots). It intentionally does not implement the full XPath spec. + * + * Supported: * - Child (`/`) and descendant (`//`) axes * - Tag names and wildcard (`*`) * - Positional indices (`[n]`) - * - Attribute predicates (`[@attr='value']`) + * - Attribute equality predicates (`[@attr='value']`, `[@attr="value"]`) + * - Multiple predicates per step (`[@class='foo'][@id='bar']`) * - Optional `xpath=` prefix + * + * Not supported: + * - Attribute existence without value (`[@attr]`) + * - String functions (`contains()`, `starts-with()`, `normalize-space()`) + * - Text matching (`[text()='value']`) + * - Boolean operators within predicates (`[@a='x' and @b='y']`) + * - Negation (`[not(...)]`) + * - Position functions (`[position() > n]`, `[last()]`) + * - Axes beyond child/descendant (`ancestor::`, `parent::`, `self::`, + * `preceding-sibling::`, `following-sibling::`) + * - Union operator (`|`) + * - Grouped expressions (`(//div)[n]`) + * + * Unsupported predicates are silently ignored — the step still matches + * by tag name, but the unrecognized predicate has no filtering effect. */ export function parseXPathSteps(input: string): XPathStep[] { const path = String(input || "") From 6f7661832b5aab0dad9e6407f3300e9e08abe772 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 11 Feb 2026 00:49:21 -0800 Subject: [PATCH 5/9] refactor: deduplicate XPath parser in piercer.runtime.ts Replace the third inline copy of the XPath step parser in the shadow piercer with imports from the shared xpathParser module. The piercer's resolveSimpleXPath now gets attribute predicate support for free. Co-Authored-By: Claude Opus 4.6 --- packages/core/lib/v3/dom/global.d.ts | 2 +- packages/core/lib/v3/dom/piercer.runtime.ts | 65 +++++---------------- 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/packages/core/lib/v3/dom/global.d.ts b/packages/core/lib/v3/dom/global.d.ts index aec096b1a..cbdcadad6 100644 --- a/packages/core/lib/v3/dom/global.d.ts +++ b/packages/core/lib/v3/dom/global.d.ts @@ -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; } diff --git a/packages/core/lib/v3/dom/piercer.runtime.ts b/packages/core/lib/v3/dom/piercer.runtime.ts index e84d09cb2..e6519b959 100644 --- a/packages/core/lib/v3/dom/piercer.runtime.ts +++ b/packages/core/lib/v3/dom/piercer.runtime.ts @@ -1,3 +1,8 @@ +import { + elementMatchesStep, + parseXPathSteps, +} from "./locatorScripts/xpathParser"; + export interface V3ShadowPatchOptions { debug?: boolean; tagExisting?: boolean; @@ -14,7 +19,7 @@ export interface StagehandV3Backdoor { open: number; closed: number; }; - /** Simple composed-tree resolver (axis '/', '//' and trailing [n] only; no iframe hops) */ + /** Composed-tree XPath resolver (does not cross iframes) */ resolveSimpleXPath(xp: string): Element | null; } @@ -76,52 +81,16 @@ export function installV3ShadowPiercer(opts: V3ShadowPatchOptions = {}): void { return out; }; - // Simple composed-tree resolver with axis '/', '//' and trailing [n] + // Simple composed-tree resolver (no iframe hops) const resolveSimpleXPath = (xp: string): Element | null => { - const s = String(xp || "").trim(); - if (!s) return null; - const path = s.replace(/^xpath=/i, ""); - - type Axis = "child" | "desc"; - type Step = { - axis: Axis; - raw: string; - tag: string; - index: number | null; - }; - - const steps: Step[] = []; - { - let i = 0; - while (i < path.length) { - let axis: Axis = "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++; - const raw = path.slice(start, i).trim(); - if (!raw) continue; - - const m = raw.match(/^(.*?)(\[(\d+)\])?$/u); - const base = (m?.[1] ?? raw).trim(); - const index = m?.[3] ? Math.max(1, Number(m[3])) : null; - const tag = base === "" ? "*" : base.toLowerCase(); - steps.push({ axis, raw, tag, index }); - } - } + const steps = parseXPathSteps(xp); + if (!steps.length) return null; if (state.debug) { console.info("[v3-piercer][resolve] start", { url: location.href, steps: steps.map((s) => ({ axis: s.axis, - raw: s.raw, tag: s.tag, index: s.index, })), @@ -131,7 +100,6 @@ export function installV3ShadowPiercer(opts: V3ShadowPatchOptions = {}): void { let current: Node[] = [document]; for (const step of steps) { - const wantIdx = step.index; let chosen: Element | null = null; for (const root of current) { @@ -139,16 +107,13 @@ export function installV3ShadowPiercer(opts: V3ShadowPatchOptions = {}): void { step.axis === "child" ? composedChildren(root) : composedDescendants(root); - const matches: Element[] = []; - for (const el of pool) { - if (step.tag === "*" || el.localName === step.tag) matches.push(el); - } + const matches = pool.filter((el) => elementMatchesStep(el, step)); if (state.debug) { console.info("[v3-piercer][resolve] step", { axis: step.axis, tag: step.tag, - index: wantIdx, + index: step.index, poolCount: pool.length, matchesCount: matches.length, }); @@ -156,8 +121,8 @@ export function installV3ShadowPiercer(opts: V3ShadowPatchOptions = {}): void { if (!matches.length) continue; - if (wantIdx != null) { - const idx0 = wantIdx - 1; + if (step.index != null) { + const idx0 = step.index - 1; chosen = idx0 >= 0 && idx0 < matches.length ? matches[idx0] : null; } else { chosen = matches[0]; @@ -168,7 +133,9 @@ export function installV3ShadowPiercer(opts: V3ShadowPatchOptions = {}): void { if (!chosen) { if (state.debug) { - console.info("[v3-piercer][resolve] no-match", { step: step.raw }); + console.info("[v3-piercer][resolve] no-match", { + tag: step.tag, + }); } return null; } From 960fe1133eb1a9f85f050443c96295f84034fb59 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 11 Feb 2026 00:56:55 -0800 Subject: [PATCH 6/9] fix: handle forward slashes inside attribute values, dedupe waitForSelector The step splitter now tracks bracket depth so `/` inside predicates (e.g. `[@href='/api/endpoint']`) no longer incorrectly splits the step. This was a pre-existing bug in all copies of the parser; waitForSelector.ts had independently fixed it but the other copies hadn't. Also deduplicates the fourth and final copy of the XPath parser from waitForSelector.ts, replacing its local XPathStep type, parseXPathSteps, and inline matching logic with imports from the shared xpathParser module. Adds 2 unit tests for forward slashes in attribute values. Co-Authored-By: Claude Opus 4.6 --- .../v3/dom/locatorScripts/waitForSelector.ts | 81 ++----------------- .../lib/v3/dom/locatorScripts/xpathParser.ts | 8 +- packages/core/tests/xpath-parser.test.ts | 28 +++++++ 3 files changed, 41 insertions(+), 76 deletions(-) diff --git a/packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts b/packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts index 3b24b482c..a4542c1f1 100644 --- a/packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts +++ b/packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts @@ -7,6 +7,8 @@ * and resilient to exceptions. */ +import { elementMatchesStep, parseXPathSteps } from "./xpathParser"; + type WaitForSelectorState = "attached" | "detached" | "visible" | "hidden"; /** @@ -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). */ @@ -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; diff --git a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts index ef4a0a5d7..f3bbb0d82 100644 --- a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts +++ b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts @@ -59,7 +59,13 @@ export function parseXPathSteps(input: string): XPathStep[] { } const start = i; - while (i < path.length && path[i] !== "/") i += 1; + 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; diff --git a/packages/core/tests/xpath-parser.test.ts b/packages/core/tests/xpath-parser.test.ts index 76ffa3741..1cc0a3463 100644 --- a/packages/core/tests/xpath-parser.test.ts +++ b/packages/core/tests/xpath-parser.test.ts @@ -145,6 +145,34 @@ describe("parseXPathSteps", () => { ]); }); + it("handles forward slashes inside attribute values", () => { + const steps = parseXPathSteps("//a[@href='/api/endpoint']"); + expect(steps).toEqual([ + { + axis: "desc", + tag: "a", + index: null, + attrs: [{ name: "href", value: "/api/endpoint" }], + }, + ]); + }); + + it("handles URL attribute values with multiple slashes", () => { + const steps = parseXPathSteps( + "//a[@data-url='http://example.com/path/to/page']", + ); + expect(steps).toEqual([ + { + axis: "desc", + tag: "a", + index: null, + attrs: [ + { name: "data-url", value: "http://example.com/path/to/page" }, + ], + }, + ]); + }); + it("handles whitespace", () => { const steps = parseXPathSteps(" //div "); expect(steps.length).toBe(1); From 4d9d43f57bc0c866cb048e7f1a615caa5648e5b4 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 11 Feb 2026 01:20:04 -0800 Subject: [PATCH 7/9] fix: handle closing brackets inside quoted attribute values The predicate extraction regex `[^\]]*` stopped at the first `]`, breaking attribute values containing brackets (e.g. `[@title='array[0]']`). Replace with a quote-aware character scan that only treats `]` as a predicate terminator when it's outside quoted strings. Adds 2 unit tests for bracket-containing attribute values. Co-Authored-By: Claude Opus 4.6 --- .../lib/v3/dom/locatorScripts/xpathParser.ts | 38 ++++++++++++++++--- packages/core/tests/xpath-parser.test.ts | 10 +++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts index f3bbb0d82..4b914beda 100644 --- a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts +++ b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts @@ -76,6 +76,38 @@ export function parseXPathSteps(input: string): XPathStep[] { return steps; } +/** + * Extract predicate contents from a string like `[@attr='val'][2]`. + * Handles `]` inside quoted attribute values (e.g. `[@title='a[0]']`). + */ +function extractPredicates(str: string): string[] { + const results: string[] = []; + let i = 0; + while (i < str.length) { + if (str[i] !== "[") { + i++; + continue; + } + i++; // skip opening [ + const start = i; + let quote: string | null = null; + while (i < str.length) { + const ch = str[i]; + if (quote) { + if (ch === quote) quote = null; + } else if (ch === "'" || ch === '"') { + quote = ch; + } else if (ch === "]") { + break; + } + i++; + } + results.push(str.slice(start, i).trim()); + i++; // skip closing ] + } + return results; +} + function parseStep(raw: string): { tag: string; index: number | null; @@ -94,11 +126,7 @@ function parseStep(raw: string): { let index: number | null = null; const attrs: XPathAttrPredicate[] = []; - const predicateRe = /\[([^\]]*)\]/g; - let m: RegExpExecArray | null; - while ((m = predicateRe.exec(predicateStr)) !== null) { - const inner = m[1].trim(); - + for (const inner of extractPredicates(predicateStr)) { // Positional index: [n] if (/^\d+$/.test(inner)) { index = Math.max(1, Number(inner)); diff --git a/packages/core/tests/xpath-parser.test.ts b/packages/core/tests/xpath-parser.test.ts index 1cc0a3463..d609b0a81 100644 --- a/packages/core/tests/xpath-parser.test.ts +++ b/packages/core/tests/xpath-parser.test.ts @@ -104,6 +104,16 @@ describe("parseXPathSteps", () => { const steps = parseXPathSteps("//input[@value='']"); expect(steps[0].attrs).toEqual([{ name: "value", value: "" }]); }); + + it("parses attribute value containing closing bracket", () => { + const steps = parseXPathSteps("//div[@title='array[0]']"); + expect(steps[0].attrs).toEqual([{ name: "title", value: "array[0]" }]); + }); + + it("parses attribute value containing multiple brackets", () => { + const steps = parseXPathSteps("//div[@data-json='[1,2,3]']"); + expect(steps[0].attrs).toEqual([{ name: "data-json", value: "[1,2,3]" }]); + }); }); describe("multi-step with predicates", () => { From 0805ccbaaa93cd22bd84065a6bb9fd732bfbc1ee Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Thu, 12 Feb 2026 17:51:35 -0800 Subject: [PATCH 8/9] address comment --- .../lib/v3/dom/locatorScripts/xpathParser.ts | 16 +++++++++++++--- packages/core/tests/xpath-parser.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts index 4b914beda..ed7662f21 100644 --- a/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts +++ b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts @@ -60,10 +60,20 @@ export function parseXPathSteps(input: string): XPathStep[] { const start = i; let bracketDepth = 0; + let quote: string | null = null; while (i < path.length) { - if (path[i] === "[") bracketDepth++; - else if (path[i] === "]") bracketDepth--; - else if (path[i] === "/" && bracketDepth === 0) break; + const ch = path[i]; + if (quote) { + if (ch === quote) quote = null; + } else if (ch === "'" || ch === '"') { + quote = ch; + } else if (ch === "[") { + bracketDepth++; + } else if (ch === "]") { + bracketDepth--; + } else if (ch === "/" && bracketDepth === 0) { + break; + } i += 1; } const rawStep = path.slice(start, i).trim(); diff --git a/packages/core/tests/xpath-parser.test.ts b/packages/core/tests/xpath-parser.test.ts index d609b0a81..acdf5d153 100644 --- a/packages/core/tests/xpath-parser.test.ts +++ b/packages/core/tests/xpath-parser.test.ts @@ -114,6 +114,20 @@ describe("parseXPathSteps", () => { const steps = parseXPathSteps("//div[@data-json='[1,2,3]']"); expect(steps[0].attrs).toEqual([{ name: "data-json", value: "[1,2,3]" }]); }); + + it("parses attribute value containing a closing bracket", () => { + // The step splitter should ignore ] characters inside quotes. + const steps = parseXPathSteps("//div[@title='a]b']/span"); + expect(steps).toEqual([ + { + axis: "desc", + tag: "div", + index: null, + attrs: [{ name: "title", value: "a]b" }], + }, + { axis: "child", tag: "span", index: null, attrs: [] }, + ]); + }); }); describe("multi-step with predicates", () => { From 3d31617962c99b1a0e8eceb2bee99f7ed44b77ab Mon Sep 17 00:00:00 2001 From: Sean McGuire Date: Thu, 12 Feb 2026 17:53:05 -0800 Subject: [PATCH 9/9] changeset --- .changeset/tidy-ghosts-wear.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-ghosts-wear.md diff --git a/.changeset/tidy-ghosts-wear.md b/.changeset/tidy-ghosts-wear.md new file mode 100644 index 000000000..9741ad312 --- /dev/null +++ b/.changeset/tidy-ghosts-wear.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +fix issue where locator.count() was not working with xpaths that have attribute predicates