From b347f8b2e2015c5da20ee5c3627d8e4f88baaa03 Mon Sep 17 00:00:00 2001 From: Rohit <71192000+rohitsux@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:07:34 +0530 Subject: [PATCH] feat(cdp): fire hover events on Input.dispatchMouseEvent mouseMoved Wire the CDP Input.dispatchMouseEvent "mouseMoved" type to a new Frame.triggerMouseMove, which hit-tests the point via elementFromPoint and dispatches mousemove, mouseover and mouseenter on the target, mirroring the existing triggerMouseClick and the actions.zig hover() event semantics. Previously mouseMoved was silently ignored, so element.hover() over Playwright/Puppeteer (CDP) fired no events. Addresses the hover gap reported in #2043. --- src/browser/Frame.zig | 38 +++++++++++++++++++++++++++++ src/cdp/domains/input.zig | 51 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index ffafef0d..c03e3a4f 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -3929,6 +3929,44 @@ pub fn triggerMouseClick(self: *Frame, x: f64, y: f64) !void { try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent()); } +pub fn triggerMouseMove(self: *Frame, x: f64, y: f64) !void { + const target = (try self.window._document.elementFromPoint(x, y, self)) orelse return; + if (comptime IS_DEBUG) { + log.debug(.frame, "frame mouse move", .{ + .url = self.url, + .node = target, + .x = x, + .y = y, + .type = self._type, + }); + } + + const move_event: *MouseEvent = try .initTrusted(comptime .wrap("mousemove"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .clientX = x, + .clientY = y, + }, self); + try self._event_manager.dispatch(target.asEventTarget(), move_event.asEvent()); + + const over_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseover"), .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .clientX = x, + .clientY = y, + }, self); + try self._event_manager.dispatch(target.asEventTarget(), over_event.asEvent()); + + const enter_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseenter"), .{ + .composed = true, + .clientX = x, + .clientY = y, + }, self); + try self._event_manager.dispatch(target.asEventTarget(), enter_event.asEvent()); +} + // callback when the "click" event reaches the frame. pub fn handleClick(self: *Frame, target: *Node) !void { // TODO: Also support elements when implement diff --git a/src/cdp/domains/input.zig b/src/cdp/domains/input.zig index 0944c4f5..abf6c614 100644 --- a/src/cdp/domains/input.zig +++ b/src/cdp/domains/input.zig @@ -96,13 +96,17 @@ fn dispatchMouseEvent(cmd: *CDP.Command) !void { // quickly ignore types we know we don't handle switch (params.type) { - .mouseMoved, .mouseWheel, .mouseReleased => return, + .mouseWheel, .mouseReleased => return, else => {}, } const bc = cmd.browser_context orelse return; const frame = bc.session.currentFrame() orelse return; - try frame.triggerMouseClick(params.x, params.y); + switch (params.type) { + .mousePressed => try frame.triggerMouseClick(params.x, params.y), + .mouseMoved => try frame.triggerMouseMove(params.x, params.y), + .mouseWheel, .mouseReleased => unreachable, + } // result already sent } @@ -119,3 +123,46 @@ fn insertText(cmd: *CDP.Command) !void { try cmd.sendResult(null, .{}); } + +const lp = @import("lightpanda"); +const testing = @import("../testing.zig"); + +test "cdp.input: dispatchMouseEvent mouseMoved fires hover events" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const frame = try bc.session.createPage(); + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: lp.js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + // Register listeners for the full enter sequence on #hoverTarget, then read + // its (faux-layout) position so we can target it precisely. + _ = try ls.local.compileAndRun( + \\const t = document.getElementById('hoverTarget'); + \\t.addEventListener('mousemove', () => { window.moved = true; }); + \\t.addEventListener('mouseenter', () => { window.entered = true; }); + , null); + + const rect_x = try (try ls.local.compileAndRun("document.getElementById('hoverTarget').getBoundingClientRect().x", null)).toF64(); + const rect_y = try (try ls.local.compileAndRun("document.getElementById('hoverTarget').getBoundingClientRect().y", null)).toF64(); + + try ctx.processMessage(.{ + .id = 1, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseMoved", .x = rect_x, .y = rect_y }, + }); + + const result = try ls.local.compileAndRun("window.hovered === true && window.entered === true && window.moved === true", null); + try testing.expect(result.isTrue()); +}