Skip to content

Commit adec18d

Browse files
committed
add tests DOM session builders
1 parent 962b1fa commit adec18d

File tree

3 files changed

+322
-32
lines changed

3 files changed

+322
-32
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { CDPSessionLike } from "../../lib/v3/understudy/cdp";
2+
3+
type Handler = (params?: Record<string, unknown>) => Promise<unknown> | unknown;
4+
5+
export class MockCDPSession implements CDPSessionLike {
6+
public readonly id: string;
7+
public readonly calls: Array<{
8+
method: string;
9+
params?: Record<string, unknown>;
10+
}> = [];
11+
12+
constructor(
13+
private readonly handlers: Record<string, Handler> = {},
14+
sessionId = "mock-session",
15+
) {
16+
this.id = sessionId;
17+
}
18+
19+
async send<R = unknown>(
20+
method: string,
21+
params: Record<string, unknown> = {},
22+
): Promise<R> {
23+
this.calls.push({ method, params });
24+
const handler = this.handlers[method];
25+
if (!handler) return {} as R;
26+
return (await handler(params)) as R;
27+
}
28+
29+
on(): void {}
30+
off(): void {}
31+
async close(): Promise<void> {}
32+
33+
callsFor(method: string): Array<{ params?: Record<string, unknown> }> {
34+
return this.calls
35+
.filter((call) => call.method === method)
36+
.map(({ params }) => ({ params }));
37+
}
38+
}

packages/core/tests/snapshot-cbor.test.ts

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,13 @@ import { describe, expect, it } from "vitest";
22
import type { Protocol } from "devtools-protocol";
33

44
import { captureHybridSnapshot } from "../lib/v3/understudy/a11y/snapshot";
5-
import type { CDPSessionLike } from "../lib/v3/understudy/cdp";
5+
import { MockCDPSession } from "./helpers/mockCDPSession";
66
import type { Page } from "../lib/v3/understudy/page";
77
import { StagehandDomProcessError } from "../lib/v3/types/public/sdkErrors";
8+
import { CDPSessionLike } from "../lib/v3/understudy/cdp";
89

910
type Handler = (params?: Record<string, unknown>) => Promise<unknown> | unknown;
1011

