diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index 045d0d93d..60a764650 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -186,6 +186,10 @@ input_modifiers: if (lp.build_config.wpt_extensions) @import("frame/user_input.z
// The element the synthetic pointer is currently over
input_hover_target: ?*Element = null,
+// Per-gesture button state for the synthetic mouse pointer; see
+// user_input.PointerButtons.
+input_pointer: @import("frame/user_input.zig").PointerButtons = .{},
+
// Popup Frames opened by window.open. They are top-level browsing contexts
// (parent == null, no iframe element) but share this Page's factory, arena,
// and identity map.
diff --git a/src/browser/actions.zig b/src/browser/actions.zig
index 481d88e9a..d40df49a9 100644
--- a/src/browser/actions.zig
+++ b/src/browser/actions.zig
@@ -22,7 +22,6 @@ 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");
@@ -39,41 +38,6 @@ fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
};
}
-fn dispatch(el: *Element, event: *Event, comptime typ: []const u8, frame: *Frame) !bool {
- return frame._event_manager.dispatchCancelable(el.asEventTarget(), event) catch |err| {
- lp.log.err(.app, "click " ++ typ ++ " failed", .{ .err = err });
- return error.ActionFailed;
- };
-}
-
-fn dispatchPointer(el: *Element, comptime typ: []const u8, buttons: u16, detail: u32, frame: *Frame) !bool {
- const event: *PointerEvent = try .initTrusted(typ, .{
- .bubbles = true,
- .cancelable = true,
- .composed = true,
- .buttons = buttons,
- .detail = detail,
- .pointerId = 1,
- .pointerType = "mouse",
- .isPrimary = true,
- .pressure = if (buttons != 0) 0.5 else 0.0,
- }, frame);
- return dispatch(el, event.asEvent(), typ, frame);
-}
-
-fn dispatchMouse(el: *Element, comptime typ: []const u8, buttons: u16, frame: *Frame) !bool {
- const event: *MouseEvent = try .initTrusted(comptime .wrap(typ), .{
- .bubbles = true,
- .cancelable = true,
- .composed = true,
- .buttons = buttons,
- .detail = 1,
- }, frame);
- return dispatch(el, event.asEvent(), typ, frame);
-}
-
-/// The trusted primary-button gesture a real user click produces; widgets key
-/// off pointerdown/mousedown, not click alone.
pub fn click(node: *DOMNode, frame: *Frame) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
@@ -83,25 +47,10 @@ pub fn click(node: *DOMNode, frame: *Frame) !void {
Frame.user_input.updateHoverTarget(frame, el, .{ .with_pointer = true });
- // preventDefault() on pointerdown suppresses both compatibility mouse
- // events (mousedown and mouseup) for the rest of this gesture; click
- // still fires.
- const suppress_mouse = try dispatchPointer(el, "pointerdown", 1, 0, frame);
- if (!suppress_mouse) {
- const suppress_focus = try dispatchMouse(el, "mousedown", 1, frame);
- if (!suppress_focus) {
- Frame.user_input.focusForMouseDown(frame, el) catch |err| {
- lp.log.warn(.app, "click mousedown focus", .{ .err = err });
- };
- }
- }
-
- _ = try dispatchPointer(el, "pointerup", 0, 0, frame);
- if (!suppress_mouse) {
- _ = try dispatchMouse(el, "mouseup", 0, frame);
- }
-
- _ = try dispatchPointer(el, "click", 0, 1, frame);
+ Frame.user_input.triggerClick(frame, el, .{}) catch |err| {
+ lp.log.err(.app, "click failed", .{ .err = err });
+ return error.ActionFailed;
+ };
}
pub fn hover(node: *DOMNode, frame: *Frame) !void {
diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig
index 5ac120f65..bd39e063e 100644
--- a/src/browser/frame/user_input.zig
+++ b/src/browser/frame/user_input.zig
@@ -180,25 +180,161 @@ fn dispatchBoundaryEvent(frame: *Frame, target: *Element, comptime mouse_typ: []
};
}
-/// Dispatch a single trusted mouse event of the given type on `target`, carrying
-/// the pressed button and pointer position. `detail` is the click count (used for
-/// click/dblclick); 0 for events where it does not apply. Reports whether the
-/// event was cancelled via preventDefault().
-fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, x: f64, y: f64, button: i32, detail: u32) !bool {
+/// Event fields for the trusted mouse/pointer dispatchers, bundled so `button`
+/// (which button changed) and `buttons` (the held mask) can't be transposed.
+const PointerInput = struct {
+ x: f64,
+ y: f64,
+ button: i32 = mouse_button.main,
+ buttons: u16 = 0,
+ detail: u32 = 0,
+ modifiers: Modifiers = .{},
+};
+
+/// Dispatch a trusted mouse event; returns whether preventDefault() cancelled it.
+fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, in: PointerInput) !bool {
const event: *MouseEvent = try .initTrusted(comptime .wrap(typ), .{
.bubbles = true,
.cancelable = true,
.composed = true,
- .clientX = x,
- .clientY = y,
- .button = button,
- .detail = detail,
+ .clientX = in.x,
+ .clientY = in.y,
+ .button = in.button,
+ .buttons = in.buttons,
+ .detail = in.detail,
+ .ctrlKey = in.modifiers.ctrl,
+ .shiftKey = in.modifiers.shift,
+ .altKey = in.modifiers.alt,
+ .metaKey = in.modifiers.meta,
}, frame);
return frame._event_manager.dispatchCancelable(target.asEventTarget(), event.asEvent());
}
-pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void {
- const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse return;
+/// Dispatch a trusted pointer event (always mouse-sourced: pointerType,
+/// pointerId, isPrimary fixed); returns whether preventDefault() cancelled it.
+fn dispatchPointerEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, in: PointerInput) !bool {
+ const event: *PointerEvent = try .initTrusted(typ, .{
+ .bubbles = true,
+ .cancelable = true,
+ .composed = true,
+ .clientX = in.x,
+ .clientY = in.y,
+ .button = in.button,
+ .buttons = in.buttons,
+ .detail = in.detail,
+ .pointerId = 1,
+ .pointerType = "mouse",
+ .isPrimary = true,
+ .pressure = if (in.buttons != 0) 0.5 else 0.0,
+ .ctrlKey = in.modifiers.ctrl,
+ .shiftKey = in.modifiers.shift,
+ .altKey = in.modifiers.alt,
+ .metaKey = in.modifiers.meta,
+ }, frame);
+ return frame._event_manager.dispatchCancelable(target.asEventTarget(), event.asEvent());
+}
+
+/// MouseEvent/PointerEvent.buttons bitmask for a MouseEvent.button value.
+/// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
+pub fn buttonsBitmask(button: i32) u16 {
+ return switch (button) {
+ mouse_button.main => 1,
+ mouse_button.secondary => 2,
+ mouse_button.auxiliary => 4,
+ mouse_button.fourth => 8,
+ mouse_button.fifth => 16,
+ else => 0,
+ };
+}
+
+/// Held-button mask and suppression flag for the single synthetic mouse
+/// pointer, tracked across the split press/release messages of one gesture so
+/// a chord is told apart from a new gesture.
+pub const PointerButtons = struct {
+ /// Mask of buttons currently held.
+ held: u16 = 0,
+ /// Whether the gesture's opening pointerdown suppressed the compat mouse
+ /// events; held for the whole gesture so each split message reads it here.
+ mousedown_suppressed: bool = false,
+
+ /// `starts_gesture` is false for a chorded press (another button held).
+ pub fn press(self: *PointerButtons, button: i32) struct { starts_gesture: bool, held: u16 } {
+ const bit = buttonsBitmask(button);
+ const starts_gesture = self.held & ~bit == 0;
+ self.held |= bit;
+ return .{ .starts_gesture = starts_gesture, .held = self.held };
+ }
+
+ /// `ends_gesture` is the last held button releasing, which clears the
+ /// suppression flag; `was_suppressed` is that flag for this gesture.
+ pub fn release(self: *PointerButtons, button: i32) struct { ends_gesture: bool, held: u16, was_suppressed: bool } {
+ const was_suppressed = self.mousedown_suppressed;
+ self.held &= ~buttonsBitmask(button);
+ const ends_gesture = self.held == 0;
+ if (ends_gesture) self.mousedown_suppressed = false;
+ return .{ .ends_gesture = ends_gesture, .held = self.held, .was_suppressed = was_suppressed };
+ }
+
+ /// Discards an in-progress gesture (a press/release that hit no element).
+ pub fn reset(self: *PointerButtons) void {
+ self.* = .{};
+ }
+};
+
+const PressResult = struct {
+ /// pointerdown's preventDefault() suppressed the compat mousedown here and
+ /// the paired mouseup on release.
+ suppress_mouse: bool,
+ /// mousedown's focus default action is suppressed (always when
+ /// suppress_mouse is).
+ suppress_focus: bool,
+};
+
+/// pointerdown, then mousedown unless pointerdown was cancelled. `in.detail`
+/// applies to mousedown only.
+fn dispatchPointerPress(frame: *Frame, target: *Element, in: PointerInput) !PressResult {
+ var pointer = in;
+ pointer.detail = 0;
+ const suppress_mouse = try dispatchPointerEventOn(frame, target, "pointerdown", pointer);
+ if (suppress_mouse) {
+ return .{ .suppress_mouse = true, .suppress_focus = true };
+ }
+ const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", in);
+ return .{ .suppress_mouse = false, .suppress_focus = suppress_focus };
+}
+
+/// pointerup, then mouseup unless the gesture's pointerdown was cancelled.
+/// `in.detail` applies to mouseup only.
+fn dispatchPointerRelease(frame: *Frame, target: *Element, in: PointerInput, suppress_mouse: bool) !void {
+ var pointer = in;
+ pointer.detail = 0;
+ _ = try dispatchPointerEventOn(frame, target, "pointerup", pointer);
+ if (suppress_mouse == false) {
+ _ = try dispatchMouseEventOn(frame, target, "mouseup", in);
+ }
+}
+
+/// The trusted primary-button gesture a real user click produces; widgets key
+/// off pointerdown/mousedown, not click alone. A focus failure is logged, not
+/// returned.
+pub fn triggerClick(frame: *Frame, target: *Element, modifiers: Modifiers) !void {
+ const press = try dispatchPointerPress(frame, target, .{ .x = 0, .y = 0, .buttons = 1, .detail = 1, .modifiers = modifiers });
+ if (press.suppress_focus == false) {
+ focusForMouseDown(frame, target) catch |err| log.warn(.app, "click mousedown focus", .{ .err = err });
+ }
+
+ const up: PointerInput = .{ .x = 0, .y = 0, .detail = 1, .modifiers = modifiers };
+ try dispatchPointerRelease(frame, target, up, press.suppress_mouse);
+ // click is a PointerEvent, matching HTMLElement.click().
+ _ = try dispatchPointerEventOn(frame, target, "click", up);
+}
+
+pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count: i32) !void {
+ const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse {
+ // Don't leave a prior gesture's state for the next message to misread.
+ frame.page.input_pointer.reset();
+ return;
+ };
if (comptime lp.IS_DEBUG) {
log.debug(.frame, "frame mouse press", .{
.url = frame.url,
@@ -209,9 +345,31 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void {
.type = frame._type,
});
}
- const suppressed = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0);
- if (!suppressed) {
- try focusForMouseDown(frame, target);
+
+ const gesture = frame.page.input_pointer.press(button);
+ // clickCount 0 (omitted) stays 0, not forced to 1: Chrome and Firefox
+ // both fire mousedown with detail 0 in that case.
+ const detail: u32 = if (click_count > 0) @intCast(click_count) else 0;
+ const in: PointerInput = .{ .x = x, .y = y, .button = button, .buttons = gesture.held, .detail = detail };
+
+ if (gesture.starts_gesture) {
+ // Stash the pointerdown outcome before the fallible focus call: the
+ // release half is a separate message and can't observe it otherwise.
+ const press = try dispatchPointerPress(frame, target, in);
+ frame.page.input_pointer.mousedown_suppressed = press.suppress_mouse;
+ if (press.suppress_focus == false) {
+ try focusForMouseDown(frame, target);
+ }
+ } else {
+ // A chorded press is a buttons-mask change (pointermove), not a second
+ // pointerdown: https://www.w3.org/TR/pointerevents3/#chorded-button-interactions
+ _ = try dispatchPointerEventOn(frame, target, "pointermove", .{ .x = x, .y = y, .button = button, .buttons = gesture.held });
+ if (frame.page.input_pointer.mousedown_suppressed == false) {
+ const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", in);
+ if (suppress_focus == false) {
+ try focusForMouseDown(frame, target);
+ }
+ }
}
}
@@ -240,6 +398,13 @@ pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void {
}
pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_count: i32) !void {
+ // Consume the state before any early return, so a release that misses
+ // every element can't leave it for the next message to misread.
+ const gesture = frame.page.input_pointer.release(button);
+ const remaining = gesture.held;
+ const ends_gesture = gesture.ends_gesture;
+ const was_suppressed = gesture.was_suppressed;
+
const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse return;
if (comptime lp.IS_DEBUG) {
log.debug(.frame, "frame mouse release", .{
@@ -254,19 +419,28 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou
const detail: u32 = if (click_count > 0) @intCast(click_count) else 1;
- _ = try dispatchMouseEventOn(frame, target, "mouseup", x, y, button, detail);
+ if (ends_gesture) {
+ try dispatchPointerRelease(frame, target, .{ .x = x, .y = y, .button = button, .detail = detail }, was_suppressed);
+ } else {
+ // A chorded release (another button still held) is a buttons-mask
+ // change, not pointerup.
+ _ = try dispatchPointerEventOn(frame, target, "pointermove", .{ .x = x, .y = y, .button = button, .buttons = remaining });
+ if (!was_suppressed) {
+ _ = try dispatchMouseEventOn(frame, target, "mouseup", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail });
+ }
+ }
// After mouseup, the activation event depends on the button.
switch (button) {
mouse_button.main => {
- _ = try dispatchMouseEventOn(frame, target, "click", x, y, button, detail);
+ _ = try dispatchPointerEventOn(frame, target, "click", .{ .x = x, .y = y, .buttons = remaining, .detail = detail });
// A second click in quick succession also fires dblclick.
if (click_count == 2) {
- _ = try dispatchMouseEventOn(frame, target, "dblclick", x, y, button, detail);
+ _ = try dispatchMouseEventOn(frame, target, "dblclick", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail });
}
},
- mouse_button.auxiliary => _ = try dispatchMouseEventOn(frame, target, "auxclick", x, y, button, detail),
- mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", x, y, button, detail),
+ mouse_button.auxiliary => _ = try dispatchMouseEventOn(frame, target, "auxclick", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail }),
+ mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail }),
else => {},
}
}
diff --git a/src/browser/tests/mcp_actions.html b/src/browser/tests/mcp_actions.html
index 01f38ce6e..c6e409ce5 100644
--- a/src/browser/tests/mcp_actions.html
+++ b/src/browser/tests/mcp_actions.html
@@ -27,6 +27,7 @@
+
Focus me
Not focusable
@@ -34,7 +35,7 @@
window.seq = [];
const btn = document.getElementById('btn');
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
- btn.addEventListener(t, function(e) { window.seq.push([e.type, e.button, e.buttons, e.pointerType, e.isTrusted].join(':')); });
+ btn.addEventListener(t, function(e) { window.seq.push([e.type, e.button, e.buttons, e.detail, e.pointerType, e.isTrusted].join(':')); });
}
// Regression coverage for pointerdown's preventDefault() suppressing
@@ -46,6 +47,18 @@
btnPreventDefault.addEventListener(t, function(e) { window.seqPrevented.push(e.type); });
}
+ // Regression coverage for a multi-button chord: the left button's
+ // pointerdown is cancelled, then right is pressed while left is
+ // still held. Only the left press/release should be full
+ // pointerdown/pointerup; the chorded right press/release in between
+ // must report as pointermove, not a second pointerdown/pointerup.
+ window.seqChord = [];
+ const btnChord = document.getElementById('btnChord');
+ btnChord.addEventListener('pointerdown', function(e) { if (e.button === 0) e.preventDefault(); });
+ for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'pointermove', 'click', 'auxclick', 'contextmenu']) {
+ btnChord.addEventListener(t, function(e) { window.seqChord.push([e.type, e.buttons].join(':')); });
+ }
+
window.disabledEvents = [];
const btnDisabled = document.getElementById('btnDisabled');
for (const t of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig
index da36d393e..2d2df2341 100644
--- a/src/browser/webapi/WebDriver.zig
+++ b/src/browser/webapi/WebDriver.zig
@@ -67,11 +67,10 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void {
}
}
- dispatchPointer(element, "pointerdown", 0, 1, frame);
- _ = dispatchMouse(element, "mousedown", 0, 1, 1, frame);
- dispatchPointer(element, "pointerup", 0, 0, frame);
- _ = dispatchMouse(element, "mouseup", 0, 0, 1, frame);
- _ = dispatchMouse(element, "click", 0, 0, 1, frame);
+ // A dispatch error must never reject the testdriver command.
+ Frame.user_input.triggerClick(frame, element, frame._page.input_modifiers) catch |err| {
+ log.warn(.app, "webdriver click", .{ .err = err });
+ };
}
const WebDriverCookie = struct {
@@ -281,18 +280,18 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void {
const el = target orelse continue;
const button = readI32(action, "button", 0);
pressed = true;
- pressed_mask = buttonsMask(button);
+ pressed_mask = Frame.user_input.buttonsBitmask(button);
down_target = el;
if (last_click_target == el and last_click_button == button) {
click_count += 1;
} else {
click_count = 1;
}
- dispatchPointer(el, "pointerdown", button, buttonsMask(button), frame);
+ dispatchPointer(el, "pointerdown", button, Frame.user_input.buttonsBitmask(button), frame);
if (is_touch) {
dispatchTouch(el, "touchstart", frame);
} else {
- const suppressed = dispatchMouse(el, "mousedown", button, buttonsMask(button), click_count, frame);
+ const suppressed = dispatchMouse(el, "mousedown", button, Frame.user_input.buttonsBitmask(button), click_count, frame);
if (!suppressed) {
Frame.user_input.focusForMouseDown(frame, el) catch |err| {
log.warn(.app, "webdriver mousedown focus", .{ .err = err });
@@ -331,19 +330,6 @@ fn performPointerSource(source: js.Object, frame: *Frame) !void {
}
}
-// The `buttons` bitmask bit for a WebDriver button number: the flag order does
-// not follow the button numbering (left=1, right=2, middle=4).
-fn buttonsMask(button: i32) u16 {
- return switch (button) {
- 0 => 1,
- 1 => 4,
- 2 => 2,
- 3 => 8,
- 4 => 16,
- else => 0,
- };
-}
-
// A click whose mousedown and mouseup landed on different elements fires at
// their nearest common inclusive ancestor element.
fn commonClickTarget(down: *Element, up: *Element) *Element {
diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig
index 048a01bd6..f965b02f4 100644
--- a/src/mcp/tools.zig
+++ b/src/mcp/tools.zig
@@ -1292,8 +1292,8 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
const result = try ls.local.exec(
\\ JSON.stringify(window.seq) === JSON.stringify([
- \\ 'pointerdown:0:1:mouse:true', 'mousedown:0:1::true',
- \\ 'pointerup:0:0:mouse:true', 'mouseup:0:0::true', 'click:0:0:mouse:true'
+ \\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:1::true',
+ \\ 'pointerup:0:0:0:mouse:true', 'mouseup:0:0:1::true', 'click:0:0:1:mouse:true'
\\ ]) &&
\\ JSON.stringify(window.seqPrevented) === JSON.stringify(['pointerdown', 'pointerup', 'click']) &&
\\ JSON.stringify(window.disabledEvents) === '[]' &&
diff --git a/src/server/bidi/input.zig b/src/server/bidi/input.zig
index 527beb918..7762dfaef 100644
--- a/src/server/bidi/input.zig
+++ b/src/server/bidi/input.zig
@@ -570,7 +570,7 @@ fn dispatch(bidi: *BiDi, frame: *Frame, source: *Source, action: *const Action)
pointer.last_click_x = pointer.x;
pointer.last_click_y = pointer.y;
- try user_input.triggerMousePress(frame, pointer.x, pointer.y, button);
+ try user_input.triggerMousePress(frame, pointer.x, pointer.y, button, pointer.click_count);
},
.pointer_up => |button| {
const pointer = &source.pointer;
diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig
index 05a30355c..8fd444b41 100644
--- a/src/server/cdp/domains/input.zig
+++ b/src/server/cdp/domains/input.zig
@@ -125,7 +125,7 @@ fn dispatchMouseEvent(cmd: *CDP.Command) !void {
};
switch (params.type) {
- .mousePressed => try Frame.user_input.triggerMousePress(frame, params.x, params.y, button),
+ .mousePressed => try Frame.user_input.triggerMousePress(frame, params.x, params.y, button, params.clickCount),
.mouseReleased => try Frame.user_input.triggerMouseRelease(frame, params.x, params.y, button, params.clickCount),
.mouseMoved => try Frame.user_input.triggerMouseMove(frame, params.x, params.y),
.mouseWheel => try Frame.user_input.triggerMouseWheel(frame, params.x, params.y, params.deltaX, params.deltaY),
@@ -596,6 +596,399 @@ test "cdp.input: dispatchMouseEvent right button fires contextmenu, double-click
try testing.expect(result.isTrue());
}
+// A CDP press/release pair fires the full pointer/mouse sequence, with
+// mousedown carrying the message's clickCount as its detail (matches Chrome).
+test "cdp.input: dispatchMouseEvent mousePressed/mouseReleased fires the full pointer/mouse sequence" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+
+ const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
+ try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
+ const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+
+ const result = try ls.local.compileAndRun(
+ \\JSON.stringify(window.seq) === JSON.stringify([
+ \\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:1::true',
+ \\ 'pointerup:0:0:0:mouse:true', 'mouseup:0:0:1::true', 'click:0:0:1:mouse:true'
+ \\])
+ , null);
+ try testing.expect(result.isTrue());
+}
+
+// clickCount 0 (omitted) is preserved as mousedown's detail, not forced to 1:
+// Chrome and Firefox both fire detail 0 there, distinct from a detail-1 click.
+test "cdp.input: dispatchMouseEvent mousePressed's mousedown detail matches the message's clickCount" {
+ const cases = .{
+ .{ .click_count = 0, .expect_detail = 0 },
+ .{ .click_count = 1, .expect_detail = 1 },
+ .{ .click_count = 2, .expect_detail = 2 },
+ };
+ inline for (cases) |case| {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+
+ const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
+ try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
+ const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = case.click_count },
+ });
+
+ const expected = std.fmt.comptimePrint("'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:{d}::true'", .{case.expect_detail});
+ const script = std.fmt.comptimePrint(
+ "JSON.stringify(window.seq) === JSON.stringify([{s}])",
+ .{expected},
+ );
+ const result = try ls.local.compileAndRun(script, null);
+ try testing.expect(result.isTrue());
+ }
+}
+
+// clickCount reaches the release half too: a clickCount-2 pair carries
+// detail 2 on mousedown/mouseup/click and fires dblclick.
+test "cdp.input: clickCount 2 on press and release puts detail 2 on mousedown, mouseup, click and fires dblclick" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+
+ const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
+ try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ _ = try ls.local.compileAndRun(
+ \\window.sawDbl = false;
+ \\document.getElementById('btn').addEventListener('dblclick', () => { window.sawDbl = true; });
+ , null);
+
+ const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
+ const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 2 },
+ });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 2 },
+ });
+
+ const result = try ls.local.compileAndRun(
+ \\JSON.stringify(window.seq) === JSON.stringify([
+ \\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:2::true',
+ \\ 'pointerup:0:0:0:mouse:true', 'mouseup:0:0:2::true', 'click:0:0:2:mouse:true'
+ \\]) && window.sawDbl === true
+ , null);
+ try testing.expect(result.isTrue());
+}
+
+// clickCount must reach the chorded-press mousedown, not only the first press.
+test "cdp.input: a chorded mousedown carries the press message's clickCount as its detail" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+
+ const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
+ try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ const rect_x = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().x", null)).toF64();
+ const rect_y = try (try ls.local.compileAndRun("document.getElementById('btn').getBoundingClientRect().y", null)).toF64();
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 2 },
+ });
+
+ const result = try ls.local.compileAndRun(
+ \\JSON.stringify(window.seq) === JSON.stringify([
+ \\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:1::true',
+ \\ 'mousedown:2:3:2::true'
+ \\])
+ , null);
+ try testing.expect(result.isTrue());
+}
+
+// A pointerdown cancelled on the press message must still suppress mouseup on
+// the separate release message, with no state carried by the caller.
+test "cdp.input: a cancelled pointerdown suppresses mousedown and mouseup across the split mousePressed/mouseReleased calls" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+
+ const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
+ try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ const rect_x = try (try ls.local.compileAndRun("document.getElementById('btnPreventDefault').getBoundingClientRect().x", null)).toF64();
+ const rect_y = try (try ls.local.compileAndRun("document.getElementById('btnPreventDefault').getBoundingClientRect().y", null)).toF64();
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+
+ const result = try ls.local.compileAndRun(
+ \\JSON.stringify(window.seqPrevented) === JSON.stringify(['pointerdown', 'pointerup', 'click'])
+ , null);
+ try testing.expect(result.isTrue());
+}
+
+// Asserts only the pointer events: pointerdown/pointerup fire at the mask's
+// 0/nonzero transitions and a mid-gesture button change is a pointermove (the
+// activation order below is a pre-existing, non-spec deviation from Chrome).
+test "cdp.input: a mouse chord fires pointermove for the mid-gesture button change, not a second pointerdown/pointerup" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+
+ const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
+ try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ const rect_x = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().x", null)).toF64();
+ const rect_y = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().y", null)).toF64();
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 3,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 4,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+
+ const result = try ls.local.compileAndRun(
+ \\JSON.stringify(window.seqChord) === JSON.stringify([
+ \\ 'pointerdown:1', 'pointermove:3', 'pointermove:1', 'contextmenu:1', 'pointerup:0', 'click:0'
+ \\])
+ , null);
+ try testing.expect(result.isTrue());
+}
+
+// A primary release mid-chord reports the still-held mask on its click, not 0
+// (confirmed against Chrome and Firefox).
+test "cdp.input: a primary click fired mid-chord carries the still-held buttons mask" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+
+ const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
+ try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ const rect_x = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().x", null)).toF64();
+ const rect_y = try (try ls.local.compileAndRun("document.getElementById('btnChord').getBoundingClientRect().y", null)).toF64();
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
+ });
+ // Left releases first this time, while right is still held.
+ try ctx.processMessage(.{
+ .id = 3,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "left", .clickCount = 1 },
+ });
+ try ctx.processMessage(.{
+ .id = 4,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mouseReleased", .x = rect_x, .y = rect_y, .button = "right", .clickCount = 1 },
+ });
+
+ const result = try ls.local.compileAndRun(
+ \\JSON.stringify(window.seqChord) === JSON.stringify([
+ \\ 'pointerdown:1', 'pointermove:3', 'pointermove:2', 'click:2', 'pointerup:0', 'contextmenu:0'
+ \\])
+ , null);
+ try testing.expect(result.isTrue());
+}
+
+test "cdp.input: chorded mousedown focuses its target unless pointerdown or mousedown was cancelled" {
+ inline for (.{ "none", "mousedown", "pointerdown" }) |cancel| {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+ try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{ .reason = .address_bar, .kind = .{ .push = null } });
+ try testing.waitForPage(bc);
+
+ 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();
+
+ _ = try ls.local.compileAndRun("window.cancelAt = '" ++ cancel ++ "';" ++
+ \\document.getElementById('inp').focus();
+ \\document.getElementById('inp').addEventListener('pointerdown', e => {
+ \\ if (window.cancelAt === 'pointerdown') e.preventDefault();
+ \\});
+ \\window.chordMouseDowns = 0;
+ \\document.getElementById('keyTarget').addEventListener('mousedown', e => {
+ \\ window.chordMouseDowns++;
+ \\ if (window.cancelAt === 'mousedown') e.preventDefault();
+ \\});
+ , null);
+
+ const first_x = try (try ls.local.compileAndRun("document.getElementById('inp').getBoundingClientRect().x", null)).toF64();
+ const first_y = try (try ls.local.compileAndRun("document.getElementById('inp').getBoundingClientRect().y", null)).toF64();
+ const second_x = try (try ls.local.compileAndRun("document.getElementById('keyTarget').getBoundingClientRect().x", null)).toF64();
+ const second_y = try (try ls.local.compileAndRun("document.getElementById('keyTarget').getBoundingClientRect().y", null)).toF64();
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = first_x, .y = first_y, .button = "left" },
+ });
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchMouseEvent",
+ .params = .{ .type = "mousePressed", .x = second_x, .y = second_y, .button = "right" },
+ });
+
+ // Check before any release/click activation can change focus.
+ const result = try ls.local.compileAndRun(
+ \\document.activeElement.id === (window.cancelAt === 'none' ? 'keyTarget' : 'inp') &&
+ \\window.chordMouseDowns === (window.cancelAt === 'pointerdown' ? 0 : 1)
+ , null);
+ try testing.expect(result.isTrue());
+ }
+}
+
test "cdp.input: dispatchKeyEvent Tab runs sequential focus navigation" {
var ctx = try testing.context();
defer ctx.deinit();