Merge pull request #3507 from R4m1r0qu41/refactor/shared-click-dispatch-v2

Share pointer/mouse click dispatch across click paths
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-17 08:20:41 +08:00
commit e13f10841f
8 files changed
+619 -100

No files matched your search

+4
View File
@@ -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.
+4 -55
View File
@@ -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 {
+193 -19
View File
@@ -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 => {},
}
}
+14 -1
View File
@@ -27,6 +27,7 @@
<input id="chk" type="checkbox">
<input id="rad" type="radio" name="group1">
<button id="btnPreventDefault">Prevent Default</button>
<button id="btnChord">Chord</button>
<button id="btnDisabled" disabled>Disabled</button>
<div id="focusTarget" tabindex="0" onclick="window.focusTargetFocused = document.activeElement === this;">Focus me</div>
<div id="plain" onclick="window.plainBlurred = document.activeElement === document.body;">Not focusable</div>
@@ -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']) {
+7 -21
View File
@@ -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 {
+2 -2
View File
@@ -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) === '[]' &&
+1 -1
View File
@@ -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;
+394 -1
View File
@@ -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();