Skip to content

Commit b6a4719

Browse files
committed
add custom locator engine & shadow dom traversal logic
1 parent a204ca1 commit b6a4719

File tree

6 files changed

+392
-19
lines changed

6 files changed

+392
-19
lines changed

lib/StagehandPage.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { CDPSession, Page as PlaywrightPage, Frame } from "playwright";
1+
import type {
2+
CDPSession,
3+
Page as PlaywrightPage,
4+
Frame,
5+
ElementHandle,
6+
} from "playwright";
7+
import { selectors } from "playwright";
28
import { z } from "zod/v3";
39
import { Page, defaultExtractSchema } from "../types/page";
410
import {
@@ -29,6 +35,7 @@ import {
2935
import { StagehandAPIError } from "@/types/stagehandApiErrors";
3036
import { scriptContent } from "@/lib/dom/build/scriptContent";
3137
import type { Protocol } from "devtools-protocol";
38+
import { StagehandBackdoor } from "@/lib/dom/global";
3239

3340
async function getCurrentRootFrameId(session: CDPSession): Promise<string> {
3441
const { frameTree } = (await session.send(
@@ -37,6 +44,9 @@ async function getCurrentRootFrameId(session: CDPSession): Promise<string> {
3744
return frameTree.frame.id;
3845
}
3946

47+
/** ensure we register the custom selector only once per process */
48+
let stagehandSelectorRegistered = false;
49+
4050
export class StagehandPage {
4151
private stagehand: Stagehand;
4252
private rawPage: PlaywrightPage;
@@ -188,6 +198,113 @@ ${scriptContent} \
188198
}
189199
}
190200

201+
/** Register the custom selector engine that pierces open/closed shadow roots. */
202+
private async ensureStagehandSelectorEngine(): Promise<void> {
203+
if (stagehandSelectorRegistered) return;
204+
stagehandSelectorRegistered = true;
205+
206+
await selectors.register("stagehand", () => {
207+
type Backdoor = {
208+
getClosedRoot?: (host: Element) => ShadowRoot | undefined;
209+
};
210+
211+
function parseSelector(input: string): { name: string; value: string } {
212+
// Accept either: "abc123" → uses DEFAULT_ATTR
213+
// or explicitly: "data-__stagehand-id=abc123"
214+
const raw = input.trim();
215+
const eq = raw.indexOf("=");
216+
if (eq === -1) {
217+
return {
218+
name: "data-__stagehand-id",
219+
value: raw.replace(/^["']|["']$/g, ""),
220+
};
221+
}
222+
const name = raw.slice(0, eq).trim();
223+
const value = raw
224+
.slice(eq + 1)
225+
.trim()
226+
.replace(/^["']|["']$/g, "");
227+
return { name, value };
228+
}
229+
230+
function pushChildren(node: Node, stack: Node[]): void {
231+
if (node.nodeType === Node.DOCUMENT_NODE) {
232+
const de = (node as Document).documentElement;
233+
if (de) stack.push(de);
234+
return;
235+
}
236+
237+
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
238+
const frag = node as DocumentFragment;
239+
const hc = frag.children as HTMLCollection | undefined;
240+
if (hc && hc.length) {
241+
for (let i = hc.length - 1; i >= 0; i--)
242+
stack.push(hc[i] as Element);
243+
} else {
244+
const cn = frag.childNodes;
245+
for (let i = cn.length - 1; i >= 0; i--) stack.push(cn[i]);
246+
}
247+
return;
248+
}
249+
250+
if (node.nodeType === Node.ELEMENT_NODE) {
251+
const el = node as Element;
252+
for (let i = el.children.length - 1; i >= 0; i--)
253+
stack.push(el.children[i]);
254+
}
255+
}
256+
257+
function* traverseAllTrees(
258+
start: Node,
259+
): Generator<Element, void, unknown> {
260+
const backdoor = window.__stagehand__ as Backdoor | undefined;
261+
const stack: Node[] = [];
262+
263+
if (start.nodeType === Node.DOCUMENT_NODE) {
264+
const de = (start as Document).documentElement;
265+
if (de) stack.push(de);
266+
} else {
267+
stack.push(start);
268+
}
269+
270+
while (stack.length) {
271+
const node = stack.pop()!;
272+
if (node.nodeType === Node.ELEMENT_NODE) {
273+
const el = node as Element;
274+
yield el;
275+
276+
// open shadow
277+
const open = el.shadowRoot as ShadowRoot | null;
278+
if (open) stack.push(open);
279+
280+
// closed shadow via backdoor
281+
const closed = backdoor?.getClosedRoot?.(el);
282+
if (closed) stack.push(closed);
283+
}
284+
pushChildren(node, stack);
285+
}
286+
}
287+
288+
return {
289+
query(root: Node, selector: string): Element | null {
290+
const { name, value } = parseSelector(selector);
291+
for (const el of traverseAllTrees(root)) {
292+
if (el.getAttribute(name) === value) return el;
293+
}
294+
return null;
295+
},
296+
queryAll(root: Node, selector: string): Element[] {
297+
const { name, value } = parseSelector(selector);
298+
const out: Element[] = [];
299+
for (const el of traverseAllTrees(root)) {
300+
if (el.getAttribute(name) === value) out.push(el);
301+
}
302+
return out;
303+
},
304+
};
305+
});
306+
}
307+
191308
/**
192309
* Waits for a captcha to be solved when using Browserbase environment.
193310
*
@@ -410,6 +527,11 @@ ${scriptContent} \
410527
this.intContext.registerFrameId(rootId, this);
411528

412529
this.intPage = new Proxy(page, handler) as unknown as Page;
530+
531+
// Ensure our backdoor and selector engine are ready up front
532+
await this.ensureStagehandScript();
533+
await this.ensureStagehandSelectorEngine();
534+
413535
this.initialized = true;
414536
return this;
415537
} catch (err: unknown) {
@@ -999,4 +1121,23 @@ ${scriptContent} \
9991121
): Promise<void> {
10001122
await this.sendCDP<void>(`${domain}.disable`, {}, target);
10011123
}
1124+
1125+
async getShadowRootHandle(
1126+
this: StagehandPage,
1127+
host: ElementHandle<Element>,
1128+
): Promise<ElementHandle<ShadowRoot> | null> {
1129+
const h = await host.evaluateHandle((el: Element): ShadowRoot | null => {
1130+
// Open root?
1131+
if ((el as HTMLElement).shadowRoot)
1132+
return (el as HTMLElement).shadowRoot!;
1133+
// Closed root kept in our isolated world
1134+
return (
1135+
(
1136+
window as Window & { __stagehand__?: StagehandBackdoor }
1137+
).__stagehand__?.getClosedRoot(el) ?? null
1138+
);
1139+
});
1140+
1141+
return h.asElement() as ElementHandle<ShadowRoot> | null;
1142+
}
10021143
}

lib/dom/global.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export {};
1+
export interface StagehandBackdoor {
2+
/** Closed shadow-root accessors */
3+
getClosedRoot(host: Element): ShadowRoot | undefined;
4+
queryClosed(host: Element, selector: string): Element[];
5+
xpathClosed(host: Element, xpath: string): Node[];
6+
}
27
declare global {
38
interface Window {
49
__stagehandInjected?: boolean;
@@ -8,5 +13,6 @@ declare global {
813
getScrollableElementXpaths: (topN?: number) => Promise<string[]>;
914
getNodeFromXpath: (xpath: string) => Node | null;
1015
waitForElementScrollEnd: (element: HTMLElement) => Promise<void>;
16+
readonly __stagehand__?: StagehandBackdoor;
1117
}
1218
}

lib/dom/process.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,66 @@ export async function getScrollableElementXpaths(
7373
return xpaths;
7474
}
7575

76+
(() => {
77+
// Map <host ➜ shadowRoot> for every root created in closed mode
78+
const closedRoots: WeakMap<Element, ShadowRoot> = new WeakMap();
79+
80+
// Preserve the original method
81+
const nativeAttachShadow = Element.prototype.attachShadow;
82+
83+
// Intercept *before any page script runs*
84+
Element.prototype.attachShadow = function (init: ShadowRootInit): ShadowRoot {
85+
const root = nativeAttachShadow.call(this, init);
86+
if (init.mode === "closed") closedRoots.set(this, root);
87+
return root;
88+
};
89+
90+
interface StagehandBackdoor {
91+
/** Get the real ShadowRoot (undefined if host has none / is open) */
92+
getClosedRoot(host: Element): ShadowRoot | undefined;
93+
94+
/** CSS‑selector search inside that root */
95+
queryClosed(host: Element, selector: string): Element[];
96+
97+
/** XPath search inside that root (relative XPath supported) */
98+
xpathClosed(host: Element, xpath: string): Node[];
99+
}
100+
101+
const backdoor: StagehandBackdoor = {
102+
getClosedRoot: (host) => closedRoots.get(host),
103+
104+
queryClosed: (host, selector) => {
105+
const root = closedRoots.get(host);
106+
return root ? Array.from(root.querySelectorAll(selector)) : [];
107+
},
108+
109+
xpathClosed: (host, xp) => {
110+
const root = closedRoots.get(host);
111+
if (!root) return [];
112+
const it = document.evaluate(
113+
xp,
114+
root,
115+
null,
116+
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
117+
null,
118+
);
119+
const out: Node[] = [];
120+
for (let i = 0; i < it.snapshotLength; ++i) {
121+
const n = it.snapshotItem(i);
122+
if (n) out.push(n);
123+
}
124+
return out;
125+
},
126+
};
127+
128+
Object.defineProperty(window, "__stagehand__", {
129+
value: backdoor,
130+
enumerable: false,
131+
writable: false,
132+
configurable: false,
133+
});
134+
})();
135+
76136
window.getScrollableElementXpaths = getScrollableElementXpaths;
77137
window.getNodeFromXpath = getNodeFromXpath;
78138
window.waitForElementScrollEnd = waitForElementScrollEnd;

lib/handlers/actHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ export class StagehandActHandler {
311311
domSettleTimeoutMs?: number,
312312
) {
313313
const xpath = rawXPath.replace(/^xpath=/i, "").trim();
314-
const locator = deepLocator(this.stagehandPage.page, xpath).first();
314+
const locator = await deepLocator(this.stagehandPage.page, xpath);
315315
const initialUrl = this.stagehandPage.page.url();
316316

317317
this.logger({

0 commit comments

Comments
 (0)