diff --git a/.changeset/silly-rooms-grow.md b/.changeset/silly-rooms-grow.md new file mode 100644 index 000000000..259b22da5 --- /dev/null +++ b/.changeset/silly-rooms-grow.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +add support for page.waitForSelector() diff --git a/packages/core/lib/v3/dom/locatorScripts/index.ts b/packages/core/lib/v3/dom/locatorScripts/index.ts index 3507beca8..d9b4f2514 100644 --- a/packages/core/lib/v3/dom/locatorScripts/index.ts +++ b/packages/core/lib/v3/dom/locatorScripts/index.ts @@ -1,3 +1,4 @@ export * from "./scripts"; export * from "./selectors"; export * from "./counts"; +export * from "./waitForSelector"; diff --git a/packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts b/packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts new file mode 100644 index 000000000..3b24b482c --- /dev/null +++ b/packages/core/lib/v3/dom/locatorScripts/waitForSelector.ts @@ -0,0 +1,549 @@ +/** + * waitForSelector - Waits for an element matching a selector to reach a specific state. + * Supports both CSS selectors and XPath expressions. + * Uses MutationObserver for efficiency and integrates with the V3 piercer for closed shadow roots. + * + * NOTE: This function runs inside the page context. Keep it dependency-free + * and resilient to exceptions. + */ + +type WaitForSelectorState = "attached" | "detached" | "visible" | "hidden"; + +/** + * Check if a selector is an XPath expression. + */ +const isXPath = (selector: string): boolean => { + return selector.startsWith("xpath=") || selector.startsWith("/"); +}; + +/** + * Normalize XPath by removing "xpath=" prefix if present. + */ +const normalizeXPath = (selector: string): string => { + if (selector.startsWith("xpath=")) { + return selector.slice(6).trim(); + } + return selector; +}; + +/** + * Get closed shadow root via the V3 piercer if available. + */ +const getClosedRoot = (element: Element): ShadowRoot | null => { + try { + const backdoor = window.__stagehandV3__; + if (backdoor && typeof backdoor.getClosedRoot === "function") { + return backdoor.getClosedRoot(element) ?? null; + } + } catch { + // ignore + } + return null; +}; + +/** + * Get shadow root (open or closed via piercer). + */ +const getShadowRoot = (element: Element): ShadowRoot | null => { + // First try open shadow root + if (element.shadowRoot) return element.shadowRoot; + // Then try closed shadow root via piercer + return getClosedRoot(element); +}; + +/** + * Deep querySelector that pierces shadow DOM (both open and closed via piercer). + */ +const deepQuerySelector = ( + root: Document | ShadowRoot, + selector: string, + pierceShadow: boolean, +): Element | null => { + // Try regular querySelector first + try { + const el = root.querySelector(selector); + if (el) return el; + } catch { + // ignore query errors + } + + if (!pierceShadow) return null; + + // BFS queue to search all shadow roots (open and closed) + const seenRoots = new WeakSet(); + const queue: Array = [root]; + + while (queue.length > 0) { + const currentRoot = queue.shift(); + if (!currentRoot || seenRoots.has(currentRoot)) continue; + seenRoots.add(currentRoot); + + // Try querySelector on this root + try { + const found = currentRoot.querySelector(selector); + if (found) return found; + } catch { + // ignore query errors + } + + // Walk all elements in this root to find shadow hosts + try { + const ownerDoc = + currentRoot instanceof Document + ? currentRoot + : (currentRoot.host?.ownerDocument ?? document); + const walker = ownerDoc.createTreeWalker( + currentRoot, + NodeFilter.SHOW_ELEMENT, + ); + let node: Node | null; + while ((node = walker.nextNode())) { + if (!(node instanceof Element)) continue; + const shadowRoot = getShadowRoot(node); + if (shadowRoot && !seenRoots.has(shadowRoot)) { + queue.push(shadowRoot); + } + } + } catch { + // ignore traversal errors + } + } + + 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). + */ +const composedChildren = (node: Node | null | undefined): Element[] => { + const out: Element[] = []; + if (!node) return out; + + if (node instanceof Document) { + if (node.documentElement) out.push(node.documentElement); + return out; + } + + if (node instanceof ShadowRoot || node instanceof DocumentFragment) { + out.push(...Array.from(node.children ?? [])); + return out; + } + + if (node instanceof Element) { + out.push(...Array.from(node.children ?? [])); + const open = node.shadowRoot; + if (open) out.push(...Array.from(open.children ?? [])); + const closed = getClosedRoot(node); + if (closed) out.push(...Array.from(closed.children ?? [])); + return out; + } + + return out; +}; + +/** + * Get all composed descendants of a node. + */ +const composedDescendants = (node: Node | null | undefined): Element[] => { + const out: Element[] = []; + const seen = new Set(); + const queue = [...composedChildren(node)]; + + while (queue.length) { + const next = queue.shift(); + if (!next || seen.has(next)) continue; + seen.add(next); + out.push(next); + queue.push(...composedChildren(next)); + } + + return out; +}; + +/** + * Resolve XPath with shadow DOM piercing support. + */ +const deepXPathQuery = ( + xpath: string, + pierceShadow: boolean, +): Element | null => { + const xp = normalizeXPath(xpath); + if (!xp) return null; + + const backdoor = window.__stagehandV3__; + + // Try fast path via piercer's resolveSimpleXPath first (handles shadow DOM) + if (pierceShadow) { + try { + if (backdoor && typeof backdoor.resolveSimpleXPath === "function") { + const fast = backdoor.resolveSimpleXPath(xp); + if (fast) return fast; + } + } catch { + // ignore and continue + } + } + + // Try native document.evaluate (works for light DOM elements) + try { + const result = document.evaluate( + xp, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue as Element | null; + if (result) return result; + } catch { + // XPath syntax error or evaluation failed, continue to fallback + } + + // If not piercing shadow DOM, we're done + if (!pierceShadow) { + return null; + } + + // Parse XPath into steps for composed tree traversal (shadow DOM piercing) + const steps = parseXPathSteps(xp); + if (!steps.length) { + return null; + } + + // Traverse composed tree following XPath steps + let current: Array = [ + document, + ]; + + for (const step of steps) { + const next: Element[] = []; + const seen = new Set(); + + for (const root of current) { + if (!root) continue; + const pool = + step.axis === "child" + ? composedChildren(root) + : 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; + }); + } + + if (step.index != null) { + const idx = step.index - 1; + const chosen = idx >= 0 && idx < matches.length ? matches[idx] : null; + if (chosen && !seen.has(chosen)) { + seen.add(chosen); + next.push(chosen); + } + } else { + for (const candidate of matches) { + if (!seen.has(candidate)) { + seen.add(candidate); + next.push(candidate); + } + } + } + } + + if (!next.length) return null; + current = next; + } + + return (current[0] as Element) ?? null; +}; + +/** + * Find element by selector (CSS or XPath) with optional shadow DOM piercing. + */ +const findElement = ( + selector: string, + pierceShadow: boolean, +): Element | null => { + if (isXPath(selector)) { + return deepXPathQuery(selector, pierceShadow); + } + return deepQuerySelector(document, selector, pierceShadow); +}; + +/** + * Check if element matches the desired state. + */ +const checkState = ( + el: Element | null, + state: WaitForSelectorState, +): boolean => { + if (state === "detached") return el === null; + if (state === "attached") return el !== null; + if (el === null) return false; + + if (state === "hidden") { + try { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return ( + style.display === "none" || + style.visibility === "hidden" || + style.opacity === "0" || + rect.width === 0 || + rect.height === 0 + ); + } catch { + return false; + } + } + + // state === "visible" + try { + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + style.opacity !== "0" && + rect.width > 0 && + rect.height > 0 + ); + } catch { + return false; + } +}; + +/** + * Set up MutationObservers on all shadow roots to detect changes. + */ +const setupShadowObservers = ( + callback: () => void, + observers: MutationObserver[], +): void => { + const seenRoots = new WeakSet(); + + const observeShadowRoots = (node: Element): void => { + const shadowRoot = getShadowRoot(node); + if (shadowRoot && !seenRoots.has(shadowRoot)) { + seenRoots.add(shadowRoot); + const shadowObserver = new MutationObserver(callback); + shadowObserver.observe(shadowRoot, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["style", "class", "hidden", "disabled"], + }); + observers.push(shadowObserver); + + // Recurse into shadow root children + for (const child of Array.from(shadowRoot.children)) { + observeShadowRoots(child); + } + } + + // Recurse into regular children + for (const child of Array.from(node.children)) { + observeShadowRoots(child); + } + }; + + const root = document.documentElement || document.body; + if (root) { + observeShadowRoots(root); + } +}; + +/** + * Wait for an element matching the selector to reach the specified state. + * Supports both CSS selectors and XPath expressions (prefix with "xpath=" or start with "/"). + * + * @param selectorRaw - CSS selector or XPath expression to wait for + * @param stateRaw - Element state: 'attached' | 'detached' | 'visible' | 'hidden' + * @param timeoutRaw - Maximum time to wait in milliseconds + * @param pierceShadowRaw - Whether to search inside shadow DOM + * @returns Promise that resolves to true when condition is met, or rejects on timeout + */ +export function waitForSelector( + selectorRaw: string, + stateRaw?: string, + timeoutRaw?: number, + pierceShadowRaw?: boolean, +): Promise { + const selector = String(selectorRaw ?? "").trim(); + const state = + (String(stateRaw ?? "visible") as WaitForSelectorState) || "visible"; + const timeout = + typeof timeoutRaw === "number" && timeoutRaw > 0 ? timeoutRaw : 30000; + const pierceShadow = pierceShadowRaw !== false; + + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | null = null; + let domReadyHandler: (() => void) | null = null; + let settled = false; + const clearTimer = (): void => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + // Check immediately + const el = findElement(selector, pierceShadow); + if (checkState(el, state)) { + settled = true; + resolve(true); + return; + } + + const observers: MutationObserver[] = []; + + const cleanup = (): void => { + for (const obs of observers) { + obs.disconnect(); + } + if (domReadyHandler) { + document.removeEventListener("DOMContentLoaded", domReadyHandler); + domReadyHandler = null; + } + }; + + const check = (): void => { + if (settled) return; + const el = findElement(selector, pierceShadow); + if (checkState(el, state)) { + settled = true; + clearTimer(); + cleanup(); + resolve(true); + } + }; + + // Handle case where document.body is not ready yet + const observeRoot = document.body || document.documentElement; + if (!observeRoot) { + domReadyHandler = (): void => { + document.removeEventListener("DOMContentLoaded", domReadyHandler!); + domReadyHandler = null; + check(); + setupObservers(); + }; + document.addEventListener("DOMContentLoaded", domReadyHandler); + timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + clearTimer(); + cleanup(); + reject( + new Error( + `waitForSelector: Timeout ${timeout}ms exceeded waiting for "${selector}" to be ${state}`, + ), + ); + }, timeout); + return; + } + + const setupObservers = (): void => { + const root = document.body || document.documentElement; + if (!root) return; + + // Main document observer + const mainObserver = new MutationObserver(check); + mainObserver.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["style", "class", "hidden", "disabled"], + }); + observers.push(mainObserver); + + // Shadow DOM observers (if piercing) + if (pierceShadow) { + setupShadowObservers(check, observers); + } + }; + + setupObservers(); + + // Set up timeout + timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + clearTimer(); + cleanup(); + reject( + new Error( + `waitForSelector: Timeout ${timeout}ms exceeded waiting for "${selector}" to be ${state}`, + ), + ); + }, timeout); + }); +} diff --git a/packages/core/lib/v3/tests/wait-for-selector.spec.ts b/packages/core/lib/v3/tests/wait-for-selector.spec.ts new file mode 100644 index 000000000..d3bf5a377 --- /dev/null +++ b/packages/core/lib/v3/tests/wait-for-selector.spec.ts @@ -0,0 +1,896 @@ +import { expect, test } from "@playwright/test"; +import { V3 } from "../v3"; +import { v3DynamicTestConfig } from "./v3.dynamic.config"; + +test.describe("Page.waitForSelector tests", () => { + let v3: V3; + + test.beforeEach(async () => { + v3 = new V3(v3DynamicTestConfig); + await v3.init(); + }); + + test.afterEach(async () => { + await v3?.close?.().catch(() => {}); + }); + + test.describe("Basic state tests", () => { + test("resolves when element is already visible", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent(''), + ); + + const result = await page.waitForSelector("#submit-btn"); + expect(result).toBe(true); + }); + + test("resolves when element appears after delay", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "
" + + "", + ), + ); + + const result = await page.waitForSelector("#delayed-btn", { + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("state 'attached' resolves for hidden elements", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '', + ), + ); + + const result = await page.waitForSelector("#hidden-div", { + state: "attached", + }); + expect(result).toBe(true); + }); + + test("state 'visible' waits for element to become visible", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '' + + "", + ), + ); + + const result = await page.waitForSelector("#show-later", { + state: "visible", + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("state 'hidden' waits for element to become hidden", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
Will Hide
' + + "", + ), + ); + + const result = await page.waitForSelector("#hide-later", { + state: "hidden", + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("state 'detached' waits for element to be removed", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
Will Be Removed
' + + "", + ), + ); + + const result = await page.waitForSelector("#remove-me", { + state: "detached", + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("state 'detached' resolves immediately for non-existent element", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + encodeURIComponent("
Content
"), + ); + + const result = await page.waitForSelector("#does-not-exist", { + state: "detached", + timeout: 1000, + }); + expect(result).toBe(true); + }); + }); + + test.describe("Timeout behavior", () => { + test("throws on timeout when element never appears", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + encodeURIComponent("
No button here
"), + ); + + let error: Error | null = null; + try { + await page.waitForSelector("#nonexistent", { timeout: 300 }); + } catch (e) { + error = e as Error; + } + + expect(error).not.toBeNull(); + expect(error?.message).toContain("Timeout"); + expect(error?.message).toContain("#nonexistent"); + }); + + test("respects custom timeout duration", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + encodeURIComponent("
Content
"), + ); + + const startTime = Date.now(); + try { + await page.waitForSelector("#nonexistent", { timeout: 500 }); + } catch { + // Expected to timeout + } + const elapsed = Date.now() - startTime; + + // Should timeout around 500ms (allow some margin) + expect(elapsed).toBeGreaterThanOrEqual(450); + expect(elapsed).toBeLessThan(1000); + }); + }); + + test.describe("CSS selector variants", () => { + test("handles complex CSS selectors", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + '
' + + '' + + "
" + + "
", + ), + ); + + const result = await page.waitForSelector( + ".container #login-form button[type='submit']", + ); + expect(result).toBe(true); + }); + }); + + test.describe("Open shadow DOM", () => { + test("finds element inside open shadow DOM with pierceShadow: true", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector("#shadow-btn", { + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("does NOT find shadow DOM element with pierceShadow: false", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + let error: Error | null = null; + try { + await page.waitForSelector("#shadow-only-btn", { + pierceShadow: false, + timeout: 300, + }); + } catch (e) { + error = e as Error; + } + + expect(error).not.toBeNull(); + expect(error?.message).toContain("Timeout"); + }); + + test("finds element in nested open shadow DOM", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector("#deep-element", { + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + }); + + test.describe("Closed shadow DOM (via piercer)", () => { + test("finds element inside closed shadow DOM via custom element", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "" + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + // The piercer hooks attachShadow and stores closed shadow roots + const result = await page.waitForSelector("#closed-btn", { + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("finds element in nested closed shadow DOM", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "" + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector("#deeply-closed", { + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("finds element in mixed open/closed nested shadow DOM", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector("#mixed-deep-btn", { + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("waits for element to appear inside closed shadow DOM", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "" + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + + const result = await page.waitForSelector("#delayed-closed-btn", { + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + }); + + test.describe("XPath selectors", () => { + test("finds element with basic XPath", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent(''), + ); + + const result = await page.waitForSelector("//button[@id='xpath-btn']", { + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("finds element with xpath= prefix", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
Target
', + ), + ); + + const result = await page.waitForSelector( + "xpath=//span[@class='target']", + { + timeout: 5000, + }, + ); + expect(result).toBe(true); + }); + + test("waits for element to appear with XPath", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "
" + + "", + ), + ); + + const result = await page.waitForSelector("//span[@id='delayed-xpath']", { + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("finds element in open shadow DOM with XPath", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector( + "//button[@id='shadow-xpath-btn']", + { + pierceShadow: true, + timeout: 5000, + }, + ); + expect(result).toBe(true); + }); + + test("finds element in closed shadow DOM with XPath", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "" + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector( + "//span[@id='xpath-closed-target']", + { + pierceShadow: true, + timeout: 5000, + }, + ); + expect(result).toBe(true); + }); + }); + + test.describe("Iframe hop notation (>>)", () => { + test("finds element inside single iframe", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '' + + '' + + "", + ), + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector( + "iframe#my-frame >> #frame-btn", + { + timeout: 5000, + }, + ); + expect(result).toBe(true); + }); + + test("finds element through multiple iframe hops", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '' + + "", + ), + ); + await page.waitForTimeout(300); + + const result = await page.waitForSelector( + "iframe#outer-frame >> iframe#inner-frame >> #nested-content", + { timeout: 5000 }, + ); + expect(result).toBe(true); + }); + + test("waits for element to appear inside iframe", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '' + + "", + ), + ); + + const result = await page.waitForSelector( + "iframe#delay-frame >> #delayed-in-frame", + { + timeout: 5000, + }, + ); + expect(result).toBe(true); + }); + }); + + test.describe("Visibility edge cases", () => { + test("visibility: hidden is not visible", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '', + ), + ); + + // Should be attached but not visible + const attached = await page.waitForSelector("#vis-hidden", { + state: "attached", + }); + expect(attached).toBe(true); + + let error: Error | null = null; + try { + await page.waitForSelector("#vis-hidden", { + state: "visible", + timeout: 200, + }); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + }); + + test("opacity: 0 is not visible", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
Transparent
', + ), + ); + + const attached = await page.waitForSelector("#transparent", { + state: "attached", + }); + expect(attached).toBe(true); + + let error: Error | null = null; + try { + await page.waitForSelector("#transparent", { + state: "visible", + timeout: 200, + }); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + }); + + test("zero dimensions is not visible", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
Zero
', + ), + ); + + const attached = await page.waitForSelector("#zero-size", { + state: "attached", + }); + expect(attached).toBe(true); + + let error: Error | null = null; + try { + await page.waitForSelector("#zero-size", { + state: "visible", + timeout: 200, + }); + } catch (e) { + error = e as Error; + } + expect(error).not.toBeNull(); + }); + + test("detects visibility change via class toggle", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "" + + '' + + "", + ), + ); + + const result = await page.waitForSelector("#class-toggle", { + state: "visible", + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("detects visibility change via style attribute", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '' + + "", + ), + ); + + const result = await page.waitForSelector("#style-toggle", { + state: "visible", + timeout: 5000, + }); + expect(result).toBe(true); + }); + }); + + test.describe("Dynamic DOM scenarios", () => { + test("handles rapid DOM mutations", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + "
" + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + // Small delay to ensure script starts + await page.waitForTimeout(50); + + const result = await page.waitForSelector("#item-7", { timeout: 10000 }); + expect(result).toBe(true); + }); + + test("handles element removed and re-added", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
Toggle
' + + "", + ), + ); + + // First wait for detached + const detached = await page.waitForSelector("#toggle-me", { + state: "detached", + timeout: 5000, + }); + expect(detached).toBe(true); + + // Then wait for visible again + const visible = await page.waitForSelector("#toggle-me", { + state: "visible", + timeout: 5000, + }); + expect(visible).toBe(true); + }); + + test("handles dynamically replaced innerHTML", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
Loading...
' + + "", + ), + ); + + const result = await page.waitForSelector("#loaded-btn", { + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("handles element created via insertAdjacentHTML", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + ); + + const result = await page.waitForSelector("#inserted", { timeout: 5000 }); + expect(result).toBe(true); + }); + }); + + test.describe("Shadow DOM visibility changes", () => { + test("detects element becoming visible inside open shadow DOM", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + + const result = await page.waitForSelector("#shadow-btn", { + state: "visible", + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + + test("detects element becoming hidden inside shadow DOM", async () => { + const page = v3.context.pages()[0]; + await page.goto( + "data:text/html," + + encodeURIComponent( + '
' + + "", + ), + { waitUntil: "load", timeoutMs: 30000 }, + ); + await page.waitForTimeout(100); + + const result = await page.waitForSelector("#hide-shadow-btn", { + state: "hidden", + pierceShadow: true, + timeout: 5000, + }); + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/core/lib/v3/understudy/deepLocator.ts b/packages/core/lib/v3/understudy/deepLocator.ts index 1e8566b52..878eb7911 100644 --- a/packages/core/lib/v3/understudy/deepLocator.ts +++ b/packages/core/lib/v3/understudy/deepLocator.ts @@ -13,6 +13,11 @@ const IFRAME_STEP_RE = /^iframe(?:\[\d+])?$/i; type Axis = "child" | "desc"; type Step = { axis: Axis; raw: string; name: string }; +export type ResolvedLocatorTarget = { + frame: Frame; + selector: string; +}; + /** Parse XPath into steps preserving '/' vs '//' and the raw token (with [n]) */ function parseXPath(path: string): Step[] { const s = path.trim(); @@ -54,60 +59,19 @@ export async function deepLocatorThroughIframes( root: Frame, xpathOrSelector: string, ): Promise { - let path = xpathOrSelector.trim(); - if (path.startsWith("xpath=")) path = path.slice("xpath=".length).trim(); - if (!path.startsWith("/")) path = "/" + path; - - const steps = parseXPath(path); - let fl: FrameLocator | undefined; - let buf: Step[] = []; - - const flushIntoFrameLocator = () => { - if (!buf.length) return; - const selectorForIframe = "xpath=" + buildXPathFromSteps(buf); - v3Logger({ - category: "deep-hop", - message: "resolving iframe via FrameLocator", - level: 2, - auxiliary: { - selectorForIframe: { value: selectorForIframe, type: "string" }, - rootFrameId: { value: String(root.frameId), type: "string" }, - }, - }); - fl = fl - ? fl.frameLocator(selectorForIframe) - : frameLocatorFromFrame(page, root, selectorForIframe); - buf = []; - }; - - for (const st of steps) { - buf.push(st); - if (IFRAME_STEP_RE.test(st.name)) flushIntoFrameLocator(); - } - - const finalSelector = "xpath=" + buildXPathFromSteps(buf); - const targetFrame = fl ? await fl.resolveFrame() : root; - v3Logger({ - category: "deep-hop", - message: "final tail", - level: 2, - auxiliary: { - frameId: { value: String(targetFrame.frameId), type: "string" }, - finalSelector: { value: finalSelector, type: "string" }, - }, - }); - return new Locator(targetFrame, finalSelector); + const target = await resolveDeepXPathTarget(page, root, xpathOrSelector); + return new Locator(target.frame, target.selector); } /** * Unified resolver that supports '>>' hop notation, deep XPath across iframes, * and plain single-frame selectors. Keeps hop logic in one shared place. */ -export async function resolveLocatorWithHops( +export async function resolveLocatorTarget( page: Page, root: Frame, selectorRaw: string, -): Promise { +): Promise { const sel = selectorRaw.trim(); const parts = sel .split(">>") @@ -121,13 +85,24 @@ export async function resolveLocatorWithHops( fl = fl.frameLocator(parts[i]!); } const targetFrame = await fl.resolveFrame(); - return new Locator(targetFrame, parts[parts.length - 1]!); + return { frame: targetFrame, selector: parts[parts.length - 1]! }; } // No hops — delegate to XPath-aware deep resolver when needed const isXPath = sel.startsWith("xpath=") || sel.startsWith("/"); - if (isXPath) return deepLocatorThroughIframes(page, root, sel); - return new Locator(root, sel); + if (isXPath) { + return resolveDeepXPathTarget(page, root, sel); + } + return { frame: root, selector: sel }; +} + +export async function resolveLocatorWithHops( + page: Page, + root: Frame, + selectorRaw: string, +): Promise { + const target = await resolveLocatorTarget(page, root, selectorRaw); + return new Locator(target.frame, target.selector); } /** @@ -266,3 +241,53 @@ export function deepLocatorFromPage( ): DeepLocatorDelegate { return new DeepLocatorDelegate(page, root, selector); } + +async function resolveDeepXPathTarget( + page: Page, + root: Frame, + xpathOrSelector: string, +): Promise { + let path = xpathOrSelector.trim(); + if (path.startsWith("xpath=")) path = path.slice("xpath=".length).trim(); + if (!path.startsWith("/")) path = "/" + path; + + const steps = parseXPath(path); + let fl: FrameLocator | undefined; + let buf: Step[] = []; + + const flushIntoFrameLocator = () => { + if (!buf.length) return; + const selectorForIframe = "xpath=" + buildXPathFromSteps(buf); + v3Logger({ + category: "deep-hop", + message: "resolving iframe via FrameLocator", + level: 2, + auxiliary: { + selectorForIframe: { value: selectorForIframe, type: "string" }, + rootFrameId: { value: String(root.frameId), type: "string" }, + }, + }); + fl = fl + ? fl.frameLocator(selectorForIframe) + : frameLocatorFromFrame(page, root, selectorForIframe); + buf = []; + }; + + for (const st of steps) { + buf.push(st); + if (IFRAME_STEP_RE.test(st.name)) flushIntoFrameLocator(); + } + + const finalSelector = "xpath=" + buildXPathFromSteps(buf); + const targetFrame = fl ? await fl.resolveFrame() : root; + v3Logger({ + category: "deep-hop", + message: "final tail", + level: 2, + auxiliary: { + frameId: { value: String(targetFrame.frameId), type: "string" }, + finalSelector: { value: finalSelector, type: "string" }, + }, + }); + return { frame: targetFrame, selector: finalSelector }; +} diff --git a/packages/core/lib/v3/understudy/page.ts b/packages/core/lib/v3/understudy/page.ts index 6943583b4..6a74da8be 100644 --- a/packages/core/lib/v3/understudy/page.ts +++ b/packages/core/lib/v3/understudy/page.ts @@ -6,7 +6,7 @@ import type { CDPSessionLike } from "./cdp"; import { CdpConnection } from "./cdp"; import { Frame } from "./frame"; import { FrameLocator } from "./frameLocator"; -import { deepLocatorFromPage } from "./deepLocator"; +import { deepLocatorFromPage, resolveLocatorTarget } from "./deepLocator"; import { resolveXpathForLocation } from "./a11y/snapshot"; import { FrameRegistry } from "./frameRegistry"; import { executionContexts } from "./executionContextRegistry"; @@ -24,6 +24,7 @@ import { StagehandEvalError, } from "../types/public/sdkErrors"; import { normalizeInitScriptSource } from "./initScripts"; +import { buildLocatorInvocation } from "./locatorInvocation"; import type { ScreenshotAnimationsOption, ScreenshotCaretOption, @@ -44,6 +45,7 @@ import { type ScreenshotCleanup, } from "./screenshotUtils"; import { InitScriptSource } from "../types/private"; + /** * Page * @@ -1190,6 +1192,47 @@ export class Page { return new Promise((resolve) => setTimeout(resolve, ms)); } + /** + * Wait for an element matching the selector to appear in the DOM. + * Uses MutationObserver for efficiency + * Pierces shadow DOM by default. + * Supports iframe hop notation with '>>' (e.g., 'iframe#checkout >> .submit-btn'). + * + * @param selector CSS selector to wait for (supports '>>' for iframe hops) + * @param options.state Element state to wait for: 'attached' | 'detached' | 'visible' | 'hidden' (default: 'visible') + * @param options.timeout Maximum time to wait in milliseconds (default: 30000) + * @param options.pierceShadow Whether to search inside shadow DOM (default: true) + * @returns True when the condition is met + * @throws Error if timeout is reached before the condition is met + */ + @logAction("Page.waitForSelector") + async waitForSelector( + selector: string, + options?: { + state?: "attached" | "detached" | "visible" | "hidden"; + timeout?: number; + pierceShadow?: boolean; + }, + ): Promise { + const timeout = options?.timeout ?? 30000; + const state = options?.state ?? "visible"; + const pierceShadow = options?.pierceShadow ?? true; + const startTime = Date.now(); + const root = this.mainFrameWrapper; + const { frame: targetFrame, selector: finalSelector } = + await resolveLocatorTarget(this, root, selector); + const elapsed = Date.now() - startTime; + const remainingTimeout = Math.max(0, timeout - elapsed); + + const expression = buildLocatorInvocation("waitForSelector", [ + JSON.stringify(finalSelector), + JSON.stringify(state), + String(remainingTimeout), + String(pierceShadow), + ]); + return targetFrame.evaluate(expression); + } + /** * Evaluate a function or expression in the current main frame's main world. * - If a string is provided, it is treated as a JS expression.