fix(actions): click dispatches full pointer/mouse sequence, not a bare click event

actions.click() previously dispatched a single untrusted-shaped "click"
MouseEvent directly on the target node, skipping pointerdown, mousedown,
pointerup, and mouseup entirely. Many real-world widgets — custom
autocomplete/combobox components in particular — open or otherwise react
on mousedown, not click alone, so this made them unreachable through the
click tool even though the element was correctly focused and targeted.

WebDriver.zig's own click() already implements the correct sequence
(pointerdown, mousedown, pointerup, mouseup, click) for testdriver's
click, with a comment explicitly contrasting it against a lone untrusted
click event. This change ports that same sequence into actions.click()
so the MCP/CDP "click" action produces the same event sequence a real
user click would.

Adds a mousedown assertion to the existing MCP Actions test
(mcp_actions.html + tools.zig) to catch a regression here; confirmed the
new assertion fails against the old implementation and passes against
this one. Full test suite (1339 tests) and zig fmt --check both pass.

Reproduced against a real, previously-untested production site
(a Wix-built autocomplete branch-selector widget) where click could
focus the input but never open its option list; a minimal local
reproduction (a mousedown-only widget) confirms the fix.
This commit is contained in:
Ramiro_quai committed 2026-08-30 04:32:17 -06:00
1 parent 5d5459fc8b
commit 3e8ff142ef
3 files changed
+43 -9

No files matched your search

+41 -7
View File
@@ -22,6 +22,7 @@ const DOMNode = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const PointerEvent = @import("webapi/event/PointerEvent.zig");
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Frame = @import("Frame.zig");
const Session = @import("Session.zig");
@@ -38,23 +39,56 @@ fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
};
}
pub fn click(node: *DOMNode, frame: *Frame) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
fn dispatchPointer(el: *Element, comptime typ: []const u8, button: i32, buttons: u16, frame: *Frame) !void {
const event: *PointerEvent = try .initTrusted(typ, .{
.bubbles = true,
.cancelable = true,
.composed = true,
.button = button,
.buttons = buttons,
.pointerId = 1,
.pointerType = "mouse",
.isPrimary = true,
}, frame);
frame._event_manager.dispatch(el.asEventTarget(), event.asEvent()) catch |err| {
lp.log.err(.app, "click failed", .{ .err = err, .type = typ });
return error.ActionFailed;
};
}
fn dispatchMouse(el: *Element, comptime typ: []const u8, button: i32, buttons: u16, frame: *Frame) !void {
const event: *MouseEvent = try .initTrusted(comptime .wrap(typ), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.button = button,
.buttons = buttons,
.detail = 1,
.clientX = 0,
.clientY = 0,
}, frame);
frame._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| {
lp.log.err(.app, "click failed", .{ .err = err });
frame._event_manager.dispatch(el.asEventTarget(), event.asEvent()) catch |err| {
lp.log.err(.app, "click failed", .{ .err = err, .type = typ });
return error.ActionFailed;
};
}
// A full trusted primary-button click sequence on the element, as a real user
// click would produce: pointerdown, mousedown, pointerup, mouseup, click.
// Unlike a single bare "click" event, many widgets (autocomplete/combobox
// components in particular) key their open/interaction behavior off
// mousedown or pointerdown, not click alone -- see WebDriver.zig's `click`,
// which already implements this same sequence for testdriver's `click`.
pub fn click(node: *DOMNode, frame: *Frame) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
try dispatchPointer(el, "pointerdown", 0, 1, frame);
try dispatchMouse(el, "mousedown", 0, 1, frame);
try dispatchPointer(el, "pointerup", 0, 0, frame);
try dispatchMouse(el, "mouseup", 0, 0, frame);
try dispatchMouse(el, "click", 0, 0, frame);
}
pub fn hover(node: *DOMNode, frame: *Frame) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<body>
<button id="btn" onclick="window.clicked = true;">Click Me</button>
<button id="btn" onmousedown="window.mousedowned = true;" onclick="window.clicked = true;">Click Me</button>
<input id="inp" oninput="window.inputVal = this.value" onchange="window.changed = true;">
<select id="sel" onchange="window.selChanged = this.value">
<option value="opt1">Option 1</option>
+1 -1
View File
@@ -1161,7 +1161,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
defer try_catch.deinit();
const result = try ls.local.exec(
\\ window.clicked === true && window.inputVal === 'hello' &&
\\ window.mousedowned === true && window.clicked === true && window.inputVal === 'hello' &&
\\ window.changed === true && window.selChanged === 'opt2' &&
\\ window.scrolled === true &&
\\ window.hovered === true &&