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 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/locatorScripts/counts.ts b/packages/core/lib/v3/dom/locatorScripts/counts.ts index f70c754a1..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; 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/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 new file mode 100644 index 000000000..ed7662f21 --- /dev/null +++ b/packages/core/lib/v3/dom/locatorScripts/xpathParser.ts @@ -0,0 +1,171 @@ +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. + * + * 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 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 || "") + .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; + let bracketDepth = 0; + let quote: string | null = null; + while (i < path.length) { + 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(); + if (!rawStep) continue; + + const { tag, index, attrs } = parseStep(rawStep); + steps.push({ axis, tag, index, attrs }); + } + + 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; + 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[] = []; + + for (const inner of extractPredicates(predicateStr)) { + // 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/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; } diff --git a/packages/core/tests/xpath-parser.test.ts b/packages/core/tests/xpath-parser.test.ts new file mode 100644 index 000000000..acdf5d153 --- /dev/null +++ b/packages/core/tests/xpath-parser.test.ts @@ -0,0 +1,286 @@ +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: "" }]); + }); + + 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]" }]); + }); + + 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", () => { + 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 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); + 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); + }); +});