From f4f1ba4883ed888623735ce5f7e7c71cfa278254 Mon Sep 17 00:00:00 2001 From: Shivansh Singhal Date: Fri, 23 Oct 2026 11:55:46 +1000 Subject: [PATCH] fix(extension): speed up text locator count and nth on large lists --- .changeset/fast-text-locator-lookups.md | 8 + .../extension/dom/locatorScripts/counts.ts | 148 ++------------- .../extension/dom/locatorScripts/selectors.ts | 131 +------------ .../dom/locatorScripts/textMatches.ts | 174 ++++++++++++++++++ .../extension/understudy/deepLocator.test.ts | 9 +- .../understudy/locatorResolution.test.ts | 54 ++++-- .../extension/understudy/selectorResolver.ts | 31 ++-- .../tests/integration/locatorNth.test.ts | 16 ++ .../integration/textSelectorInnermost.test.ts | 128 ++++++++++++- 9 files changed, 403 insertions(+), 296 deletions(-) create mode 100644 .changeset/fast-text-locator-lookups.md create mode 100644 packages/extension/dom/locatorScripts/textMatches.ts diff --git a/.changeset/fast-text-locator-lookups.md b/.changeset/fast-text-locator-lookups.md new file mode 100644 index 0000000000..b72a5886d0 --- /dev/null +++ b/.changeset/fast-text-locator-lookups.md @@ -0,0 +1,8 @@ +--- +"@browserbasehq/stagehand-extension": patch +"@browserbasehq/stagehand": patch +"@browserbasehq/stagehand-python": patch +"@browserbasehq/stagehand-go": patch +--- + +Speed up `text=` locator `count()` and `nth()` on large repeated lists by matching text in a single linear pass and resolving only the requested index. diff --git a/packages/extension/dom/locatorScripts/counts.ts b/packages/extension/dom/locatorScripts/counts.ts index 09f4ed11ab..16b710389a 100644 --- a/packages/extension/dom/locatorScripts/counts.ts +++ b/packages/extension/dom/locatorScripts/counts.ts @@ -1,5 +1,6 @@ import { countXPathMatches } from "./xpathResolver.js"; import { getOpenOrClosedShadowRoot } from "./shadowRoots.js"; +import { collectInnermostTextMatches, textUsedForMatch } from "./textMatches.js"; export interface TextMatchSample { tag: string; @@ -64,145 +65,30 @@ export function countCssMatchesPrimary(selectorRaw: string): number { } } +const classNameOf = (element: Element): string => { + try { + const value = (element as HTMLElement).className; + return typeof value === "string" ? value : ""; + } catch { + return ""; + } +}; + export function countTextMatches(rawNeedle: string): TextMatchResult { const needle = String(rawNeedle ?? ""); if (!needle) { return { count: 0, sample: [], error: null }; } - const needleLc = needle.toLowerCase(); - const skipTags = new Set([ - "SCRIPT", - "STYLE", - "TEMPLATE", - "NOSCRIPT", - "HEAD", - "TITLE", - "LINK", - "META", - "HTML", - "BODY", - ]); - - const shouldSkip = (node: Element | null | undefined): boolean => { - if (!node) return false; - const tag = node.tagName?.toUpperCase() ?? ""; - return skipTags.has(tag); - }; - - const extractText = (element: Element): string => { - try { - if (shouldSkip(element)) return ""; - const inner = (element as HTMLElement).innerText; - if (typeof inner === "string" && inner.trim()) return inner.trim(); - } catch { - // ignore - } - try { - const text = element.textContent; - if (typeof text === "string") return text.trim(); - } catch { - // ignore - } - return ""; - }; - - const matches = (element: Element): boolean => { - const text = extractText(element); - return !!text && text.toLowerCase().includes(needleLc); - }; - - const seen = new WeakSet(); - const queue: Node[] = []; - - const enqueue = (node: Node | null | undefined) => { - if (!node || seen.has(node)) return; - seen.add(node); - queue.push(node); - }; - - const walkerFor = (root: Node): TreeWalker | null => { - try { - const doc = root instanceof Document ? root : ((root as Element)?.ownerDocument ?? document); - return doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - } catch { - return null; - } - }; - - const matchesList: Array<{ - element: Element; - tag: string; - id: string; - className: string; - text: string; - }> = []; - - enqueue(document); - - while (queue.length) { - const root = queue.shift(); - if (!root) continue; - - if (root instanceof Element && matches(root)) { - matchesList.push({ - element: root, - tag: root.tagName ?? "", - id: root.id ?? "", - className: (root as HTMLElement).className ?? "", - text: extractText(root), - }); - } - - const walker = walkerFor(root); - if (!walker) continue; - - let node: Node | null; - while ((node = walker.nextNode())) { - if (!(node instanceof Element)) continue; - - if (matches(node)) { - matchesList.push({ - element: node, - tag: node.tagName ?? "", - id: node.id ?? "", - className: (node as HTMLElement).className ?? "", - text: extractText(node), - }); - } - - const shadowRoot = getOpenOrClosedShadowRoot(node); - if (shadowRoot) enqueue(shadowRoot); - } - } - - const innermost: typeof matchesList = []; - for (const item of matchesList) { - const el = item.element; - let skip = false; - for (const other of matchesList) { - if (item === other) continue; - try { - if (el.contains(other.element)) { - skip = true; - break; - } - } catch { - // ignore containment errors - } - } - if (!skip) innermost.push(item); - } - - const count = innermost.length; - const sample = innermost.slice(0, 5).map((item) => ({ - tag: item.tag, - id: item.id, - class: item.className, - text: item.text, + const matches = collectInnermostTextMatches(needle); + const sample = matches.slice(0, 5).map((element) => ({ + tag: element.tagName ?? "", + id: element.id ?? "", + class: classNameOf(element), + text: textUsedForMatch(element), })); - return { count, sample, error: null }; + return { count: matches.length, sample, error: null }; } export function countXPathMatchesMainWorld(rawXp: string): number { diff --git a/packages/extension/dom/locatorScripts/selectors.ts b/packages/extension/dom/locatorScripts/selectors.ts index c606d3c6be..1e6eedb6a5 100644 --- a/packages/extension/dom/locatorScripts/selectors.ts +++ b/packages/extension/dom/locatorScripts/selectors.ts @@ -1,5 +1,6 @@ import { resolveXPathAtIndex } from "./xpathResolver.js"; import { getOpenOrClosedShadowRoot } from "./shadowRoots.js"; +import { collectInnermostTextMatches } from "./textMatches.js"; const parseTargetIndex = (value: unknown): number => { const num = Number(value ?? 0); @@ -65,135 +66,9 @@ export function resolveCssSelector(selectorRaw: string, targetIndexRaw?: number) export function resolveTextSelector(rawNeedle: string, targetIndexRaw?: number): Element | null { const needle = String(rawNeedle ?? ""); if (!needle) return null; - const needleLc = needle.toLowerCase(); const targetIndex = parseTargetIndex(targetIndexRaw); - - const skipTags = new Set([ - "SCRIPT", - "STYLE", - "TEMPLATE", - "NOSCRIPT", - "HEAD", - "TITLE", - "LINK", - "META", - "HTML", - "BODY", - ]); - - const shouldSkip = (node: Element | null | undefined): boolean => { - if (!node) return false; - const tag = node.tagName?.toUpperCase() ?? ""; - return skipTags.has(tag); - }; - - const extractText = (node: Element): string => { - try { - if (shouldSkip(node)) return ""; - const inner = (node as HTMLElement).innerText; - if (typeof inner === "string" && inner.trim()) return inner.trim(); - } catch { - // ignore - } - try { - const text = node.textContent; - if (typeof text === "string") return text.trim(); - } catch { - // ignore - } - return ""; - }; - - const matches = (node: Element): boolean => { - const text = extractText(node); - return !!text && text.toLowerCase().includes(needleLc); - }; - - const seen = new WeakSet(); - const queue: Node[] = []; - const matchesList: Array<{ - element: Element; - tag: string; - id: string; - className: string; - text: string; - }> = []; - - const enqueue = (node: Node | null | undefined) => { - if (!node || seen.has(node)) return; - seen.add(node); - queue.push(node); - }; - - const walkerFor = (root: Node): TreeWalker | null => { - try { - const doc = root instanceof Document ? root : ((root as Element)?.ownerDocument ?? document); - return doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); - } catch { - return null; - } - }; - - enqueue(document); - - while (queue.length) { - const root = queue.shift(); - if (!root) continue; - - if (root instanceof Element && matches(root)) { - matchesList.push({ - element: root, - tag: root.tagName ?? "", - id: root.id ?? "", - className: (root as HTMLElement).className ?? "", - text: extractText(root), - }); - } - - const walker = walkerFor(root); - if (!walker) continue; - - let node: Node | null; - while ((node = walker.nextNode())) { - if (!(node instanceof Element)) continue; - - if (matches(node)) { - matchesList.push({ - element: node, - tag: node.tagName ?? "", - id: node.id ?? "", - className: (node as HTMLElement).className ?? "", - text: extractText(node), - }); - } - - const shadowRoot = getOpenOrClosedShadowRoot(node); - if (shadowRoot) enqueue(shadowRoot); - } - } - - const innermost: typeof matchesList = []; - for (const item of matchesList) { - const el = item.element; - let skip = false; - for (const other of matchesList) { - if (item === other) continue; - try { - if (el.contains(other.element)) { - skip = true; - break; - } - } catch { - // ignore containment errors - } - } - if (!skip) { - innermost.push(item); - } - } - - const target = innermost[targetIndex]; - return target?.element ?? null; + const matches = collectInnermostTextMatches(needle, targetIndex + 1); + return matches[targetIndex] ?? null; } export function resolveXPathMainWorld(rawXp: string, targetIndexRaw?: number): Element | null { diff --git a/packages/extension/dom/locatorScripts/textMatches.ts b/packages/extension/dom/locatorScripts/textMatches.ts new file mode 100644 index 0000000000..bb1ebea6bc --- /dev/null +++ b/packages/extension/dom/locatorScripts/textMatches.ts @@ -0,0 +1,174 @@ +import { getOpenOrClosedShadowRoot } from "./shadowRoots.js"; + +const SKIP_TAGS = new Set([ + "SCRIPT", + "STYLE", + "TEMPLATE", + "NOSCRIPT", + "HEAD", + "TITLE", + "LINK", + "META", + "HTML", + "BODY", +]); + +const shouldSkip = (element: Element): boolean => { + try { + return SKIP_TAGS.has(element.tagName?.toUpperCase() ?? ""); + } catch { + return false; + } +}; + +const textContentOf = (element: Element): string => { + try { + const text = element.textContent; + return typeof text === "string" ? text.trim() : ""; + } catch { + return ""; + } +}; + +/** + * Prefer trimmed innerText, then trimmed textContent. + * Skipped tags contribute no text, but their descendants are still walked. + */ +const extractText = (element: Element): string => { + try { + if (shouldSkip(element)) return ""; + const inner = (element as HTMLElement).innerText; + if (typeof inner === "string" && inner.trim()) return inner.trim(); + } catch { + // ignore + } + return textContentOf(element); +}; + +const isLightLeaf = (element: Element): boolean => { + try { + // An open shadow host has no light children but innerText includes shadow text. + return element.childElementCount === 0 && !element.shadowRoot; + } catch { + return false; + } +}; + +// Whitespace that innerText would collapse or normalize: any run of two or more +// whitespace characters, or any whitespace character other than a plain space. +const COLLAPSIBLE_WHITESPACE = /\s\s|[^\S ]/; + +/** + * Text used to decide a candidate. A leaf whose text has nothing to collapse skips layout, + * because innerText and textContent agree there. Everything else stays layout-aware. + */ +export function textUsedForMatch(element: Element): string { + if (shouldSkip(element)) return ""; + if (isLightLeaf(element)) { + const text = textContentOf(element); + if (!COLLAPSIBLE_WHITESPACE.test(text)) return text; + } + return extractText(element); +} + +const includesNeedle = (element: Element, needleLc: string): boolean => { + const text = textUsedForMatch(element); + return text.length > 0 && text.toLowerCase().includes(needleLc); +}; + +const ownerDocumentOf = (root: ParentNode): Document => { + if (root instanceof Document) return root; + if (root instanceof ShadowRoot) return root.host?.ownerDocument ?? document; + return root.ownerDocument ?? document; +}; + +/** + * Innermost light-DOM matches in document order. + * + * A light-DOM descendant that matches suppresses its ancestors. A shadow match does not: + * element.contains does not cross a shadow boundary. Each tree is enumerated with a + * TreeWalker so closed shadow roots from getOpenOrClosedShadowRoot stay readable. + * Shadow roots are queued in document order, then searched breadth-first after the tree + * that contains those hosts. Nested shadow roots therefore run after later sibling hosts. + * + * maxMatches stops once that many innermost elements have been emitted. Later siblings + * and not-yet-searched shadow roots sort after those elements. + */ +export function collectInnermostTextMatches(rawNeedle: string, maxMatches = Infinity): Element[] { + const needle = String(rawNeedle ?? ""); + if (!needle || maxMatches <= 0) return []; + const needleLc = needle.toLowerCase(); + const results: Element[] = []; + const shadowRoots: ShadowRoot[] = []; + const seenShadows = new WeakSet(); + + const queueShadow = (element: Element): void => { + try { + const shadow = getOpenOrClosedShadowRoot(element); + if (!shadow || seenShadows.has(shadow)) return; + seenShadows.add(shadow); + shadowRoots.push(shadow); + } catch { + // ignore shadow lookup errors + } + }; + + const visitTree = (root: ParentNode): void => { + const elements: Element[] = []; + try { + const walker = ownerDocumentOf(root).createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let node: Node | null; + while ((node = walker.nextNode())) { + if (!(node instanceof Element)) continue; + elements.push(node); + queueShadow(node); + } + } catch { + return; + } + + const childrenByParent = new Map(); + for (const element of elements) { + const parent = element.parentNode; + if (!parent) continue; + const siblings = childrenByParent.get(parent); + if (siblings) siblings.push(element); + else childrenByParent.set(parent, [element]); + } + + const visit = (element: Element): boolean => { + let descendantMatched = false; + const children = childrenByParent.get(element); + if (children) { + for (const child of children) { + if (results.length >= maxMatches) break; + if (visit(child)) descendantMatched = true; + } + } + if (descendantMatched) return true; + if (results.length >= maxMatches) return false; + if (!includesNeedle(element, needleLc)) return false; + results.push(element); + return true; + }; + + const topLevel = childrenByParent.get(root); + if (!topLevel) return; + for (const child of topLevel) { + if (results.length >= maxMatches) return; + visit(child); + } + }; + + try { + visitTree(document); + for (let index = 0; index < shadowRoots.length && results.length < maxMatches; index += 1) { + const shadow = shadowRoots[index]; + if (shadow) visitTree(shadow); + } + } catch { + // Best-effort, matching the previous walker. + } + + return results; +} diff --git a/packages/extension/understudy/deepLocator.test.ts b/packages/extension/understudy/deepLocator.test.ts index fa586e1491..77e285814e 100644 --- a/packages/extension/understudy/deepLocator.test.ts +++ b/packages/extension/understudy/deepLocator.test.ts @@ -85,9 +85,7 @@ describe("locator resolution contexts", () => { vi.useFakeTimers(); const { page, root, inner } = createFrames(); const resolveFrame = vi.spyOn(FrameLocator.prototype, "resolveFrame"); - const lookups = (["resolveCss", "resolveText", "resolveXPath"] as const).map((method) => - vi.spyOn(FrameSelectorResolver.prototype, method), - ); + const resolveAtIndex = vi.spyOn(FrameSelectorResolver.prototype, "resolveAtIndex"); const delegate = kind === "frame locator" ? frameLocatorFromFrame(page, root, "#outer") @@ -110,9 +108,8 @@ describe("locator resolution contexts", () => { nodeId: 1, }); expect(resolveFrame.mock.calls).toEqual([[progress], [progress]]); - const calls = lookups.flatMap((lookup) => lookup.mock.calls); - expect(calls).toHaveLength(3); - expect(calls.every(([, , context]) => context === progress)).toBe(true); + expect(resolveAtIndex.mock.calls.map(([, index]) => index)).toEqual([0, 0, 1]); + expect(resolveAtIndex.mock.calls.every(([, , context]) => context === progress)).toBe(true); expect(progress.remainingMs()).toBe(100); }); expect(vi.getTimerCount()).toBe(0); diff --git a/packages/extension/understudy/locatorResolution.test.ts b/packages/extension/understudy/locatorResolution.test.ts index c169946731..ff08236ded 100644 --- a/packages/extension/understudy/locatorResolution.test.ts +++ b/packages/extension/understudy/locatorResolution.test.ts @@ -224,6 +224,23 @@ describe("locator resolution deadlines", () => { expect(root.send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "node" }); }); + it.each([ + ["button", "resolveCssSelector", '"button", 1'], + ["text=Submit", "resolveTextSelector", '"Submit", 1'], + ["xpath=//button", "resolveXPathMainWorld", '"//button", 1'], + ] as const)("resolves %s with one indexed evaluation", async (selector, helper, args) => { + const { frame, send } = createFrame("root"); + await expect( + frame.locator(selector).nth(1).resolveNode(createProgress()), + ).resolves.toMatchObject({ objectId: "node" }); + const evaluations = send.mock.calls.filter(([method]) => method === "Runtime.evaluate"); + expect(evaluations).toHaveLength(1); + const expression = + (evaluations[0]?.[1] as { expression?: string } | undefined)?.expression ?? ""; + expect(expression).toContain(helper); + expect(expression).toContain(args); + }); + it("releases earlier matches when a later selector lookup times out", async () => { const { frame, send, respond } = createFrame("root"); const gate = deferred(); @@ -235,7 +252,13 @@ describe("locator resolution deadlines", () => { : Promise.resolve({ result: { objectId: "first" } }) : respond(method, params), ); - const pending = frame.locator("button").nth(1).resolveNode(createProgress()); + const pending = frame + .locator("button") + .selectorResolver.resolveAll( + { kind: "css", value: "button" }, + { limit: 2 }, + createProgress(), + ); const rejected = expect(pending).rejects.toThrow(TimeoutError); await vi.advanceTimersByTimeAsync(100); await rejected; @@ -245,32 +268,33 @@ describe("locator resolution deadlines", () => { expect(send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "second" }); }); - it("reports expiry without waiting for a second stalled cleanup", async () => { + it("releases only the indexed lookup when its evaluation times out", async () => { const { frame, send, respond } = createFrame("root"); const resolver = frame.locator("button").selectorResolver; - vi.spyOn(resolver, "resolveAll").mockResolvedValue([ - { objectId: "unselected", nodeId: 1 }, - { objectId: "selected", nodeId: 2 }, - ]); + const resolveAll = vi.spyOn(resolver, "resolveAll"); const gate = deferred(); - send.mockImplementation((method, params) => - method === "Runtime.releaseObject" ? gate.promise : respond(method, params), - ); + send.mockImplementation((method, params) => { + if (method !== "Runtime.evaluate") return respond(method, params); + const expression = (params as { expression?: string } | undefined)?.expression ?? ""; + if (!expression.includes("resolveCssSelector")) return respond(method, params); + return gate.promise; + }); const settled = vi.fn(); const pending = resolver.resolveAtIndex({ kind: "css", value: "button" }, 1, createProgress()); void pending.then(settled, settled); try { - await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(100); expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(TimeoutError)); - expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toEqual([ - ["Runtime.releaseObject", { objectId: "unselected" }], - ["Runtime.releaseObject", { objectId: "selected" }], - ]); + expect(resolveAll).not.toHaveBeenCalled(); + expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toEqual([]); } finally { - gate.resolve({}); + gate.resolve({ result: { objectId: "selected" } }); await vi.advanceTimersByTimeAsync(0); } + expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toEqual([ + ["Runtime.releaseObject", { objectId: "selected" }], + ]); }); it("removes the main-world listener & timer when its caller expires", async () => { diff --git a/packages/extension/understudy/selectorResolver.ts b/packages/extension/understudy/selectorResolver.ts index acb975e56c..79d8e94174 100644 --- a/packages/extension/understudy/selectorResolver.ts +++ b/packages/extension/understudy/selectorResolver.ts @@ -19,6 +19,12 @@ export interface ResolveManyOptions { limit?: number; } +const selectorHelpers = { + css: "resolveCssSelector", + text: "resolveTextSelector", + xpath: "resolveXPathMainWorld", +} as const; + export class FrameSelectorResolver { constructor( readonly frame: Frame, @@ -108,21 +114,16 @@ export class FrameSelectorResolver { ): Promise { progress?.throwIfStopped(); if (index < 0 || !Number.isFinite(index)) return null; - const results = await this.resolveAll(query, { limit: index + 1 }, progress); - const selected = results[index] ?? null; - if (progress) { - await this.releaseNodes( - results.filter((node) => node !== selected), - progress, - ); - try { - progress.throwIfStopped(); - } catch (error) { - if (selected) void this.releaseNodes([selected], progress); - throw error; - } - } - return selected; + const helper = selectorHelpers[query.kind]; + const { contextId } = await executionContexts.waitForLocatorWorld( + this.frame.session, + this.frame.frameId, + 1000, + progress, + this.readinessOptions, + ); + const expression = buildLocatorInvocation(helper, [JSON.stringify(query.value), String(index)]); + return this.evaluateElement(expression, contextId, progress); } async resolveCss(selector: string, limit: number, progress?: Progress): Promise { diff --git a/packages/sdk-ts/tests/integration/locatorNth.test.ts b/packages/sdk-ts/tests/integration/locatorNth.test.ts index b12a4b2ba8..5cee173199 100644 --- a/packages/sdk-ts/tests/integration/locatorNth.test.ts +++ b/packages/sdk-ts/tests/integration/locatorNth.test.ts @@ -92,6 +92,22 @@ describe("Locator nth() method tests", () => { expect(text2).toBe("Click me"); }); + it("nth() selects one repeated text match", async () => { + const page = await firstPage(stagehand); + await page.goto(`data:text/html,${encodeURIComponent("
")}`); + await page.evaluate(() => { + document.querySelector("#root")!.innerHTML = Array.from( + { length: 50 }, + (_, index) => `
`, + ).join(""); + }); + + const matches = page.locator("text=Repeated label"); + expect(await matches.count()).toBe(50); + expect(await matches.nth(2).innerText()).toBe("Repeated label 2"); + expect(await matches.nth(49).innerText()).toBe("Repeated label 49"); + }); + it("nth() with shadow DOM", async () => { const page = await firstPage(stagehand); diff --git a/packages/sdk-ts/tests/integration/textSelectorInnermost.test.ts b/packages/sdk-ts/tests/integration/textSelectorInnermost.test.ts index ea9f0f059a..e3e61efe98 100644 --- a/packages/sdk-ts/tests/integration/textSelectorInnermost.test.ts +++ b/packages/sdk-ts/tests/integration/textSelectorInnermost.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Stagehand } from "../../src/index.js"; -import { closeStagehand, createStagehand, firstPage } from "./_support.js"; +import { closeStagehand, createStagehand, firstPage, startFixtureServer } from "./_support.js"; describe("text selector innermost element matching", () => { let stagehand: Stagehand; @@ -44,4 +44,130 @@ describe("text selector innermost element matching", () => { expect(await page.locator("text=World").count()).toBe(1); expect(await page.locator("text=Hello World").count()).toBe(1); }); + + it("does not treat block text separated by a newline as one word", async () => { + const page = await firstPage(stagehand); + await page.goto( + `data:text/html,${encodeURIComponent(`
Hello
World
`)}`, + ); + + expect(await page.locator("text=HelloWorld").count()).toBe(0); + const matches = page.locator("text=Hello"); + expect(await matches.count()).toBe(1); + expect(await matches.innerText()).toBe("Hello"); + }); + + it("collapses whitespace in a leaf element like innerText", async () => { + const page = await firstPage(stagehand); + await page.goto(`data:text/html,${encodeURIComponent(``)}`); + + expect(await page.locator("text=Hello World").count()).toBe(1); + }); + + it("matches hidden text through the textContent fallback", async () => { + const page = await firstPage(stagehand); + await page.goto( + `data:text/html,${encodeURIComponent(`
Hidden label
Hidden label`)}`, + ); + + expect(await page.locator("text=Hidden label").count()).toBe(2); + }); + + it("orders a light-DOM match before an open shadow match", async () => { + const page = await firstPage(stagehand); + await page.goto(`data:text/html,${encodeURIComponent("
")}`); + await page.evaluate(() => { + const host = document.createElement("div"); + const light = document.createElement("button"); + light.textContent = "Light label"; + host.appendChild(light); + document.querySelector("#root")!.appendChild(host); + host.attachShadow({ mode: "open" }).innerHTML = ""; + }); + + const matches = page.locator("text=label"); + expect(await matches.count()).toBe(2); + expect(await matches.nth(0).innerText()).toBe("Light label"); + expect(await matches.nth(1).innerText()).toBe("Shadow label"); + }); + + it("keeps a shadow host when its shadow tree also matches", async () => { + const page = await firstPage(stagehand); + await page.goto(`data:text/html,${encodeURIComponent("
")}`); + await page.evaluate(() => { + const host = document.createElement("div"); + host.textContent = "Host label outside"; + host.addEventListener("click", () => { + document.body.dataset.clicked = "host"; + }); + document.querySelector("#root")!.appendChild(host); + const shadow = host.attachShadow({ mode: "open" }); + const inner = document.createElement("span"); + inner.textContent = "Host label inside"; + inner.addEventListener("click", (event) => { + event.stopPropagation(); + document.body.dataset.clicked = "shadow"; + }); + shadow.appendChild(inner); + }); + + const matches = page.locator("text=Host label"); + expect(await matches.count()).toBe(2); + await matches.nth(0).click(); + expect(await page.evaluate(() => document.body.dataset.clicked)).toBe("host"); + await matches.nth(1).click(); + expect(await page.evaluate(() => document.body.dataset.clicked)).toBe("shadow"); + }); + + it("searches sibling shadow roots before a shadow root nested in an earlier host", async () => { + const fixture = await startFixtureServer({ + "/": "
", + }); + try { + const page = await firstPage(stagehand); + await page.goto(fixture.url, { waitUntil: "load" }); + await page.evaluate(() => { + const root = document.querySelector("#root")!; + root.innerHTML = `Label light
`; + const shadowA = document.querySelector("#hostA")!.attachShadow({ mode: "closed" }); + shadowA.innerHTML = `Label A
`; + shadowA + .querySelector("#hostB")! + .attachShadow({ mode: "closed" }).innerHTML = `Label B`; + document + .querySelector("#hostC")! + .attachShadow({ mode: "closed" }).innerHTML = `Label C`; + }); + + const matches = page.locator("text=Label"); + expect(await matches.count()).toBe(4); + expect(await matches.nth(0).innerText()).toBe("Label light"); + expect(await matches.nth(1).innerText()).toBe("Label A"); + expect(await matches.nth(2).innerText()).toBe("Label C"); + expect(await matches.nth(3).innerText()).toBe("Label B"); + } finally { + await fixture.close(); + } + }); + + it("counts and selects a repeated text list without a multi-second scan", async () => { + const page = await firstPage(stagehand); + const rows = 10_000; + await page.goto(`data:text/html,${encodeURIComponent("
")}`); + await page.evaluate((count) => { + document.querySelector("#root")!.innerHTML = Array.from( + { length: count }, + (_, index) => `
`, + ).join(""); + }, rows); + + const matches = page.locator("text=Repeated label"); + const countStarted = performance.now(); + expect(await matches.count()).toBe(rows); + expect(performance.now() - countStarted).toBeLessThan(5_000); + + const nthStarted = performance.now(); + expect(await matches.nth(2).innerText()).toBe("Repeated label 2"); + expect(performance.now() - nthStarted).toBeLessThan(5_000); + }); });