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();