@browserbasehq/stagehand
Version:
Browserbase's SDK for building browser agents
1,171 lines (1,170 loc) • 36.4 kB
JavaScript
//#region dom/locatorScripts/xpathParser.ts
/**
* 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"]`)
* - Attribute existence (`[@attr]`)
* - Attribute contains/starts-with (`contains(@attr,'v')`, `starts-with(@attr,'v')`)
* - Text equality/contains (`[text()='v']`, `[contains(text(),'v')]`, `[.='v']`),
* where `text()` reads direct child text nodes and `.` the whole subtree
* - normalize-space on text/attributes (`[normalize-space(text())='v']`)
* - Basic boolean predicates (`and`, `or`, `not(...)`)
* - Multiple predicates per step (`[@class='foo'][2]`)
* - Optional `xpath=` prefix
*
* Not supported:
* - 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.
*/
function parseXPathSteps(input) {
const path = String(input || "").trim().replace(/^xpath=/i, "");
if (!path) return [];
const steps = [];
let i = 0;
while (i < path.length) {
let axis = "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 = 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, predicates } = parseStep(rawStep);
steps.push({
axis,
tag,
predicates
});
}
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) {
const results = [];
let i = 0;
while (i < str.length) {
if (str[i] !== "[") {
i++;
continue;
}
i++;
const start = i;
let quote = 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++;
}
return results;
}
function parseStep(raw) {
const bracketPos = raw.indexOf("[");
if (bracketPos === -1) return {
tag: raw === "" ? "*" : raw.toLowerCase(),
predicates: []
};
const tagPart = raw.slice(0, bracketPos).trim();
const tag = tagPart === "" ? "*" : tagPart.toLowerCase();
const predicateStr = raw.slice(bracketPos);
const predicates = [];
for (const inner of extractPredicates(predicateStr)) {
const parsed = parsePredicateExpression(inner);
if (parsed) predicates.push(parsed);
}
return {
tag,
predicates
};
}
function parsePredicateExpression(input) {
const trimmed = input.trim();
if (!trimmed) return null;
const orParts = splitTopLevel(trimmed, "or");
if (orParts.length > 1) {
const preds = orParts.map((part) => parsePredicateExpression(part)).filter(Boolean);
if (preds.length !== orParts.length) return null;
return {
type: "or",
predicates: preds
};
}
const andParts = splitTopLevel(trimmed, "and");
if (andParts.length > 1) {
const preds = andParts.map((part) => parsePredicateExpression(part)).filter(Boolean);
if (preds.length !== andParts.length) return null;
return {
type: "and",
predicates: preds
};
}
const notInner = unwrapFunctionCall(trimmed, "not");
if (notInner != null) {
const predicate = parsePredicateExpression(notInner);
return predicate ? {
type: "not",
predicate
} : null;
}
return parseAtomicPredicate(trimmed);
}
function parseAtomicPredicate(input) {
const valueMatch = /^(?:'([^']*)'|"([^"]*)")$/;
const attrName = "[a-zA-Z_][\\w.-]*";
const quoted = "(?:'([^']*)'|\"([^\"]*)\")";
if (/^\d+$/.test(input)) return {
type: "index",
index: Math.max(1, Number(input))
};
const normalizeAttrMatch = input.match(new RegExp(`^normalize-space\\(\\s*@(${attrName})\\s*\\)\\s*=\\s*${quoted}$`));
if (normalizeAttrMatch) return {
type: "attrEquals",
name: normalizeAttrMatch[1],
value: normalizeAttrMatch[2] ?? normalizeAttrMatch[3] ?? "",
normalize: true
};
const normalizeTextMatch = input.match(new RegExp(`^normalize-space\\(\\s*(text\\(\\)|\\.)\\s*\\)\\s*=\\s*${quoted}$`));
if (normalizeTextMatch) return {
type: "textEquals",
value: normalizeTextMatch[2] ?? normalizeTextMatch[3] ?? "",
normalize: true,
source: textSource(normalizeTextMatch[1])
};
const attrEqualsMatch = input.match(new RegExp(`^@(${attrName})\\s*=\\s*${quoted}$`));
if (attrEqualsMatch) return {
type: "attrEquals",
name: attrEqualsMatch[1],
value: attrEqualsMatch[2] ?? attrEqualsMatch[3] ?? ""
};
const attrExistsMatch = input.match(new RegExp(`^@(${attrName})$`));
if (attrExistsMatch) return {
type: "attrExists",
name: attrExistsMatch[1]
};
const attrContainsMatch = input.match(new RegExp(`^contains\\(\\s*@(${attrName})\\s*,\\s*${quoted}\\s*\\)$`));
if (attrContainsMatch) return {
type: "attrContains",
name: attrContainsMatch[1],
value: attrContainsMatch[2] ?? attrContainsMatch[3] ?? ""
};
const attrStartsMatch = input.match(new RegExp(`^starts-with\\(\\s*@(${attrName})\\s*,\\s*${quoted}\\s*\\)$`));
if (attrStartsMatch) return {
type: "attrStartsWith",
name: attrStartsMatch[1],
value: attrStartsMatch[2] ?? attrStartsMatch[3] ?? ""
};
const textEqualsMatch = input.match(new RegExp(`^(text\\(\\)|\\.)\\s*=\\s*${quoted}$`));
if (textEqualsMatch) return {
type: "textEquals",
value: textEqualsMatch[2] ?? textEqualsMatch[3] ?? "",
source: textSource(textEqualsMatch[1])
};
const textContainsMatch = input.match(new RegExp(`^contains\\(\\s*(text\\(\\)|\\.)\\s*,\\s*${quoted}\\s*\\)$`));
if (textContainsMatch) return {
type: "textContains",
value: textContainsMatch[2] ?? textContainsMatch[3] ?? "",
source: textSource(textContainsMatch[1])
};
if (valueMatch.test(input)) return null;
return null;
}
function splitTopLevel(input, keyword) {
const parts = [];
let start = 0;
let depth = 0;
let quote = null;
let i = 0;
while (i < input.length) {
const ch = input[i];
if (quote) {
if (ch === quote) quote = null;
i += 1;
continue;
}
if (ch === "'" || ch === "\"") {
quote = ch;
i += 1;
continue;
}
if (ch === "(") {
depth += 1;
i += 1;
continue;
}
if (ch === ")") {
depth = Math.max(0, depth - 1);
i += 1;
continue;
}
if (depth === 0 && isKeywordAt(input, i, keyword)) {
parts.push(input.slice(start, i).trim());
i += keyword.length;
start = i;
continue;
}
i += 1;
}
parts.push(input.slice(start).trim());
return parts.filter((part) => part.length > 0);
}
function isKeywordAt(input, index, keyword) {
if (!input.startsWith(keyword, index)) return false;
const before = index > 0 ? input[index - 1] : " ";
if (before === "@") return false;
const after = index + keyword.length < input.length ? input[index + keyword.length] : " ";
return isBoundary(before) && isBoundary(after);
}
function isBoundary(ch) {
return !/[a-zA-Z0-9_.-]/.test(ch);
}
function unwrapFunctionCall(input, name) {
const prefix = `${name}(`;
if (!input.startsWith(prefix) || !input.endsWith(")")) return null;
const inner = input.slice(prefix.length, -1);
return hasBalancedParens(inner) ? inner : null;
}
function hasBalancedParens(input) {
let depth = 0;
let quote = null;
for (let i = 0; i < input.length; i += 1) {
const ch = input[i];
if (quote) {
if (ch === quote) quote = null;
continue;
}
if (ch === "'" || ch === "\"") {
quote = ch;
continue;
}
if (ch === "(") depth += 1;
else if (ch === ")") depth -= 1;
if (depth < 0) return false;
}
return depth === 0;
}
var normalizeSpace = (value) => value.replace(/\s+/g, " ").trim();
var TEXT_NODE = 3;
function textSource(token) {
return token === "text()" ? "text" : "self";
}
function textValue(element) {
return String(element.textContent ?? "");
}
/** The node-set `text()` selects: direct child text nodes, in document order. */
function childTextValues(element) {
const values = [];
for (const node of Array.from(element.childNodes)) if (node.nodeType === TEXT_NODE) values.push(String(node.nodeValue ?? ""));
return values;
}
/** `string(node-set)` is the string-value of its first node, or `""` when empty. */
function firstChildTextValue(element) {
return childTextValues(element)[0] ?? "";
}
function normalizeMaybe(value, normalize) {
return normalize ? normalizeSpace(value) : value;
}
function evaluatePredicate(element, predicate) {
switch (predicate.type) {
case "and": return predicate.predicates.every((p) => evaluatePredicate(element, p));
case "or": return predicate.predicates.some((p) => evaluatePredicate(element, p));
case "not": return !evaluatePredicate(element, predicate.predicate);
case "attrExists": return element.getAttribute(predicate.name) !== null;
case "attrEquals": {
const attr = element.getAttribute(predicate.name);
if (attr === null) return false;
return normalizeMaybe(attr, predicate.normalize) === normalizeMaybe(predicate.value, predicate.normalize);
}
case "attrContains": {
const attr = element.getAttribute(predicate.name);
if (attr === null) return false;
return normalizeMaybe(attr, predicate.normalize).includes(normalizeMaybe(predicate.value, predicate.normalize));
}
case "attrStartsWith": {
const attr = element.getAttribute(predicate.name);
if (attr === null) return false;
return normalizeMaybe(attr, predicate.normalize).startsWith(normalizeMaybe(predicate.value, predicate.normalize));
}
case "textEquals": {
const target = normalizeMaybe(predicate.value, predicate.normalize);
if (predicate.source === "text") return predicate.normalize ? normalizeSpace(firstChildTextValue(element)) === target : childTextValues(element).some((value) => value === target);
return normalizeMaybe(textValue(element), predicate.normalize) === target;
}
case "textContains": return normalizeMaybe(predicate.source === "text" ? firstChildTextValue(element) : textValue(element), predicate.normalize).includes(normalizeMaybe(predicate.value, predicate.normalize));
case "index": return true;
default: return true;
}
}
function applyPredicates(elements, predicates) {
let current = elements;
for (const predicate of predicates) {
if (!current.length) return [];
if (predicate.type === "index") {
const idx = predicate.index - 1;
current = idx >= 0 && idx < current.length ? [current[idx]] : [];
continue;
}
current = current.filter((el) => evaluatePredicate(el, predicate));
}
return current;
}
//#endregion
//#region dom/locatorScripts/shadowRoots.ts
function getOpenOrClosedShadowRoot(element) {
try {
if (element instanceof HTMLElement) {
const root = globalThis.chrome?.dom?.openOrClosedShadowRoot(element);
if (root) return root;
}
} catch {}
try {
return element.shadowRoot ?? null;
} catch {
return null;
}
}
function documentHasShadowRoot() {
try {
const walker = document.createTreeWalker(document, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) if (getOpenOrClosedShadowRoot(walker.currentNode)) return true;
} catch {}
return false;
}
//#endregion
//#region dom/locatorScripts/xpathResolver.ts
var normalizeXPath = (selector) => {
const raw = String(selector ?? "").trim();
if (!raw) return "";
return raw.replace(/^xpath=/i, "").trim();
};
function resolveXPathFirst(rawXp, options) {
return resolveXPathAtIndex(rawXp, 0, options);
}
function resolveXPathAtIndex(rawXp, index, options) {
if (!Number.isFinite(index) || index < 0) return null;
const xp = normalizeXPath(rawXp);
if (!xp) return null;
const targetIndex = Math.floor(index);
const pierceShadow = options?.pierceShadow !== false;
const shadowCtx = pierceShadow ? getShadowContext() : null;
if (!pierceShadow) return resolveNativeAtIndexWithError(xp, targetIndex).value;
if (!shadowCtx?.hasShadow) {
const native = resolveNativeAtIndexWithError(xp, targetIndex);
if (!native.error) return native.value;
return resolveXPathComposedMatches(xp, shadowCtx?.getShadowRoot)[targetIndex] ?? null;
}
const shadowHopMatches = resolveStagehandShadowHopMatches(xp, shadowCtx.getShadowRoot);
if (shadowHopMatches.length > 0) return shadowHopMatches[targetIndex] ?? null;
return resolveXPathComposedMatches(xp, shadowCtx.getShadowRoot)[targetIndex] ?? null;
}
function countXPathMatches(rawXp, options) {
const xp = normalizeXPath(rawXp);
if (!xp) return 0;
const pierceShadow = options?.pierceShadow !== false;
const shadowCtx = pierceShadow ? getShadowContext() : null;
if (!pierceShadow) return resolveNativeCountWithError(xp).count;
if (!shadowCtx?.hasShadow) {
const count = resolveNativeCountWithError(xp);
if (!count.error) return count.count;
return resolveXPathComposedMatches(xp, shadowCtx?.getShadowRoot).length;
}
const shadowHopCount = resolveStagehandShadowHopMatches(xp, shadowCtx.getShadowRoot).length;
if (shadowHopCount > 0) return shadowHopCount;
return resolveXPathComposedMatches(xp, shadowCtx.getShadowRoot).length;
}
function resolveXPathComposedMatches(rawXp, getShadowRoot) {
const xp = normalizeXPath(rawXp);
if (!xp) return [];
const steps = parseXPathSteps(xp);
if (!steps.length) return [];
const shadowRootGetter = getShadowRoot ?? null;
let current = [document];
for (const step of steps) {
const next = [];
const seen = /* @__PURE__ */ new Set();
for (const root of current) {
const pool = step.axis === "child" ? composedChildren(root, shadowRootGetter) : composedDescendants(root, shadowRootGetter);
if (!pool.length) continue;
const matches = applyPredicates(pool.filter((candidate) => matchesTag(candidate, step)), step.predicates);
for (const candidate of matches) if (!seen.has(candidate)) {
seen.add(candidate);
next.push(candidate);
}
}
if (!next.length) return [];
current = next;
}
return current;
}
function resolveStagehandShadowHopMatches(rawXp, getShadowRoot) {
const xp = normalizeXPath(rawXp);
if (!xp) return [];
const steps = parseXPathSteps(xp);
if (!steps.some((step, index) => step.axis === "desc" && index > 0)) return [];
const shadowRootGetter = getShadowRoot ?? null;
let current = [document];
for (let i = 0; i < steps.length; i += 1) {
const step = steps[i];
const next = [];
const seen = /* @__PURE__ */ new Set();
for (const root of current) {
const pool = step.axis === "child" ? domChildren(root) : i === 0 ? composedDescendants(root, shadowRootGetter) : shadowRootChildren(root, shadowRootGetter);
if (!pool.length) continue;
const matches = applyPredicates(pool.filter((candidate) => matchesTag(candidate, step)), step.predicates);
for (const candidate of matches) if (!seen.has(candidate)) {
seen.add(candidate);
next.push(candidate);
}
}
if (!next.length) return [];
current = next;
}
return current;
}
function matchesTag(element, step) {
if (step.tag === "*") return true;
return element.localName === step.tag;
}
function getShadowContext() {
return {
getShadowRoot: getOpenOrClosedShadowRoot,
hasShadow: documentHasShadowRoot()
};
}
function composedChildren(node, getShadowRoot) {
const 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 shadowRoot = getShadowRoot?.(node) ?? getOpenOrClosedShadowRoot(node);
if (shadowRoot) out.push(...Array.from(shadowRoot.children ?? []));
return out;
}
return out;
}
function domChildren(node) {
const 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 ?? []));
return out;
}
return out;
}
function shadowRootChildren(node, getShadowRoot) {
const out = [];
if (!(node instanceof Element)) return out;
const shadowRoot = getShadowRoot?.(node) ?? getOpenOrClosedShadowRoot(node);
if (shadowRoot) out.push(...Array.from(shadowRoot.children ?? []));
return out;
}
function composedDescendants(node, getShadowRoot) {
const out = [];
const seen = /* @__PURE__ */ new Set();
const stack = [...composedChildren(node, getShadowRoot)].reverse();
while (stack.length) {
const next = stack.pop();
if (seen.has(next)) continue;
seen.add(next);
out.push(next);
const children = composedChildren(next, getShadowRoot);
for (let i = children.length - 1; i >= 0; i -= 1) stack.push(children[i]);
}
return out;
}
function resolveNativeAtIndexWithError(xp, index) {
try {
return {
value: document.evaluate(xp, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotItem(index),
error: false
};
} catch {
return {
value: null,
error: true
};
}
}
function resolveNativeCountWithError(xp) {
try {
return {
count: document.evaluate(xp, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null).snapshotLength,
error: false
};
} catch {
return {
count: 0,
error: true
};
}
}
//#endregion
//#region dom/locatorScripts/counts.ts
function countCssMatchesPrimary(selectorRaw) {
const selector = String(selectorRaw ?? "").trim();
if (!selector) return 0;
const seen = /* @__PURE__ */ new WeakSet();
const visit = (root) => {
if (!root || seen.has(root)) return 0;
seen.add(root);
let total = 0;
try {
const queryable = root;
if (typeof queryable.querySelectorAll === "function") total += queryable.querySelectorAll(selector).length;
} catch {}
try {
const walker = (root instanceof Document ? root : root?.ownerDocument ?? document).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node;
while (node = walker.nextNode()) if (node instanceof Element) {
const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot) total += visit(shadowRoot);
}
} catch {}
return total;
};
try {
return visit(document);
} catch {
try {
return document.querySelectorAll(selector).length;
} catch {
return 0;
}
}
}
function countTextMatches(rawNeedle) {
const needle = String(rawNeedle ?? "");
if (!needle) return {
count: 0,
sample: [],
error: null
};
const needleLc = needle.toLowerCase();
const skipTags = /* @__PURE__ */ new Set([
"SCRIPT",
"STYLE",
"TEMPLATE",
"NOSCRIPT",
"HEAD",
"TITLE",
"LINK",
"META",
"HTML",
"BODY"
]);
const shouldSkip = (node) => {
if (!node) return false;
const tag = node.tagName?.toUpperCase() ?? "";
return skipTags.has(tag);
};
const extractText = (element) => {
try {
if (shouldSkip(element)) return "";
const inner = element.innerText;
if (typeof inner === "string" && inner.trim()) return inner.trim();
} catch {}
try {
const text = element.textContent;
if (typeof text === "string") return text.trim();
} catch {}
return "";
};
const matches = (element) => {
const text = extractText(element);
return !!text && text.toLowerCase().includes(needleLc);
};
const seen = /* @__PURE__ */ new WeakSet();
const queue = [];
const enqueue = (node) => {
if (!node || seen.has(node)) return;
seen.add(node);
queue.push(node);
};
const walkerFor = (root) => {
try {
return (root instanceof Document ? root : root?.ownerDocument ?? document).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
} catch {
return null;
}
};
const matchesList = [];
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.className ?? "",
text: extractText(root)
});
const walker = walkerFor(root);
if (!walker) continue;
let node;
while (node = walker.nextNode()) {
if (!(node instanceof Element)) continue;
if (matches(node)) matchesList.push({
element: node,
tag: node.tagName ?? "",
id: node.id ?? "",
className: node.className ?? "",
text: extractText(node)
});
const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot) enqueue(shadowRoot);
}
}
const innermost = [];
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 {}
}
if (!skip) innermost.push(item);
}
return {
count: innermost.length,
sample: innermost.slice(0, 5).map((item) => ({
tag: item.tag,
id: item.id,
class: item.className,
text: item.text
})),
error: null
};
}
function countXPathMatchesMainWorld(rawXp) {
return countXPathMatches(rawXp, { pierceShadow: true });
}
//#endregion
//#region dom/locatorScripts/cursorOverlay.ts
var CURSOR_OVERLAY_ID = "__v3_cursor_overlay__";
var cursorElement = null;
var pendingPosition = null;
var installScheduled = false;
function positionCursor(element, x, y) {
element.style.left = `${Math.max(0, x)}px`;
element.style.top = `${Math.max(0, y)}px`;
}
function scheduleInstall() {
if (installScheduled) return;
installScheduled = true;
const retry = () => {
installScheduled = false;
installCursorOverlay();
};
document.addEventListener("DOMContentLoaded", retry, { once: true });
globalThis.setTimeout(retry, 100);
}
function installCursorOverlay() {
try {
if (cursorElement?.isConnected) return true;
const existing = document.getElementById(CURSOR_OVERLAY_ID);
if (existing instanceof HTMLDivElement) cursorElement = existing;
else {
const root = document.documentElement ?? document.body ?? document.head;
if (!root) {
scheduleInstall();
return false;
}
const element = document.createElement("div");
element.id = CURSOR_OVERLAY_ID;
element.style.position = "fixed";
element.style.left = "0px";
element.style.top = "0px";
element.style.width = "16px";
element.style.height = "24px";
element.style.zIndex = "2147483647";
element.style.pointerEvents = "none";
element.style.userSelect = "none";
element.style.mixBlendMode = "normal";
element.style.contain = "layout style paint";
element.style.willChange = "transform,left,top";
element.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"24\" viewBox=\"0 0 16 24\"><path d=\"M1 0 L1 22 L6 14 L15 14 Z\" fill=\"black\" stroke=\"white\" stroke-width=\"0.7\"/></svg>";
root.appendChild(element);
cursorElement = element;
}
if (pendingPosition) {
positionCursor(cursorElement, pendingPosition[0], pendingPosition[1]);
pendingPosition = null;
}
return true;
} catch {
return false;
}
}
function moveCursorOverlay(x, y) {
pendingPosition = [x, y];
if (!installCursorOverlay() || !cursorElement) return false;
positionCursor(cursorElement, x, y);
pendingPosition = null;
return true;
}
//#endregion
//#region dom/locatorScripts/selectors.ts
var parseTargetIndex = (value) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num) || num < 0) return 0;
return Math.floor(num);
};
var collectCssMatches = (selector, limit) => {
if (!selector) return [];
const seenRoots = /* @__PURE__ */ new WeakSet();
const seenElements = /* @__PURE__ */ new Set();
const results = [];
const queue = [document];
const visit = (root) => {
if (!root || seenRoots.has(root) || results.length >= limit) return;
seenRoots.add(root);
try {
const matches = root.querySelectorAll(selector);
for (const element of matches) {
if (seenElements.has(element)) continue;
seenElements.add(element);
results.push(element);
if (results.length >= limit) return;
}
} catch {}
try {
const walker = (root instanceof Document ? root : root.host?.ownerDocument ?? document).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node;
while (node = walker.nextNode()) {
if (!(node instanceof Element)) continue;
const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot) queue.push(shadowRoot);
}
} catch {}
};
while (queue.length && results.length < limit) {
const next = queue.shift();
if (next) visit(next);
}
return results;
};
function resolveCssSelector(selectorRaw, targetIndexRaw) {
const selector = String(selectorRaw ?? "").trim();
if (!selector) return null;
const targetIndex = parseTargetIndex(targetIndexRaw);
return collectCssMatches(selector, targetIndex + 1)[targetIndex] ?? null;
}
function resolveTextSelector(rawNeedle, targetIndexRaw) {
const needle = String(rawNeedle ?? "");
if (!needle) return null;
const needleLc = needle.toLowerCase();
const targetIndex = parseTargetIndex(targetIndexRaw);
const skipTags = /* @__PURE__ */ new Set([
"SCRIPT",
"STYLE",
"TEMPLATE",
"NOSCRIPT",
"HEAD",
"TITLE",
"LINK",
"META",
"HTML",
"BODY"
]);
const shouldSkip = (node) => {
if (!node) return false;
const tag = node.tagName?.toUpperCase() ?? "";
return skipTags.has(tag);
};
const extractText = (node) => {
try {
if (shouldSkip(node)) return "";
const inner = node.innerText;
if (typeof inner === "string" && inner.trim()) return inner.trim();
} catch {}
try {
const text = node.textContent;
if (typeof text === "string") return text.trim();
} catch {}
return "";
};
const matches = (node) => {
const text = extractText(node);
return !!text && text.toLowerCase().includes(needleLc);
};
const seen = /* @__PURE__ */ new WeakSet();
const queue = [];
const matchesList = [];
const enqueue = (node) => {
if (!node || seen.has(node)) return;
seen.add(node);
queue.push(node);
};
const walkerFor = (root) => {
try {
return (root instanceof Document ? root : root?.ownerDocument ?? document).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.className ?? "",
text: extractText(root)
});
const walker = walkerFor(root);
if (!walker) continue;
let node;
while (node = walker.nextNode()) {
if (!(node instanceof Element)) continue;
if (matches(node)) matchesList.push({
element: node,
tag: node.tagName ?? "",
id: node.id ?? "",
className: node.className ?? "",
text: extractText(node)
});
const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot) enqueue(shadowRoot);
}
}
const innermost = [];
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 {}
}
if (!skip) innermost.push(item);
}
return innermost[targetIndex]?.element ?? null;
}
function resolveXPathMainWorld(rawXp, targetIndexRaw) {
return resolveXPathAtIndex(rawXp, parseTargetIndex(targetIndexRaw), { pierceShadow: true });
}
//#endregion
//#region dom/locatorScripts/waitForSelector.ts
/**
* 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 the extension DOM API for closed shadow roots.
*
* NOTE: This function runs inside the page context. Keep it dependency-free
* and resilient to exceptions.
*/
/**
* Check if a selector is an XPath expression.
*/
var isXPath = (selector) => {
return selector.startsWith("xpath=") || selector.startsWith("/");
};
/**
* Deep querySelector that pierces both open and closed shadow DOM.
*/
var deepQuerySelector = (root, selector, pierceShadow) => {
try {
const el = root.querySelector(selector);
if (el) return el;
} catch {}
if (!pierceShadow) return null;
const seenRoots = /* @__PURE__ */ new WeakSet();
const queue = [root];
while (queue.length > 0) {
const currentRoot = queue.shift();
if (!currentRoot || seenRoots.has(currentRoot)) continue;
seenRoots.add(currentRoot);
try {
const found = currentRoot.querySelector(selector);
if (found) return found;
} catch {}
try {
const walker = (currentRoot instanceof Document ? currentRoot : currentRoot.host?.ownerDocument ?? document).createTreeWalker(currentRoot, NodeFilter.SHOW_ELEMENT);
let node;
while (node = walker.nextNode()) {
if (!(node instanceof Element)) continue;
const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot && !seenRoots.has(shadowRoot)) queue.push(shadowRoot);
}
} catch {}
}
return null;
};
/**
* Resolve XPath with shadow DOM piercing support.
*/
var deepXPathQuery = (xpath, pierceShadow) => {
return resolveXPathFirst(xpath, { pierceShadow });
};
/**
* Find element by selector (CSS or XPath) with optional shadow DOM piercing.
*/
var findElement = (selector, pierceShadow) => {
if (isXPath(selector)) return deepXPathQuery(selector, pierceShadow);
return deepQuerySelector(document, selector, pierceShadow);
};
/**
* Check if element matches the desired state.
*/
var checkState = (el, state) => {
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;
}
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.
*/
var setupShadowObservers = (callback, observers, isSettled) => {
const seenRoots = /* @__PURE__ */ new WeakSet();
const observeShadowRoots = (node) => {
const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot && !seenRoots.has(shadowRoot)) {
seenRoots.add(shadowRoot);
const shadowObserver = new MutationObserver(() => {
callback();
scan();
});
observers.push(shadowObserver);
shadowObserver.observe(shadowRoot, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
"style",
"class",
"hidden",
"disabled"
]
});
for (const child of Array.from(shadowRoot.children)) observeShadowRoots(child);
}
for (const child of Array.from(node.children)) observeShadowRoots(child);
};
const root = document.documentElement || document.body;
const scan = () => {
if (!isSettled() && root) observeShadowRoots(root);
};
scan();
return scan;
};
/**
* 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; defaults to 30,000, zero is unlimited
* @param pierceShadowRaw - Whether to search inside shadow DOM
* @returns A disposable handle whose promise resolves when the condition is met
*/
function createSelectorWait(selectorRaw, stateRaw, timeoutRaw, pierceShadowRaw) {
const selector = String(selectorRaw ?? "").trim();
const state = String(stateRaw ?? "visible") || "visible";
const timeout = typeof timeoutRaw === "number" && timeoutRaw >= 0 ? timeoutRaw : 3e4;
const pierceShadow = pierceShadowRaw !== false;
let dispose = () => {};
const promise = new Promise((resolve, reject) => {
let settled = false;
let timeoutId;
let shadowScanInterval;
let domReadyHandler;
let rescanShadowRoots;
const observers = [];
const settle = (error) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
clearInterval(shadowScanInterval);
for (const observer of observers) observer.disconnect();
observers.length = 0;
rescanShadowRoots = void 0;
if (domReadyHandler) {
document.removeEventListener("DOMContentLoaded", domReadyHandler);
domReadyHandler = void 0;
}
if (error) reject(error);
else resolve(true);
};
dispose = () => settle(/* @__PURE__ */ new Error("Selector wait disposed"));
const check = () => {
if (settled) return;
try {
if (checkState(findElement(selector, pierceShadow), state)) settle();
} catch (error) {
settle(error instanceof Error ? error : new Error(String(error)));
}
};
const setupObservers = () => {
if (settled) return;
const root = document.body || document.documentElement;
if (!root) return;
try {
const mainObserver = new MutationObserver(() => {
check();
rescanShadowRoots?.();
});
observers.push(mainObserver);
mainObserver.observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
"style",
"class",
"hidden",
"disabled"
]
});
if (pierceShadow) {
rescanShadowRoots = setupShadowObservers(check, observers, () => settled);
shadowScanInterval = setInterval(() => {
rescanShadowRoots?.();
check();
}, 100);
}
} catch (error) {
settle(error instanceof Error ? error : new Error(String(error)));
}
};
check();
if (settled) return;
if (document.body || document.documentElement) setupObservers();
else {
domReadyHandler = () => {
if (settled) return;
document.removeEventListener("DOMContentLoaded", domReadyHandler);
domReadyHandler = void 0;
check();
setupObservers();
};
document.addEventListener("DOMContentLoaded", domReadyHandler);
}
if (!settled && timeout > 0) timeoutId = setTimeout(() => settle(/* @__PURE__ */ new Error(`waitForSelector: Timeout ${timeout}ms exceeded waiting for "${selector}" to be ${state}`)), timeout);
});
promise.catch(() => {});
return {
promise,
dispose: () => dispose()
};
}
//#endregion
//#region dom/locatorScripts/registry.ts
var locatorScripts = Object.freeze({
countCssMatchesPrimary,
countTextMatches,
countXPathMatchesMainWorld,
getOpenOrClosedShadowRoot,
installCursorOverlay,
moveCursorOverlay,
resolveCssSelector,
resolveTextSelector,
resolveXPathMainWorld,
createSelectorWait
});
//#endregion
//#region content-script.ts
var scope = globalThis;
if (!scope.__stagehandExtensionWorld) Object.defineProperty(scope, "__stagehandExtensionWorld", {
configurable: false,
enumerable: false,
writable: false,
value: Object.freeze({
name: "stagehand",
version: "stagehand.v4"
})
});
if (!scope.__stagehandLocatorScripts) Object.defineProperty(scope, "__stagehandLocatorScripts", {
configurable: false,
enumerable: false,
writable: false,
value: locatorScripts
});
if (!scope.__stagehandLocatorWorld) {
const closedShadowRoots = typeof globalThis.chrome?.dom?.openOrClosedShadowRoot === "function";
Object.defineProperty(scope, "__stagehandLocatorWorld", {
configurable: false,
enumerable: false,
writable: false,
value: Object.freeze({
kind: closedShadowRoots ? "extension" : "cdp-fallback",
closedShadowRoots
})
});
}
//#endregion