11-
class StubSession implements CDPSessionLike {
12-
public readonly id = "stub-session";
13-
public readonly calls: Array<{
14-
method: string;
15-
params?: Record<string, unknown>;
16-
}> = [];
17-
18-
constructor(private readonly handlers: Record<string, Handler> = {}) {}
19-
20-
async send<R = unknown>(
21-
method: string,
22-
params: Record<string, unknown> = {},
23-
): Promise<R> {
24-
this.calls.push({ method, params });
25-
const handler = this.handlers[method];
26-
if (!handler) return {} as R;
27-
return (await handler(params)) as R;
28-
}
29-
30-
on(): void {}
31-
off(): void {}
32-
async close(): Promise<void> {}
33-
34-
callsFor(method: string): Array<{ params?: Record<string, unknown> }> {
35-
return this.calls.filter((c) => c.method === method);
36-
}
37-
}
38-
3912
function createFakePage(session: CDPSessionLike): Page {
4013
const frameTree: Protocol.Page.FrameTree = {
4114
frame: {
@@ -184,7 +157,7 @@ function makeCborError(): Error {
184157
describe("captureHybridSnapshot CBOR fallbacks", () => {
185158
it("retries DOM.getDocument with reduced depths before succeeding", async () => {
186159
let domCalls = 0;
187-
const session = new StubSession({
160+
const session = new MockCDPSession({
188161
...baseHandlers,
189162
"DOM.getDocument": async (params) => {
190163
domCalls += 1;
@@ -205,7 +178,7 @@ describe("captureHybridSnapshot CBOR fallbacks", () => {
205178
});
206179

207180
it("throws StagehandDomProcessError after all DOM.getDocument attempts fail", async () => {
208-
const session = new StubSession({
181+
const session = new MockCDPSession({
209182
...baseHandlers,
210183
"DOM.getDocument": async () => {
211184
throw makeCborError();
@@ -222,7 +195,7 @@ describe("captureHybridSnapshot CBOR fallbacks", () => {
222195
let domAttempts = 0;
223196
let describeAttempts = 0;
224197

225-
const session = new StubSession({
198+
const session = new MockCDPSession({
226199
...baseHandlers,
227200
"DOM.getDocument": async (params) => {
228201
domAttempts += 1;
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import type { Protocol } from "devtools-protocol";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
buildSessionDomIndex,
5+
domMapsForSession,
6+
getDomTreeWithFallback,
7+
hydrateDomTree,
8+
} from "../lib/v3/understudy/a11y/snapshot/domTree";
9+
import { StagehandDomProcessError } from "../lib/v3/types/public/sdkErrors";
10+
import { MockCDPSession } from "./helpers/mockCDPSession";
11+
12+
let nextNodeId = 1;
13+
const makeDomNode = (
14+
overrides: Partial<Protocol.DOM.Node> = {},
15+
): Protocol.DOM.Node => {
16+
const nodeId = overrides.nodeId ?? nextNodeId++;
17+
const backendNodeId = overrides.backendNodeId ?? nextNodeId++;
18+
const nodeName = overrides.nodeName ?? "DIV";
19+
const nodeType = overrides.nodeType ?? 1;
20+
const children = overrides.children ?? [];
21+
return {
22+
nodeId,
23+
backendNodeId,
24+
nodeName,
25+
nodeType,
26+
localName: overrides.localName ?? nodeName.toLowerCase(),
27+
nodeValue: overrides.nodeValue ?? "",
28+
childNodeCount: overrides.childNodeCount ?? children.length,
29+
children,
30+
shadowRoots: overrides.shadowRoots,
31+
contentDocument: overrides.contentDocument,
32+
isScrollable: overrides.isScrollable,
33+
};
34+
};
35+
36+
const buildSampleDomTree = () => {
37+
const iframeChild = makeDomNode({ nodeName: "P" });
38+
const iframeBody = makeDomNode({
39+
nodeName: "BODY",
40+
children: [iframeChild],
41+
isScrollable: true,
42+
});
43+
const iframeHtml = makeDomNode({ nodeName: "HTML", children: [iframeBody] });
44+
const iframeDoc = makeDomNode({
45+
nodeName: "#document",
46+
nodeType: 9,
47+
children: [iframeHtml],
48+
});
49+
const iframeElement = makeDomNode({
50+
nodeName: "IFRAME",
51+
contentDocument: iframeDoc,
52+
});
53+
const scrollDiv = makeDomNode({
54+
nodeName: "DIV",
55+
isScrollable: true,
56+
});
57+
const body = makeDomNode({
58+
nodeName: "BODY",
59+
children: [scrollDiv, iframeElement],
60+
});
61+
const html = makeDomNode({ nodeName: "HTML", children: [body] });
62+
const root = makeDomNode({
63+
nodeName: "#document",
64+
nodeType: 9,
65+
children: [html],
66+
});
67+
return {
68+
root,
69+
html,
70+
body,
71+
scrollDiv,
72+
iframeElement,
73+
iframeDoc,
74+
iframeHtml,
75+
iframeBody,
76+
iframeChild,
77+
};
78+
};
79+
80+
describe("hydrateDomTree", () => {
81+
it("expands truncated nodes by calling DOM.describeNode", async () => {
82+
const child = makeDomNode({ nodeName: "DIV" });
83+
const root = makeDomNode({
84+
nodeName: "HTML",
85+
childNodeCount: 1,
86+
children: [],
87+
});
88+
89+
const session = new MockCDPSession({
90+
"DOM.describeNode": async () => ({
91+
node: {
92+
...root,
93+
children: [child],
94+
childNodeCount: 1,
95+
},
96+
}),
97+
});
98+
99+
await hydrateDomTree(session, root, true);
100+
expect(root.children).toEqual([child]);
101+
});
102+
103+
it("retries describeNode when CBOR errors occur before succeeding", async () => {
104+
const child = makeDomNode({ nodeName: "DIV" });
105+
const root = makeDomNode({
106+
nodeName: "HTML",
107+
childNodeCount: 1,
108+
children: [],
109+
});
110+
111+
let attempts = 0;
112+
const session = new MockCDPSession({
113+
"DOM.describeNode": async () => {
114+
attempts++;
115+
if (attempts === 1) throw new Error("CBOR: stack limit exceeded");
116+
return { node: { ...root, children: [child], childNodeCount: 1 } };
117+
},
118+
});
119+
120+
await hydrateDomTree(session, root, true);
121+
expect(attempts).toBe(2);
122+
expect(root.children).toEqual([child]);
123+
});
124+
125+
it("throws StagehandDomProcessError after exhausting describeNode retries", async () => {
126+
const root = makeDomNode({
127+
nodeName: "HTML",
128+
childNodeCount: 1,
129+
children: [],
130+
});
131+
const session = new MockCDPSession({
132+
"DOM.describeNode": async () => {
133+
throw new Error("CBOR: stack limit exceeded");
134+
},
135+
});
136+
137+
await expect(hydrateDomTree(session, root, true)).rejects.toBeInstanceOf(
138+
StagehandDomProcessError,
139+
);
140+
});
141+
});
142+
143+
describe("getDomTreeWithFallback", () => {
144+
it("retries DOM.getDocument after CBOR errors and returns the hydrated root", async () => {
145+
const root = makeDomNode({
146+
nodeName: "#document",
147+
nodeType: 9,
148+
children: [],
149+
});
150+
const depths: number[] = [];
151+
const session = new MockCDPSession({
152+
"DOM.getDocument": async (params) => {
153+
const depth = (params?.depth ?? 0) as number;
154+
depths.push(depth);
155+
if (depth === -1) throw new Error("CBOR: stack limit exceeded");
156+
return { root };
157+
},
158+
"DOM.describeNode": async () => ({ node: root }),
159+
});
160+
161+
const result = await getDomTreeWithFallback(session, true);
162+
expect(result).toBe(root);
163+
expect(depths).toEqual([-1, 256]);
164+
});
165+
166+
it("propagates non-CBOR DOM.getDocument errors", async () => {
167+
const session = new MockCDPSession({
168+
"DOM.getDocument": async () => {
169+
throw new Error("network fail");
170+
},
171+
});
172+
await expect(getDomTreeWithFallback(session, false)).rejects.toThrow(
173+
"network fail",
174+
);
175+
});
176+
177+
it("throws StagehandDomProcessError when all depth attempts hit CBOR limits", async () => {
178+
const session = new MockCDPSession({
179+
"DOM.getDocument": async () => {
180+
throw new Error("CBOR: stack limit exceeded");
181+
},
182+
});
183+
await expect(getDomTreeWithFallback(session, false)).rejects.toBeInstanceOf(
184+
StagehandDomProcessError,
185+
);
186+
});
187+
});
188+
189+
describe("buildSessionDomIndex", () => {
190+
it("collects absolute paths, scrollability, and content-document metadata", async () => {
191+
const tree = buildSampleDomTree();
192+
const session = new MockCDPSession({
193+
"DOM.enable": async () => ({}),
194+
"DOM.getDocument": async () => ({ root: tree.root }),
195+
"DOM.describeNode": async () => ({ node: tree.root }),
196+
});
197+
198+
const index = await buildSessionDomIndex(session, true);
199+
200+
expect(index.rootBackend).toBe(tree.root.backendNodeId);
201+
expect(index.absByBe.get(tree.body.backendNodeId)).toBe("/html[1]/body[1]");
202+
expect(index.absByBe.get(tree.scrollDiv.backendNodeId)).toBe(
203+
"/html[1]/body[1]/div[1]",
204+
);
205+
expect(index.scrollByBe.get(tree.scrollDiv.backendNodeId)).toBe(true);
206+
expect(index.docRootOf.get(tree.iframeHtml.backendNodeId)).toBe(
207+
tree.iframeDoc.backendNodeId,
208+
);
209+
expect(
210+
index.contentDocRootByIframe.get(tree.iframeElement.backendNodeId),
211+
).toBe(tree.iframeDoc.backendNodeId);
212+
});
213+
});
214+
215+
describe("domMapsForSession", () => {
216+
it("derives frame-relative xpath/tag/scrollable maps for a frame's document root", async () => {
217+
const tree = buildSampleDomTree();
218+
const session = new MockCDPSession({
219+
"DOM.enable": async () => ({}),
220+
"DOM.getDocument": async () => ({ root: tree.root }),
221+
"DOM.getFrameOwner": async () => ({
222+
backendNodeId: tree.iframeElement.backendNodeId,
223+
}),
224+
"DOM.describeNode": async () => ({ node: tree.root }),
225+
});
226+
227+
const encode = (frameId: string, backendNodeId: number) =>
228+
`${frameId}-${backendNodeId}`;
229+
const maps = await domMapsForSession(
230+
session,
231+
"frame-A",
232+
true,
233+
encode,
234+
true,
235+
);
236+
237+
const iframeDocKey = `frame-A-${tree.iframeDoc.backendNodeId}`;
238+
const iframeBodyKey = `frame-A-${tree.iframeBody.backendNodeId}`;
239+
const iframeChildKey = `frame-A-${tree.iframeChild.backendNodeId}`;
240+
241+
expect(maps.tagNameMap[iframeDocKey]).toBe("#document");
242+
expect(maps.xpathMap[iframeDocKey]).toBe("/");
243+
expect(maps.xpathMap[iframeBodyKey]).toBe("/html[1]/body[1]");
244+
expect(maps.xpathMap[iframeChildKey]).toBe("/html[1]/body[1]/p[1]");
245+
expect(maps.scrollableMap[iframeBodyKey]).toBe(true);
246+
expect(Object.keys(maps.tagNameMap)).not.toContain(
247+
`frame-A-${tree.html.backendNodeId}`,
248+
);
249+
});
250+
251+
it("falls back to the root document when frame owner lookup fails", async () => {
252+
const tree = buildSampleDomTree();
253+
const session = new MockCDPSession({
254+
"DOM.enable": async () => ({}),
255+
"DOM.getDocument": async () => ({ root: tree.root }),
256+
"DOM.getFrameOwner": async () => {
257+
throw new Error("owner lookup failed");
258+
},
259+
"DOM.describeNode": async () => ({ node: tree.root }),
260+
});
261+
262+
const encode = (frameId: string, backendNodeId: number) =>
263+
`${frameId}-${backendNodeId}`;
264+
const maps = await domMapsForSession(
265+
session,
266+
"frame-B",
267+
false,
268+
encode,
269+
true,
270+
);
271+
272+
expect(maps.xpathMap[`frame-B-${tree.html.backendNodeId}`]).toBe(
273+
"/html[1]",
274+
);
275+
expect(maps.xpathMap[`frame-B-${tree.scrollDiv.backendNodeId}`]).toBe(
276+
"/html[1]/body[1]/div[1]",
277+
);
278+
});
279+
});

0 commit comments

Comments
 (0)