Skip to content

Commit b40ae11

Browse files
authored
Add hover method (#1406)
# why We had `page.click(x, y)` for coordinate-based clicking but no equivalent for hovering. Also, the agent's will need hover abilities # what changed - Added `page.hover(x, y, options?)` to dispatch mouse move events at coordinates # test plan Added `page-hover.spec.ts` with 6 tests covering: - Mouseover event triggers - Hover doesn't click - `returnXpath` option - CSS `:hover` pseudo-class activation - Multiple sequential hovers <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds page.hover(x, y, options?) for coordinate-based hovering. Enables mouseover and CSS :hover without clicking, with an option to return the hovered element’s XPath. - **New Features** - Dispatches mouseMoved at absolute page coordinates via CDP. - Supports options.returnXpath to return the element XPath. - Moves cursor without triggering click; activates mouseover and :hover states. <sup>Written for commit 5b3b39f. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
1 parent 33fba24 commit b40ae11

File tree

3 files changed

+291
-0
lines changed

3 files changed

+291
-0
lines changed

.changeset/rude-steaks-lie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@browserbasehq/stagehand": patch
3+
---
4+
5+
Add support for hovering with coordinates ( page.hover )
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import { test, expect } from "@playwright/test";
2+
import { V3 } from "../v3";
3+
import { v3TestConfig } from "./v3.config";
4+
5+
test.describe("Page.hover() - mouse hover at coordinates", () => {
6+
let v3: V3;
7+
8+
test.beforeEach(async () => {
9+
v3 = new V3(v3TestConfig);
10+
await v3.init();
11+
});
12+
13+
test.afterEach(async () => {
14+
await v3?.close?.().catch(() => {});
15+
});
16+
17+
test("hover triggers mouseover event at coordinates", async () => {
18+
const page = v3.context.pages()[0];
19+
20+
await page.goto(
21+
"data:text/html," +
22+
encodeURIComponent(
23+
`<!doctype html><html><body style="margin: 0; padding: 0;">
24+
<div id="target"
25+
style="position: absolute; top: 100px; left: 100px; width: 200px; height: 200px; background: lightblue;"
26+
onmouseover="this.dataset.hovered='true'"
27+
onmouseout="this.dataset.hovered='false'">
28+
Hover Me
29+
</div>
30+
</body></html>`,
31+
),
32+
);
33+
34+
// Check initial state
35+
let hovered = await page.evaluate(() => {
36+
const el = document.getElementById("target");
37+
return el?.dataset.hovered === "true";
38+
});
39+
expect(hovered).toBe(false);
40+
41+
// Hover at coordinates within the target element (200, 200 is center of the div)
42+
await page.hover(200, 200);
43+
44+
// Verify mouseover was triggered
45+
hovered = await page.evaluate(() => {
46+
const el = document.getElementById("target");
47+
return el?.dataset.hovered === "true";
48+
});
49+
expect(hovered).toBe(true);
50+
});
51+
52+
test("hover moves mouse without clicking", async () => {
53+
const page = v3.context.pages()[0];
54+
55+
await page.goto(
56+
"data:text/html," +
57+
encodeURIComponent(
58+
`<!doctype html><html><body style="margin: 0; padding: 0;">
59+
<button id="btn"
60+
style="position: absolute; top: 100px; left: 100px; width: 200px; height: 100px;"
61+
onclick="this.dataset.clicked='true'"
62+
onmouseover="this.dataset.hovered='true'">
63+
Click Me
64+
</button>
65+
</body></html>`,
66+
),
67+
);
68+
69+
// Hover over the button
70+
await page.hover(200, 150);
71+
72+
// Check that hover happened but click did not
73+
const state = await page.evaluate(() => {
74+
const btn = document.getElementById("btn");
75+
return {
76+
hovered: btn?.dataset.hovered === "true",
77+
clicked: btn?.dataset.clicked === "true",
78+
};
79+
});
80+
81+
expect(state.hovered).toBe(true);
82+
expect(state.clicked).toBe(false);
83+
});
84+
85+
test("hover returns xpath when requested", async () => {
86+
const page = v3.context.pages()[0];
87+
88+
await page.goto(
89+
"data:text/html," +
90+
encodeURIComponent(
91+
`<!doctype html><html><body style="margin: 0; padding: 0;">
92+
<div id="target" style="position: absolute; top: 0px; left: 400px; width: 300px; height: 100px; background: blue;">
93+
Target element
94+
</div>
95+
<p style="position: absolute; top: 200px; left: 0px;">Content below</p>
96+
</body></html>`,
97+
),
98+
);
99+
100+
// Hover at coordinate (550, 50) which should be directly over the target div
101+
const xpath = await page.hover(550, 50, { returnXpath: true });
102+
103+
// Should return a non-empty xpath string for the element at that coordinate
104+
expect(typeof xpath).toBe("string");
105+
expect(xpath.length).toBeGreaterThan(0);
106+
// Xpath should reference the div
107+
expect(xpath.toLowerCase()).toMatch(/div|target/);
108+
});
109+
110+
test("hover without returnXpath returns void", async () => {
111+
const page = v3.context.pages()[0];
112+
113+
await page.goto(
114+
"data:text/html," +
115+
encodeURIComponent(
116+
`<!doctype html><html><body style="margin: 0; padding: 0;">
117+
<div style="width: 100px; height: 100px; background: lightblue;">Content</div>
118+
</body></html>`,
119+
),
120+
);
121+
122+
// Hover without returnXpath
123+
const result = await page.hover(50, 50);
124+
125+
// Should return undefined (void)
126+
expect(result).toBeUndefined();
127+
});
128+
129+
test("hover triggers CSS :hover styles", async () => {
130+
const page = v3.context.pages()[0];
131+
132+
await page.goto(
133+
"data:text/html," +
134+
encodeURIComponent(
135+
`<!doctype html><html>
136+
<head>
137+
<style>
138+
#hoverable {
139+
position: absolute;
140+
top: 100px;
141+
left: 100px;
142+
width: 200px;
143+
height: 200px;
144+
background: red;
145+
}
146+
#hoverable:hover {
147+
background: green;
148+
}
149+
</style>
150+
</head>
151+
<body style="margin: 0; padding: 0;">
152+
<div id="hoverable">Hover to change color</div>
153+
</body></html>`,
154+
),
155+
);
156+
157+
// Get initial background color
158+
let bgColor = await page.evaluate(() => {
159+
const el = document.getElementById("hoverable");
160+
return getComputedStyle(el!).backgroundColor;
161+
});
162+
expect(bgColor).toBe("rgb(255, 0, 0)"); // red
163+
164+
// Hover over the element
165+
await page.hover(200, 200);
166+
167+
// Check that CSS :hover state is applied
168+
bgColor = await page.evaluate(() => {
169+
const el = document.getElementById("hoverable");
170+
return getComputedStyle(el!).backgroundColor;
171+
});
172+
expect(bgColor).toBe("rgb(0, 128, 0)"); // green
173+
});
174+
175+
test("multiple hovers move the mouse correctly", async () => {
176+
const page = v3.context.pages()[0];
177+
178+
await page.goto(
179+
"data:text/html," +
180+
encodeURIComponent(
181+
`<!doctype html><html><body style="margin: 0; padding: 0;">
182+
<div id="box1"
183+
style="position: absolute; top: 0; left: 0; width: 100px; height: 100px; background: red;"
184+
onmouseover="this.dataset.hovered='true'"
185+
onmouseout="this.dataset.hovered='false'">
186+
Box 1
187+
</div>
188+
<div id="box2"
189+
style="position: absolute; top: 0; left: 200px; width: 100px; height: 100px; background: blue;"
190+
onmouseover="this.dataset.hovered='true'"
191+
onmouseout="this.dataset.hovered='false'">
192+
Box 2
193+
</div>
194+
</body></html>`,
195+
),
196+
);
197+
198+
// Hover over box1
199+
await page.hover(50, 50);
200+
201+
let state = await page.evaluate(() => ({
202+
box1: document.getElementById("box1")?.dataset.hovered === "true",
203+
box2: document.getElementById("box2")?.dataset.hovered === "true",
204+
}));
205+
206+
expect(state.box1).toBe(true);
207+
expect(state.box2).toBe(false);
208+
209+
// Move hover to box2
210+
await page.hover(250, 50);
211+
212+
state = await page.evaluate(() => ({
213+
box1: document.getElementById("box1")?.dataset.hovered === "true",
214+
box2: document.getElementById("box2")?.dataset.hovered === "true",
215+
}));
216+
217+
expect(state.box1).toBe(false);
218+
expect(state.box2).toBe(true);
219+
});
220+
});

packages/core/lib/v3/understudy/page.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,72 @@ export class Page {
13801380
if (options?.returnXpath) return xpathResult ?? "";
13811381
}
13821382

1383+
/**
1384+
* Hover at absolute page coordinates (CSS pixels).
1385+
* Dispatches mouseMoved via CDP Input domain on the top-level page target's
1386+
* session.
1387+
*/
1388+
async hover(
1389+
x: number,
1390+
y: number,
1391+
options: { returnXpath: true },
1392+
): Promise<string>;
1393+
async hover(
1394+
x: number,
1395+
y: number,
1396+
options?: { returnXpath?: false },
1397+
): Promise<void>;
1398+
@logAction("Page.hover")
1399+
async hover(
1400+
x: number,
1401+
y: number,
1402+
options?: { returnXpath?: boolean },
1403+
): Promise<void | string> {
1404+
let xpathResult: string | undefined;
1405+
if (options?.returnXpath) {
1406+
try {
1407+
const hit = await resolveXpathForLocation(this, x, y);
1408+
if (hit) {
1409+
v3Logger({
1410+
category: "page",
1411+
message: "hover resolved hit",
1412+
level: 2,
1413+
auxiliary: {
1414+
frameId: { value: String(hit.frameId), type: "string" },
1415+
backendNodeId: {
1416+
value: String(hit.backendNodeId),
1417+
type: "string",
1418+
},
1419+
x: { value: String(x), type: "integer" },
1420+
y: { value: String(y), type: "integer" },
1421+
},
1422+
});
1423+
xpathResult = hit.absoluteXPath;
1424+
}
1425+
} catch {
1426+
v3Logger({
1427+
category: "page",
1428+
message: "Failed to resolve xpath for hover",
1429+
level: 2,
1430+
auxiliary: {
1431+
x: { value: String(x), type: "integer" },
1432+
y: { value: String(y), type: "integer" },
1433+
},
1434+
});
1435+
}
1436+
}
1437+
1438+
await this.updateCursor(x, y);
1439+
await this.mainSession.send<never>("Input.dispatchMouseEvent", {
1440+
type: "mouseMoved",
1441+
x,
1442+
y,
1443+
button: "none",
1444+
} as Protocol.Input.DispatchMouseEventRequest);
1445+
1446+
if (options?.returnXpath) return xpathResult ?? "";
1447+
}
1448+
13831449
async scroll(
13841450
x: number,
13851451
y: number,

0 commit comments

Comments
 (0)