Skip to content

fix(extension): preserve native XPath predicates - #2706

Open
KXHXK wants to merge 4 commits into
browserbase:mainfrom
KXHXK:fix-native-xpath-predicates
Open

fix(extension): preserve native XPath predicates#2706
KXHXK wants to merge 4 commits into
browserbase:mainfrom
KXHXK:fix-native-xpath-predicates

Conversation

@KXHXK

@KXHXK KXHXK commented Aug 13, 2026

Copy link
Copy Markdown

Summary

  • prefer native XPath results for light-DOM matches even when the document contains a shadow root
  • make the composed-tree fallback fail closed for unsupported predicates and preserve XPath index-zero semantics
  • add parser-level tests and an extension-backed locator regression for an unrelated shadow root

Why

An unrelated shadow root currently switches every XPath locator to Stagehand's subset parser. Predicates such as position() and last() are then silently ignored, so the same locator can count or click different elements depending on unrelated page content.

This keeps composed-tree lookup for selectors that need shadow traversal while preserving native XPath semantics whenever the browser can resolve a light-DOM match.

Testing

  • `focused Vitest coverage for parser, grouped XPath routing, and error propagation (7 passed)
  • oxfmt --check on all changed files
  • oxlint on all changed TypeScript files
  • attempted packages/sdk-ts/tests/integration/locatorCount.test.ts with the new HTTP fixture; all 8 tests, including unchanged baselines, reach browser startup but are blocked locally because Chrome cannot resolve the extension source path under the workspace's non-ASCII parent directory (File path cannot be resolved). The HTTP and mixed light/shadow cases are included for CI on GitHub's ASCII workspace.

Closes #2693


Summary by cubic

Preserves native XPath predicate semantics when pages include unrelated shadow roots. Previously the composed-tree fallback ignored positional predicates and treated index 0 as the first match; now grouped XPaths like (//div)[2] and any path with positional/unsupported predicates evaluate natively, and unsupported predicates in composed traversal throw.

  • Resolver: evaluate grouped (...) paths natively; if predicates are positional or unsupported, prefer native results; otherwise merge native light‑DOM results with shadow‑root matches, dedupe, and order by composed‑tree document order; distinguish empty native results from native errors before fallback.
  • Parser/runtime: add an unsupported predicate that throws in composed traversal; keep XPath index‑zero semantics (index 0 matches nothing).
  • Understudy: preserve grouped XPath strings for native evaluation; expose evaluateXPathElement; sanitize “Unsupported XPath predicate in composed-tree traversal” at the CDP boundary; transient main‑world errors still return null/0.
  • Tests: cover parser safety, grouped routing, error propagation, native zero‑match semantics, and mixed light/shadow ordering.

Migration

  • Locators that relied on ignored predicates or index‑0 coercion may now throw or return different counts. Update affected locators/tests to handle thrown XPath errors in @browserbasehq/stagehand.

Written for commit 303ed5f. Summary will update on new commits.

Review in cubic

Signed-off-by: KXH <shepherdlaurie238@gmail.com>
@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 303ed5f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@browserbasehq/stagehand Patch
@browserbasehq/stagehand-integrations Patch
@browserbasehq/stagehand-integrations-example-eve-facade Patch
@browserbasehq/stagehand-integrations-example-mastra-facade Patch
@browserbasehq/stagehand-integrations-example-vercel-ai-facade Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files

Confidence score: 4/5

  • In packages/extension/dom/locatorScripts/xpathResolver.ts (the hasShadow native-vs-composed selection), preferring native XPath can change count() and index resolution when matching tags exist in both light DOM and composed trees, which may cause subtle locator mismatches in shadow-heavy pages—add/adjust regression tests for mixed light+shadow cases and clarify/guard the preference rule.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/extension/dom/locatorScripts/xpathResolver.ts">

<violation number="1" location="packages/extension/dom/locatorScripts/xpathResolver.ts:80">
P3: The new native-vs-composed preference in the `hasShadow` path is non-trivial — native XPath never pierces shadow roots, so preferring it changes `count()`/index results whenever a tag appears in both light and composed trees, and the index-zero and `position()` semantics differ between the native engine and the subset parser. None of this rationale is documented next to the code; only the parser's docstring mentions it. Add an inline comment explaining the ordering (shadow-hop → native light-DOM → composed fallback), why native wins, and that it intentionally does not merge shadow matches, so future edits don't silently regress it.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Test/User Code
    participant SDK as SDK-TS (locatorCount.test.ts)
    participant Ext as Stagehand Extension
    participant Parser as XPath Parser
    participant Resolver as XPath Resolver
    participant Native as Native XPath (Browser)
    participant Composed as Composed-Tree Fallback

    Note over Client,Composed: XPath Resolution with Shadow Root Present

    Client->>SDK: locator("xpath=//div[position() > 1]").count()
    SDK->>Ext: resolveXpath(xpath, targetIndex)
    Ext->>Parser: parseXPathSteps(xpath)
    Parser-->>Ext: steps with predicates (including "unsupported" type for position())
    
    Note over Ext,Resolver: Primary path: try native first
    
    Ext->>Resolver: resolveXPathAtIndex(xpath, index)
    Resolver->>Native: document.evaluate(xpath, ...)
    
    alt Native succeeds and returns >0 results
        Native-->>Resolver: XPathResult with snapshotLength > 0
        Resolver-->>Ext: Element from native result
        Ext-->>SDK: Native match (preserves predicate semantics)
        SDK-->>Client: count = 2 (position() > 1 filters correctly)
    else Native returns 0 or errors
        Native-->>Resolver: 0 results or throws
        Resolver-->>Composed: Fall back to composed-tree lookup
        Composed->>Parser: evaluatePredicate(element, predicate)
        
        alt predicate type is "unsupported"
            Parser-->>Composed: return false (no match)
            Composed-->>Resolver: Empty results
            Resolver-->>Ext: null (no element found)
        else predicate type is "index" with value 0
            Parser-->>Composed: return false (preserves zero-index semantics)
            Composed-->>Resolver: Empty results
            Resolver-->>Ext: null
        end
        
        Ext-->>SDK: No match (or fewer matches than expected)
        SDK-->>Client: count = 0 for unsupported/index=0 predicates
    end

    Note over Client,Composed: Fallback path when native fails entirely
    
    Client->>SDK: locator("xpath=//div[0]").count()
    SDK->>Ext: resolveXpath(xpath, targetIndex)
    Ext->>Parser: parseXPathSteps("//div[0]")
    Parser-->>Ext: step with { type: "index", index: 0 }
    
    Ext->>Resolver: countXPathMatches(xpath)
    Resolver->>Native: document.evaluate("//div[0]", ...)
    
    alt Native succeeds (could match if predicate is valid)
        Native-->>Resolver: XPathResult with results
        Resolver-->>Ext: native count (may include index-zero behavior)
    else Native errors out
        Native-->>Resolver: throws (e.g., malformed query)
        Resolver-->>Composed: countXPathComposed(xpath)
        Composed->>Parser: evaluatePredicate for each element
        
        alt predicate type "index" with value 0
            Parser-->>Composed: return false (never matches)
            Composed-->>Resolver: count = 0
            Resolver-->>Ext: count = 0
        end
        
        Ext-->>SDK: count = 0
        SDK-->>Client: count resolves to 0 (correctly excluding index 0)
    end

    Note over Client,Composed: Integration test with shadow root
    
    Client->>SDK: locator("xpath=//div[position() > 1]").first().textContent()
    SDK->>Ext: resolveXpath with index 0 (for first())
    Ext->>Resolver: resolveXPathAtIndex(xpath, 0)
    Resolver->>Native: document.evaluate with position()
    
    alt Native succeeds with shadow root present
        Native-->>Resolver: Returns correct Element (A2)
        Resolver-->>Ext: Element <div>B2</div>
        Ext-->>SDK: Element
        SDK-->>Client: textContent = "B2" (position() > 1 semantics preserved)
    else Native returns 0 (unrelated shadow root interferes)
        Native-->>Resolver: 0 matches (shadow root causes native failure)
        Resolver-->>Composed: Fallback
        Composed->>Parser: evaluatePredicate for "unsupported" type
        Parser-->>Composed: return false for all elements
        Composed-->>Resolver: count = 0
        Resolver-->>Ext: null
        Ext-->>SDK: No match
        SDK-->>Client: Error or null (test expects failure)
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const shadowHopCount = resolveStagehandShadowHopMatches(xp, shadowCtx.getShadowRoot).length;
if (shadowHopCount > 0) return shadowHopCount;

const native = resolveNativeCountWithError(xp);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new native-vs-composed preference in the hasShadow path is non-trivial — native XPath never pierces shadow roots, so preferring it changes count()/index results whenever a tag appears in both light and composed trees, and the index-zero and position() semantics differ between the native engine and the subset parser. None of this rationale is documented next to the code; only the parser's docstring mentions it. Add an inline comment explaining the ordering (shadow-hop → native light-DOM → composed fallback), why native wins, and that it intentionally does not merge shadow matches, so future edits don't silently regress it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/dom/locatorScripts/xpathResolver.ts, line 80:

<comment>The new native-vs-composed preference in the `hasShadow` path is non-trivial — native XPath never pierces shadow roots, so preferring it changes `count()`/index results whenever a tag appears in both light and composed trees, and the index-zero and `position()` semantics differ between the native engine and the subset parser. None of this rationale is documented next to the code; only the parser's docstring mentions it. Add an inline comment explaining the ordering (shadow-hop → native light-DOM → composed fallback), why native wins, and that it intentionally does not merge shadow matches, so future edits don't silently regress it.</comment>

<file context>
@@ -74,6 +77,9 @@ export function countXPathMatches(rawXp: string, options?: XPathResolveOptions):
   const shadowHopCount = resolveStagehandShadowHopMatches(xp, shadowCtx.getShadowRoot).length;
   if (shadowHopCount > 0) return shadowHopCount;
 
+  const native = resolveNativeCountWithError(xp);
+  if (!native.error && native.count > 0) return native.count;
+
</file context>

Comment thread packages/extension/tests/xpath-parser.test.ts Outdated
@mikhail-koviazin

Copy link
Copy Markdown

Thanks for picking this up, and for going at the parser instead of around it.

Four things I would want checked before this lands, roughly in the order they worry me.

1. The new integration test uses a data: URL, and I don't think it reproduces the bug. That is why the repro in the issue is served over http. data: sits on the protocol list in isFallbackEligible (executionContextRegistry.ts: data:, about:, blob:, file:, filesystem:), and in my runs neither data: nor file:// showed the effect at all. Could you revert the xpathResolver.ts hunk locally and run just that test? If it still passes, it isn't guarding the fix.

2. Native-first gated on count > 0 under-counts pages that mix light and shadow DOM. With if (!native.error && native.count > 0) return native, any selector that matches something in the light DOM never reaches the composed walk, so matches inside shadow roots stop being returned. Two divs in the document plus one inside an open shadow root: //div used to include the shadow one and now returns only the light-DOM two. That is the case the composed walk exists for, and it is a quieter version of what I reported: same shape, opposite direction, still driven by unrelated page content. A union of native and composed matches, deduplicated and in document order, would keep both halves.

3. unsupported returning false keeps the failure silent. What I asked for in the issue was to throw on predicates the parser doesn't implement. An empty set beats a broadened one, but the caller still can't tell "no such element" from "Stagehand can't parse this predicate". Together with 2, a position() selector against content in a shadow root now returns nothing and says nothing.

4. (//div)[2] is still broken, and it doesn't need a shadow root. It returns 0 in both of my runs, with and without attachShadow, so grouping is broken on plain pages too. Since this says Closes #2693, either grouping goes in here or the issue should stay open for that part.

Minor: the parser test passes [{}, {}, {}] as Element[], and the unsupported branch returns false whatever the element is, so //div[position() > 10] satisfies that assertion even if the predicate were parsed properly. A case whose correct answer is non-empty would carry more weight.

Happy to run any of this against my repro if it helps.

@mikhail-koviazin

Copy link
Copy Markdown

On point 4, to be concrete instead of just critical: I would rather grouping did not get pulled into this PR. It is a different code path. parseXPathSteps walks the string from the start expecting / or //, so a leading ( never parses into steps at all, and that happens whether or not the document has a shadow root, which is why it was broken in both of my runs.

I will send it as its own PR against xpathParser.ts with tests, so this one can stay about the engine choice. That does mean #2693 still has a part open after this merges, which is what I was getting at with the Closes line.

Merge native light-DOM results with shadow-root matches, surface unsupported composed predicates, preserve grouped XPath expressions, and exercise the HTTP failure path.

Signed-off-by: KXH <shepherdlaurie238@gmail.com>
@KXHXK

KXHXK commented Aug 13, 2026

Copy link
Copy Markdown
Author

Thanks for the careful repro review. All four points were valid, and I have updated the PR in 2d2b7ca:

  1. Replaced the data: regression with the existing local HTTP fixture helper.
  2. Native results now supply light-DOM XPath semantics, while composed traversal contributes only elements whose root is a ShadowRoot; the two sets are deduplicated and ordered by composed document traversal. A mixed light/shadow test covers this.
  3. Unsupported composed predicates now throw with the original predicate text, and the XPath-specific CDP evaluation path propagates that error instead of converting it to 0/null.
  4. Grouped expressions such as (//div)[2] stay intact and use native XPath evaluation; a focused routing test and HTTP integration assertion cover this.

I also added the positive [2] parser control requested by Cubic. Focused unit coverage is green (7 tests), formatting is clean, and the remote compare contains only the intended two commits. The full HTTP integration file still cannot run to assertions on my Windows checkout because Chrome rejects the extension source path under its non-ASCII parent directory; all eight tests, including unchanged baselines, fail at the same extension-load step. CI should exercise these cases in an ASCII workspace.

If you can rerun the updated branch against your original repro, that would be very helpful.

@mikhail-koviazin

Copy link
Copy Markdown

Grouping is covered here, so there is no separate PR coming from me. Thanks for turning it around this fast.

The merged ordering reads right: native for light-DOM semantics, composed contributing only shadow-rooted elements, sorted by composed traversal.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 2 new issues found across 8 files (changes from recent commits).

Confidence score: 2/5

  • In packages/extension/dom/locatorScripts/xpathParser.ts, resolveXPathAtIndex can throw in the shadow-root branch even when native XPath already found valid light-DOM results, which risks turning otherwise successful lookups into hard failures on pages that include shadow DOM — guard the composed-match evaluation and fall back to native matches when parsing/composition fails.
  • In packages/extension/dom/locatorScripts/xpathResolver.ts, mergeXPathMatches combines native and composed-fallback results that apply index predicates differently, so selectors like //div[2] can resolve to inconsistent elements and cause subtle mis-targeting — align predicate semantics before unioning, or gate merging to semantically equivalent cases.
  • In packages/sdk-ts/tests/integration/locatorCount.test.ts, the current (//div)[2] check validates grouping behavior but not the shadow-fallback path tied to parseXPathSteps, leaving the regression risk under-covered — add a test that starts with / or // and exercises shadow-root fallback indexing directly.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk-ts/tests/integration/locatorCount.test.ts">

<violation number="1" location="packages/sdk-ts/tests/integration/locatorCount.test.ts:73">
P3: The `(//div)[2]` assertion tests XPath grouping, a different code path from the shadow-root fallback this PR addresses. As discussed in review, `parseXPathSteps` only parses selectors starting with `/` or `//`, so a leading `(` never reaches the composed-tree traversal regardless of whether a shadow root exists — this selector's behavior is independent of this bug fix. Keep the shadow-root regression assertions here and move the grouping case to a dedicated PR for `xpathParser.ts` so a failure here isn't attributed to the fallback change.</violation>
</file>

<file name="packages/extension/dom/locatorScripts/xpathParser.ts">

<violation number="1" location="packages/extension/dom/locatorScripts/xpathParser.ts:385">
P1: When a page has any shadow root, this throw can abort XPath resolution even if native XPath already produced valid light-DOM matches. `resolveXPathAtIndex` evaluates composed matches unguarded in the shadow-root branch, so unsupported predicates like `position()` now bubble an exception and callers return null/throw instead of native results. Catch composed-fallback errors and preserve native results when native evaluation succeeded.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

export function evaluatePredicate(element: Element, predicate: XPathPredicate): boolean {
switch (predicate.type) {
case "unsupported":
throw new Error(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a page has any shadow root, this throw can abort XPath resolution even if native XPath already produced valid light-DOM matches. resolveXPathAtIndex evaluates composed matches unguarded in the shadow-root branch, so unsupported predicates like position() now bubble an exception and callers return null/throw instead of native results. Catch composed-fallback errors and preserve native results when native evaluation succeeded.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/dom/locatorScripts/xpathParser.ts, line 385:

<comment>When a page has any shadow root, this throw can abort XPath resolution even if native XPath already produced valid light-DOM matches. `resolveXPathAtIndex` evaluates composed matches unguarded in the shadow-root branch, so unsupported predicates like `position()` now bubble an exception and callers return null/throw instead of native results. Catch composed-fallback errors and preserve native results when native evaluation succeeded.</comment>

<file context>
@@ -382,7 +382,9 @@ function normalizeMaybe(value: string, normalize?: boolean): string {
   switch (predicate.type) {
     case "unsupported":
-      return false;
+      throw new Error(
+        `Unsupported XPath predicate in composed-tree traversal: ${predicate.source}`,
+      );
</file context>

Comment thread packages/extension/understudy/selectorResolver.ts Outdated
Comment thread packages/extension/understudy/selectorResolver.ts Outdated

await expect(page.locator("xpath=//div[2]").count()).resolves.toBe(2);
await expect(page.locator("xpath=//div[0]").count()).resolves.toBe(0);
await expect(page.locator("xpath=(//div)[2]").count()).resolves.toBe(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The (//div)[2] assertion tests XPath grouping, a different code path from the shadow-root fallback this PR addresses. As discussed in review, parseXPathSteps only parses selectors starting with / or //, so a leading ( never reaches the composed-tree traversal regardless of whether a shadow root exists — this selector's behavior is independent of this bug fix. Keep the shadow-root regression assertions here and move the grouping case to a dedicated PR for xpathParser.ts so a failure here isn't attributed to the fallback change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-ts/tests/integration/locatorCount.test.ts, line 73:

<comment>The `(//div)[2]` assertion tests XPath grouping, a different code path from the shadow-root fallback this PR addresses. As discussed in review, `parseXPathSteps` only parses selectors starting with `/` or `//`, so a leading `(` never reaches the composed-tree traversal regardless of whether a shadow root exists — this selector's behavior is independent of this bug fix. Keep the shadow-root regression assertions here and move the grouping case to a dedicated PR for `xpathParser.ts` so a failure here isn't attributed to the fallback change.</comment>

<file context>
@@ -52,22 +60,33 @@ describe("Locator count() method tests", () => {
-        ),
+    await expect(page.locator("xpath=//div[2]").count()).resolves.toBe(2);
+    await expect(page.locator("xpath=//div[0]").count()).resolves.toBe(0);
+    await expect(page.locator("xpath=(//div)[2]").count()).resolves.toBe(1);
+    await expect(page.locator("xpath=//div[position() > 1]").count()).rejects.toThrow(
+      "Unsupported XPath predicate in composed-tree traversal: position() > 1",
</file context>

Comment thread packages/extension/dom/locatorScripts/xpathResolver.ts
Keep native positional semantics when light-DOM matches exist, sanitize unsupported-predicate errors at the CDP boundary, and retain null/zero recovery for transient runtime failures.

Signed-off-by: KXH <shepherdlaurie238@gmail.com>
@KXHXK
KXHXK force-pushed the fix-native-xpath-predicates branch from 5b660b3 to adbc1ca Compare August 13, 2026 10:23

KXHXK commented Aug 13, 2026

Copy link
Copy Markdown
Author

I reviewed the second Cubic pass against the resolver behavior and pushed adbc1ca.

The valid findings are addressed as follows:

  • XPath expressions with positional or unsupported predicates now keep native results when native evaluation produced light-DOM matches. This avoids applying incompatible flattened composed-tree positional semantics (for example, //div[2]) and avoids letting an unrelated shadow root turn a valid native result into an exception.
  • Attribute/text predicates that are safe to evaluate per element still merge shadow-root matches with native light-DOM matches, preserving the mixed light/shadow behavior covered by the regression.
  • The CDP boundary now exposes only the stable message Unsupported XPath predicate in composed-tree traversal; it no longer reflects the selector or raw Runtime exception details.
  • Non-predicate Runtime/context failures again resolve as 0/null, while the explicit unsupported-predicate signal still propagates.
  • Removed the unused native evaluation error flag.
  • Added coverage for transient XPath evaluation failures.

Verification on the updated files:

  • 8 focused tests passed across parser, selector resolver, and deep locator suites.
  • Oxfmt check passed.
  • Oxlint passed.

I kept the grouped-expression regression in this PR because the issue author confirmed it is covered here and no separate PR is planned.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/extension/dom/locatorScripts/xpathResolver.ts Outdated
Distinguish successful empty native evaluation from native errors before considering composed traversal, and cover an unrelated shadow root with a zero-match last() selector.

Signed-off-by: KXH <shepherdlaurie238@gmail.com>
@KXHXK
KXHXK force-pushed the fix-native-xpath-predicates branch from 60b789a to 303ed5f Compare August 13, 2026 11:06

KXHXK commented Aug 13, 2026

Copy link
Copy Markdown
Author

The zero-native-match finding is valid and is fixed in 303ed5f.

resolveNativeMatches again reports whether native evaluation failed, and that signal is now used: positional/full-XPath predicates return the native result whenever evaluation succeeds, including an empty snapshot. Composed traversal is considered only when native evaluation actually fails. This keeps //button[last()] at 0/null on a page with an unrelated shadow root instead of turning it into an unsupported-predicate error.

The HTTP fixture now covers that zero-match case and also reflects the final native-first rule for //div[position() > 1] (native count 2). Focused verification remains green: 8 tests passed, Oxfmt passed, and Oxlint passed. The extension-backed HTTP file remains blocked locally at Chrome extension loading by the non-ASCII workspace path, as noted earlier; CI will run it in its normal workspace.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

XPath predicates resolve differently once any shadow root exists on the page

2 participants